Skip to content

Commit ea8e413

Browse files
committed
feat: [advanced]ssr 연결
1 parent 1bd7868 commit ea8e413

File tree

10 files changed

+403
-139
lines changed

10 files changed

+403
-139
lines changed

packages/lib/src/Router.ts

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import type { FC } from "react";
12
import { createObserver } from "./createObserver";
23
import type { AnyFunction, StringRecord } from "./types";
34

4-
interface Route<Handler extends AnyFunction> {
5+
interface Route<Handler extends FC<unknown>> {
56
regex: RegExp;
67
paramNames: string[];
78
handler: Handler;
@@ -12,11 +13,11 @@ type QueryPayload = Record<string, string | number | undefined>;
1213

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

15-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16-
export class Router<Handler extends (...args: any[]) => any> {
16+
export class Router<Handler extends FC<unknown>> {
1717
readonly #routes: Map<string, Route<Handler>>;
1818
readonly #observer = createObserver();
1919
readonly #baseUrl;
20+
#ssrQuery: StringRecord = {};
2021

2122
#route: null | (Route<Handler> & { params: StringRecord; path: string });
2223

@@ -25,37 +26,57 @@ export class Router<Handler extends (...args: any[]) => any> {
2526
this.#route = null;
2627
this.#baseUrl = baseUrl.replace(/\/$/, "");
2728

28-
window.addEventListener("popstate", () => {
29-
this.#route = this.#findRoute();
30-
this.#observer.notify();
31-
});
32-
33-
document.addEventListener("click", (e) => {
34-
const target = e.target as HTMLElement;
35-
if (!target?.closest("[data-link]")) {
36-
return;
37-
}
38-
e.preventDefault();
39-
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
40-
if (url) {
41-
this.push(url);
42-
}
43-
});
29+
if (typeof window !== "undefined") {
30+
window.addEventListener("popstate", () => {
31+
this.#route = this.#findRoute();
32+
this.#observer.notify();
33+
});
34+
35+
document.addEventListener("click", (e) => {
36+
const target = e.target as HTMLElement;
37+
if (!target?.closest("[data-link]")) {
38+
return;
39+
}
40+
e.preventDefault();
41+
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
42+
if (url) {
43+
this.push(url);
44+
}
45+
});
46+
}
4447
}
4548

4649
get query(): StringRecord {
47-
return Router.parseQuery(window.location.search);
50+
if (typeof window !== "undefined") {
51+
return Router.parseQuery(window.location.search);
52+
}
53+
return this.#ssrQuery;
4854
}
4955

5056
set query(newQuery: QueryPayload) {
51-
const newUrl = Router.getUrl(newQuery, this.#baseUrl);
52-
this.push(newUrl);
57+
if (typeof window !== "undefined") {
58+
const newUrl = Router.getUrl(newQuery, this.#baseUrl);
59+
this.push(newUrl);
60+
} else {
61+
this.#ssrQuery = Object.entries(newQuery).reduce((acc, [key, value]) => {
62+
if (value !== null && value !== undefined && value !== "") {
63+
acc[key] = String(value);
64+
return acc;
65+
}
66+
return acc;
67+
}, {} as StringRecord);
68+
}
5369
}
5470

5571
get params() {
5672
return this.#route?.params ?? {};
5773
}
5874

75+
set params(newParams: StringRecord) {
76+
this.#route ??= {} as Route<Handler> & { params: StringRecord; path: string };
77+
this.#route.params = newParams;
78+
}
79+
5980
get route() {
6081
return this.#route;
6182
}
@@ -66,7 +87,7 @@ export class Router<Handler extends (...args: any[]) => any> {
6687

6788
readonly subscribe = this.#observer.subscribe;
6889

69-
addRoute(path: string, handler: Handler) {
90+
addRoute<T>(path: string, handler: FC<T>) {
7091
// 경로 패턴을 정규식으로 변환
7192
const paramNames: string[] = [];
7293
const regexPath = path
@@ -76,17 +97,19 @@ export class Router<Handler extends (...args: any[]) => any> {
7697
})
7798
.replace(/\//g, "\\/");
7899

79-
const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`);
100+
const regex =
101+
typeof window !== "undefined" ? new RegExp(`^${this.#baseUrl}${regexPath}$`) : new RegExp(`^${regexPath}$`);
80102

81103
this.#routes.set(path, {
82104
regex,
83105
paramNames,
84-
handler,
106+
handler: handler as Handler,
85107
});
86108
}
87109

88110
#findRoute(url = window.location.pathname) {
89-
const { pathname } = new URL(url, window.location.origin);
111+
// pathname 만 쓰기 때문에 임시 값 설정
112+
const { pathname } = new URL(url, "http://localhost");
90113
for (const [routePath, route] of this.#routes) {
91114
const match = pathname.match(route.regex);
92115
if (match) {
@@ -125,8 +148,8 @@ export class Router<Handler extends (...args: any[]) => any> {
125148
}
126149
}
127150

128-
start() {
129-
this.#route = this.#findRoute();
151+
start(url?: string) {
152+
this.#route = this.#findRoute(url);
130153
this.#observer.notify();
131154
}
132155

packages/react/index.html

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
11
<!doctype html>
22
<html lang="ko">
3-
<head>
4-
<meta charset="UTF-8" />
5-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<!--app-head-->
7-
<script src="https://cdn.tailwindcss.com"></script>
8-
<link rel="stylesheet" href="/src/styles.css">
9-
<script>
10-
tailwind.config = {
11-
theme: {
12-
extend: {
13-
colors: {
14-
primary: "#3b82f6",
15-
secondary: "#6b7280"
16-
}
17-
}
18-
}
19-
};
20-
</script>
21-
</head>
22-
<body class="bg-gray-50">
23-
<div id="root"><!--app-html--></div>
24-
<script type="module" src="/src/main.tsx"></script>
25-
</body>
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<!--app-head-->
7+
<script src="https://cdn.tailwindcss.com"></script>
8+
<link rel="stylesheet" href="/src/styles.css" />
9+
<script>
10+
tailwind.config = {
11+
theme: {
12+
extend: {
13+
colors: {
14+
primary: "#3b82f6",
15+
secondary: "#6b7280",
16+
},
17+
},
18+
},
19+
};
20+
</script>
21+
</head>
22+
<body class="bg-gray-50">
23+
<div id="root"><!--app-html--></div>
24+
<script type="module" src="/src/main.tsx"></script>
25+
<!-- app-data -->
26+
</body>
2627
</html>

packages/react/server.js

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,81 @@
11
import express from "express";
2-
import { renderToString } from "react-dom/server";
3-
import { createElement } from "react";
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
4+
import { createServer } from "vite";
45

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

10+
const templateHtml = prod ? await fs.readFile("./dist/react/index.html", "utf-8") : "";
11+
12+
const vite = await createServer({
13+
server: { middlewareMode: true },
14+
appType: "custom",
15+
base,
16+
});
17+
18+
const { mswServer } = await vite.ssrLoadModule("./src/mocks/node.ts");
19+
mswServer.listen({
20+
onUnhandledRequest: "bypass",
21+
});
22+
923
const app = express();
1024

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-
);
25+
// Add Vite or respective production middlewares
26+
/** @type {import('vite').ViteDevServer | undefined} */
27+
if (!prod) {
28+
app.use(vite.middlewares);
29+
} else {
30+
const compression = (await import("compression")).default;
31+
const sirv = (await import("sirv")).default;
32+
app.use(compression());
33+
app.use(base, sirv("./dist/react", { extensions: [] }));
34+
}
35+
36+
// 불필요한 요청 무시
37+
app.get("/favicon.ico", (_, res) => {
38+
res.status(204).end();
39+
});
40+
app.get("/.well-known/appspecific/com.chrome.devtools.json", (_, res) => {
41+
res.status(204).end();
42+
});
43+
44+
app.get("*all", async (req, res) => {
45+
try {
46+
const url = req.originalUrl.replace(base, "");
47+
const pathname = path.normalize(`/${url.split("?")[0]}`);
48+
49+
/** @type {string} */
50+
let template;
51+
/** @type {import('./src/main-server.js').render} */
52+
let render;
53+
if (!prod) {
54+
// Always read fresh template in development
55+
template = await fs.readFile("./index.html", "utf-8");
56+
template = await vite.transformIndexHtml(url, template);
57+
render = (await vite.ssrLoadModule("/src/main-server.js")).render;
58+
} else {
59+
template = templateHtml;
60+
render = (await import("./dist/react-ssr/main-server.js")).render;
61+
}
62+
63+
const rendered = await render(pathname, req.query);
64+
65+
const html = template
66+
.replace(`<!--app-head-->`, rendered.head ?? "")
67+
.replace(`<!--app-html-->`, rendered.html ?? "")
68+
.replace(
69+
`<!-- app-data -->`,
70+
`<script>window.__INITIAL_DATA__ = ${JSON.stringify(rendered.__INITIAL_DATA__)};</script>`,
71+
);
72+
73+
res.status(200).set({ "Content-Type": "text/html" }).send(html);
74+
} catch (e) {
75+
vite?.ssrFixStacktrace(e);
76+
console.log(e.stack);
77+
res.status(500).end(e.stack);
78+
}
2779
});
2880

2981
// Start http server

packages/react/src/App.tsx

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,40 @@
1-
import { router, useCurrentPage } from "./router";
2-
import { HomePage, NotFoundPage, ProductDetailPage } from "./pages";
3-
import { useLoadCartStore } from "./entities";
1+
import type { StringRecord } from "@hanghae-plus/lib";
2+
import { useState } from "react";
43
import { ModalProvider, ToastProvider } from "./components";
5-
6-
// 홈 페이지 (상품 목록)
7-
router.addRoute("/", HomePage);
8-
router.addRoute("/product/:id/", ProductDetailPage);
9-
router.addRoute(".*", NotFoundPage);
4+
import { useLoadCartStore } from "./entities";
5+
import { router, useCurrentPage } from "./router";
6+
import { isServer } from "./utils";
107

118
const CartInitializer = () => {
129
useLoadCartStore();
1310
return null;
1411
};
1512

13+
interface Props {
14+
data: unknown;
15+
query: StringRecord;
16+
}
17+
1618
/**
1719
* 전체 애플리케이션 렌더링
1820
*/
19-
export const App = () => {
21+
export const App = ({ data, query }: Props) => {
22+
useState(() => {
23+
if (isServer) {
24+
router.query = query;
25+
}
26+
});
2027
const PageComponent = useCurrentPage();
2128

2229
return (
2330
<>
2431
<ToastProvider>
25-
<ModalProvider>{PageComponent ? <PageComponent /> : null}</ModalProvider>
32+
<ModalProvider>
33+
{PageComponent ? (
34+
// @ts-expect-error initialData is unknowns
35+
<PageComponent data={data} />
36+
) : null}
37+
</ModalProvider>
2638
</ToastProvider>
2739
<CartInitializer />
2840
</>

packages/react/src/main-server.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,24 @@
1+
import { renderToString } from "react-dom/server";
2+
import { App } from "./App";
3+
import { router } from "./router";
4+
import type { ServerOptions } from "./router/withServer";
5+
6+
const fallback = () => {};
7+
18
export const render = async (url: string, query: Record<string, string>) => {
29
console.log({ url, query });
3-
return "";
10+
console.log("server start");
11+
router.start(url);
12+
const { ssr = fallback, metadata = fallback } = router.target as unknown as ServerOptions;
13+
const params = { query, params: router.params };
14+
const { title = "" } = (await metadata(params)) ?? {};
15+
const data = (await ssr(params)) ?? {};
16+
const html = renderToString(<App data={data} query={query} />);
17+
console.log("server end");
18+
19+
return {
20+
head: `<title>${title}</title>`,
21+
html,
22+
__INITIAL_DATA__: data,
23+
};
424
};

packages/react/src/main.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1+
import { hydrateRoot } from "react-dom/client";
12
import { App } from "./App";
2-
import { router } from "./router";
33
import { BASE_URL } from "./constants.ts";
4-
import { createRoot } from "react-dom/client";
4+
import { router } from "./router";
55

66
const enableMocking = () =>
77
import("./mocks/browser").then(({ worker }) =>
@@ -17,7 +17,10 @@ function main() {
1717
router.start();
1818

1919
const rootElement = document.getElementById("root")!;
20-
createRoot(rootElement).render(<App />);
20+
hydrateRoot(
21+
rootElement,
22+
<App data={(window as unknown as { __INITIAL_DATA__: unknown }).__INITIAL_DATA__} query={router.query} />,
23+
);
2124
}
2225

2326
// 애플리케이션 시작

0 commit comments

Comments
 (0)