Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions packages/lib/src/ServerRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type { AnyFunction, StringRecord } from "./types";
import { createObserver } from "./createObserver";

interface Route<Handler extends AnyFunction> {
regex: RegExp;
paramNames: string[];
handler: Handler;
params?: StringRecord;
}

type QueryPayload = Record<string, string | number | undefined>;

export type RouterInstance<T extends AnyFunction> = InstanceType<typeof ServerRouter<T>>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class ServerRouter<Handler extends (...args: any[]) => any> {
readonly #routes: Map<string, Route<Handler>>;
readonly #observer = createObserver();
readonly #baseUrl;
#currentQuery: StringRecord;
#route: null | (Route<Handler> & { params: StringRecord; path: string });

constructor(baseUrl = "") {
this.#routes = new Map();
this.#route = null;
this.#baseUrl = baseUrl.replace(/\/$/, "");
this.#currentQuery = {};
}

get query(): StringRecord {
return this.#currentQuery;
}

set query(newQuery: QueryPayload) {
const pathname = this.#route?.path ?? "/";
const newUrl = ServerRouter.getUrl(newQuery, pathname, this.#baseUrl);
this.push(newUrl);
}

get params() {
return this.#route?.params ?? {};
}

get route() {
return this.#route;
}

get target() {
return this.#route?.handler;
}

addRoute(path: string, handler: Handler) {
// 경로 패턴을 정규식으로 변환
const paramNames: string[] = [];
const regexPath = path
.replace(/:\w+/g, (match) => {
paramNames.push(match.slice(1)); // ':id' -> 'id'
return "([^/]+)";
})
.replace(/\//g, "\\/");

const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`);

this.#routes.set(path, {
regex,
paramNames,
handler,
});
}

#findRoute(url = "/", origin = "http://localhost") {
const { pathname } = new URL(url, origin);
for (const [routePath, route] of this.#routes) {
const match = pathname.match(route.regex);
if (match) {
// 매치된 파라미터들을 객체로 변환
const params: StringRecord = {};
route.paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});

return {
...route,
params,
path: routePath,
};
}
}
return null;
}

push(url: string = "/") {
try {
this.#route = this.#findRoute(url);
} catch (error) {
console.error("라우터 네비게이션 오류:", error);
}
}

start(url = "/", query = {}) {
this.#route = this.#findRoute(url);
this.#currentQuery = query;
}

subscribe = (listener: () => void) => {
return this.#observer.subscribe(listener);
};

static parseQuery = (search: string = "") => {
const params = new URLSearchParams(search);
const query: StringRecord = {};
for (const [key, value] of params) {
query[key] = value;
}
return query;
};

static stringifyQuery = (query: QueryPayload) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== null && value !== undefined && value !== "") {
params.set(key, String(value));
}
}
return params.toString();
};

static getUrl = (newQuery: QueryPayload, pathname = "/", baseUrl = "") => {
const currentQuery = ServerRouter.parseQuery();
const updatedQuery = { ...currentQuery, ...newQuery };

// 빈 값들 제거
Object.keys(updatedQuery).forEach((key) => {
if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") {
delete updatedQuery[key];
}
});

const queryString = ServerRouter.stringifyQuery(updatedQuery);
return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
};
}
19 changes: 15 additions & 4 deletions packages/lib/src/createStorage.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { createObserver } from "./createObserver.ts";

export const createStorage = <T>(key: string, storage = window.localStorage) => {
const memoryStorage = () => {
const storage = new Map();

return {
getItem: (key: string) => storage.get(key),
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
};
};

export const createStorage = <T>(
key: string,
storage = typeof window === "undefined" ? memoryStorage() : window.localStorage,
) => {
let data: T | null = JSON.parse(storage.getItem(key) ?? "null");
const { subscribe, notify } = createObserver();

const get = () => data;

const set = (value: T) => {
try {
data = value;
Expand All @@ -15,7 +28,6 @@ export const createStorage = <T>(key: string, storage = window.localStorage) =>
console.error(`Error setting storage item for key "${key}":`, error);
}
};

const reset = () => {
try {
data = null;
Expand All @@ -25,6 +37,5 @@ export const createStorage = <T>(key: string, storage = window.localStorage) =>
console.error(`Error removing storage item for key "${key}":`, error);
}
};

return { get, set, reset, subscribe };
};
12 changes: 10 additions & 2 deletions packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import type { RouterInstance } from "../Router";
import type { RouterInstance as ServerRouterInstance } from "../ServerRouter";
import type { AnyFunction } from "../types";
import { useSyncExternalStore } from "react";
import { useShallowSelector } from "./useShallowSelector";

const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useRouter = <T extends RouterInstance<AnyFunction>, S>(router: T, selector = defaultSelector<T, S>) => {
export const useRouter = <T extends RouterInstance<AnyFunction> | ServerRouterInstance<AnyFunction>, S>(
router: T,
selector = defaultSelector<T, S>,
) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(router.subscribe, () => shallowSelector(router));
return useSyncExternalStore(
router.subscribe,
() => shallowSelector(router),
() => shallowSelector(router), // getServerSnapshot - 서버에서도 같은 값 사용
);
};
3 changes: 1 addition & 2 deletions packages/lib/src/hooks/useStorage.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useSyncExternalStore } from "react";
import type { createStorage } from "../createStorage";

type Storage<T> = ReturnType<typeof createStorage<T>>;

export const useStorage = <T>(storage: Storage<T>) => {
return useSyncExternalStore(storage.subscribe, storage.get);
return useSyncExternalStore(storage.subscribe, storage.get, storage.get);
};
8 changes: 5 additions & 3 deletions packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { createStore } from "../createStore";
import { useSyncExternalStore } from "react";
import { useShallowSelector } from "./useShallowSelector";

type Store<T> = ReturnType<typeof createStore<T>>;

const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState()));
return useSyncExternalStore(
store.subscribe,
() => shallowSelector(store.getState()),
() => shallowSelector(store.getState()),
);
};
1 change: 1 addition & 0 deletions packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from "./Router";
export { useStore, useStorage, useRouter, useAutoCallback } from "./hooks";
export * from "./equals";
export * from "./types";
export * from "./ServerRouter";
47 changes: 24 additions & 23 deletions packages/react/index.html
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--app-head-->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/src/styles.css">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: "#3b82f6",
secondary: "#6b7280"
}
}
}
};
</script>
</head>
<body class="bg-gray-50">
<div id="root"><!--app-html--></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--app-head-->
<!--app-data-->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/src/styles.css" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: "#3b82f6",
secondary: "#6b7280",
},
},
},
};
</script>
</head>
<body class="bg-gray-50">
<div id="root"><!--app-html--></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
81 changes: 61 additions & 20 deletions packages/react/server.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,73 @@
import express from "express";
import { renderToString } from "react-dom/server";
import { createElement } from "react";
import fs from "node:fs/promises";
import sirv from "sirv";
import compression from "compression";
import { createServer } from "vite";

const prod = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5174;
const templateHtml = prod ? await fs.readFile("./dist/react/index.html", "utf-8") : "";
const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/react/" : "/");

const app = express();
let vite = await import("vite");
let mockServer;

if (!prod) {
vite = await createServer({
server: { middlewareMode: true },
appType: "custom",
base,
});
const { mockServer: msw } = await vite.ssrLoadModule("./src/mocks/serverMock.ts");

mockServer = msw;
mockServer.listen({
onUnhandledRequest: "bypass", // 처리되지 않은 요청은 통과
});

app.use(vite.middlewares);
} else {
const { setupServer } = await import("msw/node");
const { handlers } = await import("./src/mocks/handlers.ts");
mockServer = setupServer(...handlers);
mockServer.listen({
onUnhandledRequest: "bypass",
});

app.use(compression());
app.use(base, sirv("./dist/react", { extensions: [] }));
}

app.get(/^(?!.*\/api).*/, async (req, res) => {
try {
let template;
let render;

if (!prod) {
template = await fs.readFile("./index.html", "utf-8");
template = await vite.transformIndexHtml(req.originalUrl, template);
render = (await vite.ssrLoadModule("/src/main-server.tsx")).render;
} else {
template = templateHtml;
render = (await import("./dist/react-ssr/main-server.js")).render;
}

const rendered = await render(req.originalUrl, req.query);

const html = template
.replace(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-data-->`, `<script>window.__INITIAL_DATA__ = ${rendered.data}</script>`)
.replace(`<!--app-html-->`, rendered.html ?? "");

app.get("*all", (req, res) => {
res.send(
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React SSR</title>
</head>
<body>
<div id="app">${renderToString(createElement("div", null, "안녕하세요"))}</div>
</body>
</html>
`.trim(),
);
res.status(200).set({ "Content-Type": "text/html" }).send(html);
} catch (e) {
vite?.ssrFixStacktrace(e);
console.log(e.stack);
res.status(500).end(e.stack);
}
});

// Start http server
app.listen(port, () => {
console.log(`React Server started at http://localhost:${port}`);
console.log(`React Server started at http://localhost:${port}${base}`);
});
Loading