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
2 changes: 0 additions & 2 deletions packages/lib/src/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ interface Route<Handler extends AnyFunction> {

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

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

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class Router<Handler extends (...args: any[]) => any> {
readonly #routes: Map<string, Route<Handler>>;
Expand Down
138 changes: 138 additions & 0 deletions packages/lib/src/ServerRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { createObserver } from "./createObserver";
import type { AnyFunction, StringRecord } from "./types";

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

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

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

#route: null | (Route<Handler> & { params: StringRecord; path: string });
#currentUrl = "/";

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

get query(): StringRecord {
return ServerRouter.parseQuery(this.#currentUrl);
}

set query(newQuery: QueryPayload) {
const newUrl = ServerRouter.getUrl(newQuery, this.#currentUrl, this.#baseUrl);
this.push(newUrl);
}

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

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

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

readonly subscribe = this.#observer.subscribe;

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 = this.#baseUrl) {
const { pathname } = new URL(url, "http://localhost");
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) {
this.#currentUrl = url;
try {
// baseUrl이 없으면 자동으로 붙여줌
const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url);

this.#route = this.#findRoute(fullUrl);
this.#observer.notify();
} catch (error) {
console.error("라우터 네비게이션 오류:", error);
}
}

start() {
this.#route = this.#findRoute("/");
}

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 = (updatedQuery: QueryPayload, pathname = "/", baseUrl = "") => {
// 빈 값들 제거
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}` : ""}`;
};
}
16 changes: 12 additions & 4 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 { AnyFunction } from "../types";
import { useSyncExternalStore } from "react";
import { useShallowSelector } from "./useShallowSelector";
import type { AnyFunction, RouterInstance } from "../types";
import type { Router } from "../Router";
import type { ServerRouter } from "../ServerRouter";

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 = <R extends typeof Router | typeof ServerRouter, T extends RouterInstance<AnyFunction, R>, 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),
);
};
2 changes: 1 addition & 1 deletion packages/lib/src/hooks/useStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ 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);
};
6 changes: 5 additions & 1 deletion packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ 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 @@ -2,6 +2,7 @@ export * from "./createObserver";
export * from "./createStorage";
export * from "./createStore";
export * from "./Router";
export * from "./ServerRouter";
export { useStore, useStorage, useRouter, useAutoCallback } from "./hooks";
export * from "./equals";
export * from "./types";
8 changes: 8 additions & 0 deletions packages/lib/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { Router } from "./Router";
import type { ServerRouter } from "./ServerRouter";

export type StringRecord = Record<string, string>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyFunction = (...args: any[]) => any;

export type Selector<T, S = T> = (state: T) => S;

export type RouterInstance<
T extends AnyFunction,
R extends typeof Router<T> | typeof ServerRouter<T> = typeof Router<T>,
> = InstanceType<R>;
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-->
<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>
<!--app-data-->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
84 changes: 66 additions & 18 deletions packages/react/server.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,77 @@
import express from "express";
import { renderToString } from "react-dom/server";
import { createElement } from "react";
import fs from "node:fs/promises";
import path from "node:path";

const prod = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5174;
const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/react/" : "/");

const templateHtml = prod ? await fs.readFile("./dist/react/index.html", "utf-8") : "";

const app = express();

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(),
);
// 불필요한 요청 무시
app.get("/favicon.ico", (_, res) => {
res.status(204).end();
});
app.get("/.well-known/appspecific/com.chrome.devtools.json", (_, res) => {
res.status(204).end();
});

// Add Vite or respective production middlewares
/** @type {import('vite').ViteDevServer | undefined} */
let vite;
if (!prod) {
const { createServer } = await import("vite");
vite = await createServer({
server: { middlewareMode: true },
appType: "custom",
base,
});
app.use(vite.middlewares);
} else {
const compression = (await import("compression")).default;
const sirv = (await import("sirv")).default;
app.use(compression());
app.use(base, sirv("./dist/react", { extensions: [] }));
}

// Serve HTML
app.use("*all", async (req, res) => {
try {
const url = req.originalUrl.replace(base, "");
const pathname = path.normalize(`/${url.split("?")[0]}`);

/** @type {string} */
let template;
/** @type {import('./src/main-server.js').render} */
let render;
if (!prod) {
// Always read fresh template in development
template = await fs.readFile("./index.html", "utf-8");
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule("/src/main-server.js")).render;
} else {
template = templateHtml;
render = (await import("./dist/react-ssr/main-server.js")).render;
}

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

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

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
Expand Down
Loading
Loading