Skip to content

Commit 171138f

Browse files
committed
feat: ssr 라우터 매칭, window 우회
1 parent 9dcc660 commit 171138f

File tree

8 files changed

+139
-35
lines changed

8 files changed

+139
-35
lines changed

packages/lib/src/createStorage.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,33 @@
11
import { createObserver } from "./createObserver.ts";
22

3-
export const createStorage = <T>(key: string, storage = window.localStorage) => {
4-
let data: T | null = JSON.parse(storage.getItem(key) ?? "null");
3+
type StorageLike = {
4+
getItem(key: string): string | null;
5+
setItem(key: string, value: string): void;
6+
removeItem(key: string): void;
7+
};
8+
9+
const createMemoryStorage = (): StorageLike => {
10+
const map = new Map<string, string>();
11+
return {
12+
getItem: (k) => (map.has(k) ? map.get(k)! : null),
13+
setItem: (k, v) => void map.set(k, v),
14+
removeItem: (k) => void map.delete(k),
15+
};
16+
};
17+
18+
export const createStorage = <T>(key: string, storage?: StorageLike) => {
19+
const safeStorage: StorageLike =
20+
storage ?? (typeof window !== "undefined" && window.localStorage ? window.localStorage : createMemoryStorage());
21+
22+
let data: T | null = JSON.parse(safeStorage.getItem(key) ?? "null");
523
const { subscribe, notify } = createObserver();
624

725
const get = () => data;
826

927
const set = (value: T) => {
1028
try {
1129
data = value;
12-
storage.setItem(key, JSON.stringify(data));
30+
safeStorage.setItem(key, JSON.stringify(data));
1331
notify();
1432
} catch (error) {
1533
console.error(`Error setting storage item for key "${key}":`, error);
@@ -19,7 +37,7 @@ export const createStorage = <T>(key: string, storage = window.localStorage) =>
1937
const reset = () => {
2038
try {
2139
data = null;
22-
storage.removeItem(key);
40+
safeStorage.removeItem(key);
2341
notify();
2442
} catch (error) {
2543
console.error(`Error removing storage item for key "${key}":`, error);

packages/lib/src/hooks/useRouter.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,7 @@ const defaultSelector = <T, S = T>(state: T) => state as unknown as S;
77

88
export const useRouter = <T extends RouterInstance<AnyFunction>, S>(router: T, selector = defaultSelector<T, S>) => {
99
const shallowSelector = useShallowSelector(selector);
10-
return useSyncExternalStore(router.subscribe, () => shallowSelector(router));
10+
const getSnapshot = () => shallowSelector(router);
11+
const getServerSnapshot = getSnapshot; // SSR용 스냅샷
12+
return useSyncExternalStore(router.subscribe, getSnapshot, getServerSnapshot);
1113
};

packages/lib/src/hooks/useStore.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,7 @@ const defaultSelector = <T, S = T>(state: T) => state as unknown as S;
88

99
export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
1010
const shallowSelector = useShallowSelector(selector);
11-
return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState()));
11+
const getSnapshot = () => shallowSelector(store.getState());
12+
const getServerSnapshot = getSnapshot; // SSR 스냅샷
13+
return useSyncExternalStore(store.subscribe, getSnapshot, getServerSnapshot);
1214
};

packages/react/server.js

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,66 @@
1+
import fs from "node:fs/promises";
12
import express from "express";
2-
import { renderToString } from "react-dom/server";
3-
import { createElement } from "react";
43

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

8+
const templateHtml = prod ? await fs.readFile("./dist/react/index.html", "utf-8") : "";
9+
910
const app = express();
1011

11-
app.get("*all", (req, res) => {
12-
res.send(
13-
`
14-
<!DOCTYPE html>
15-
<html lang="en">
16-
<head>
17-
<meta charset="UTF-8" />
18-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
19-
<title>React SSR</title>
20-
</head>
21-
<body>
22-
<div id="app">${renderToString(createElement("div", null, "안녕하세요"))}</div>
23-
</body>
24-
</html>
25-
`.trim(),
26-
);
12+
/** @type {import('vite').ViteDevServer | undefined} */
13+
let vite;
14+
if (!prod) {
15+
const { createServer } = await import("vite");
16+
vite = await createServer({
17+
server: { middlewareMode: true },
18+
appType: "custom",
19+
base,
20+
});
21+
app.use(vite.middlewares);
22+
} else {
23+
const compression = (await import("compression")).default;
24+
const sirv = (await import("sirv")).default;
25+
app.use(compression());
26+
app.use(base, sirv("./dist/react", { extensions: [] }));
27+
}
28+
29+
app.use("*all", async (req, res) => {
30+
try {
31+
const url = req.originalUrl.replace(base, "");
32+
33+
/** @type {string} */
34+
let template;
35+
/** @type {import('./src/main-server.tsx').render} */
36+
let render;
37+
38+
if (!prod) {
39+
template = await fs.readFile("./index.html", "utf-8");
40+
template = await vite.transformIndexHtml(url, template);
41+
render = (await vite.ssrLoadModule("/src/main-server.tsx")).render;
42+
} else {
43+
template = templateHtml;
44+
render = (await import("./dist/react-ssr/main-server.js")).render;
45+
}
46+
47+
// 핵심: SSR 렌더 호출 후 템플릿 치환
48+
const { html, head, initialData } = await render(url, req.query);
49+
const initialDataScript = initialData ? `<script>window.__INITIAL_DATA__=${initialData}</script>` : "";
50+
51+
const finalHtml = template
52+
.replace("<!--app-html-->", html ?? "")
53+
.replace("<!--app-head-->", `${initialDataScript}${head ?? ""}`);
54+
55+
res.status(200).set({ "Content-Type": "text/html" }).send(finalHtml);
56+
} catch (e) {
57+
vite?.ssrFixStacktrace?.(e);
58+
console.error(e.stack || e);
59+
res.status(500).end(e.stack || String(e));
60+
}
2761
});
2862

29-
// Start http server
63+
// 서버 시작
3064
app.listen(port, () => {
3165
console.log(`React Server started at http://localhost:${port}`);
3266
});

packages/react/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { router, useCurrentPage } from "./router";
22
import { HomePage, NotFoundPage, ProductDetailPage } from "./pages";
33
import { useLoadCartStore } from "./entities";
44
import { ModalProvider, ToastProvider } from "./components";
5+
import type { FunctionComponent } from "react";
56

67
// 홈 페이지 (상품 목록)
78
router.addRoute("/", HomePage);
@@ -17,7 +18,7 @@ const CartInitializer = () => {
1718
* 전체 애플리케이션 렌더링
1819
*/
1920
export const App = () => {
20-
const PageComponent = useCurrentPage();
21+
const PageComponent = useCurrentPage() as FunctionComponent | null;
2122

2223
return (
2324
<>

packages/react/src/main-server.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
1-
export const render = async (url: string, query: Record<string, string>) => {
2-
console.log({ url, query });
3-
return "";
1+
import { renderToString } from "react-dom/server";
2+
3+
export const render = async () => {
4+
// 1) 라우터/앱 모듈 로드
5+
const { App } = await import("./App");
6+
const { router } = await import("./router");
7+
8+
// 2) 1단계: 서버 스텁은 URL 매칭 생략 → 다음 단계에서 추가 예정
9+
router.start();
10+
11+
// 3) 데이터 프리로딩/헤드 구성은 다음 단계에서 추가
12+
const initialData = null as unknown as string | null;
13+
const head = "";
14+
15+
// 4) React → HTML 문자열
16+
const html = renderToString(<App />);
17+
18+
return { html, head, initialData };
419
};
Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
1-
// 글로벌 라우터 인스턴스
1+
// 글로벌 라우터 인스턴스 (SSR 안전, 초간단 버전)
22
import { Router } from "@hanghae-plus/lib";
33
import { BASE_URL } from "../constants";
44
import type { FunctionComponent } from "react";
55

6-
export const router = new Router<FunctionComponent>(BASE_URL);
6+
const isClient = typeof window !== "undefined" && typeof document !== "undefined";
7+
8+
// 클라이언트: 실제 Router, 서버: 최소 스텁(no-op)
9+
export const router = isClient
10+
? new Router<FunctionComponent>(BASE_URL)
11+
: {
12+
// App.tsx에서 addRoute를 호출하므로 no-op 필요
13+
addRoute() {},
14+
// SSR 1단계에선 URL 매칭 생략 → 다음 단계에서 확장
15+
start() {},
16+
push() {},
17+
subscribe() {
18+
return () => {};
19+
},
20+
get target() {
21+
return null;
22+
},
23+
get route() {
24+
return null;
25+
},
26+
get params() {
27+
return {};
28+
},
29+
get query() {
30+
return {};
31+
},
32+
};

packages/react/src/utils/log.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@ declare global {
66
}
77
}
88

9-
window.__spyCalls = [];
10-
window.__spyCallsClear = () => {
9+
const isClient = typeof window !== "undefined";
10+
11+
if (isClient) {
1112
window.__spyCalls = [];
12-
};
13+
window.__spyCallsClear = () => {
14+
window.__spyCalls = [];
15+
};
16+
}
1317

1418
export const log: typeof console.log = (...args) => {
15-
window.__spyCalls.push(args);
19+
if (isClient) {
20+
window.__spyCalls.push(args);
21+
}
1622
return console.log(...args);
1723
};

0 commit comments

Comments
 (0)