Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
892f90b
empty commit
DEV4N4 Sep 2, 2025
4dc0ff1
feat: 서버에서 동작할 라우터 구현
DEV4N4 Sep 4, 2025
f3993dc
feat: SSR에서 사용할 함수 구현
DEV4N4 Sep 4, 2025
69953f1
feat: 서버와 브라우저 모두에서 동작하도록 라우터 수정
DEV4N4 Sep 4, 2025
4feff2a
feat: SSR에서 사용할 mock서버 추가
DEV4N4 Sep 4, 2025
7fb4782
feat: 서버에서도 api를 호출할 수 있게 처리
DEV4N4 Sep 4, 2025
373ecc1
feat: 서버에서 storage 동작되도록 대응
DEV4N4 Sep 4, 2025
d9db3c4
fix: hydration test 통과되도록 대응
DEV4N4 Sep 4, 2025
6654fb4
feat: 서버에서는 마운트.언마운트 동작 안되도록 처리
DEV4N4 Sep 4, 2025
b450330
feat: Home 화면에 SSR의 query string 대응, 제목 추가
DEV4N4 Sep 4, 2025
1955b75
feat: 상세 화면의 상품 data를 기반한 제목 추가
DEV4N4 Sep 4, 2025
692009b
feat: 서버에서 만든 초기 데이터 복원 기능 추가
DEV4N4 Sep 4, 2025
ac3f020
feat: prefetching과 render 함수 구현
DEV4N4 Sep 4, 2025
e7a68ab
feat: express를 활용한 SSR 서버 구현
DEV4N4 Sep 4, 2025
5ed068c
feat: 구현한 SSR 서버를 활용해 SSG 구현
DEV4N4 Sep 4, 2025
f5f6408
feat(react): SSR 용 스토리지 추가
DEV4N4 Sep 4, 2025
05611f5
feat(react): ServerRouter 구현
DEV4N4 Sep 4, 2025
d5f147a
fix(react): getServerSnapshot 대응
DEV4N4 Sep 4, 2025
68f4797
feat: isServer 유틸 추가
DEV4N4 Sep 4, 2025
e612ff4
fix(react): log 는 server에서도 동작 되도록 수정
DEV4N4 Sep 4, 2025
5ad6f57
feat(react): 서버에서도 API 호출할 수 있도록 대응
DEV4N4 Sep 4, 2025
38999e9
feat(react): 메타데이터 제목 추가
DEV4N4 Sep 4, 2025
035dfbc
feat(react): msw SSR 용 서버 추가
DEV4N4 Sep 4, 2025
6b8397f
fix(react): 오류나는 불필요한 설정 제거
DEV4N4 Sep 4, 2025
15b3e28
faet(react): SSR server 구현
DEV4N4 Sep 4, 2025
c5f38c3
feat(react): useRouterContext 를 통해 router 사용하도록 변경
DEV4N4 Sep 4, 2025
197bb03
feat(react): router를 싱글톤을 쓰지 않기 위해 생성 함수로 변경
DEV4N4 Sep 4, 2025
448178b
feat(react): useProductUseCase 를 통해 편리하게 router 사용토록 개선
DEV4N4 Sep 4, 2025
35962d2
feat(react): route 등록을 라우터 생성하는 쪽으로 옮기기
DEV4N4 Sep 4, 2025
bdfd5aa
feat(react): CSR상에서 라우터 등록하고 초기 데이터 복원 처리
DEV4N4 Sep 4, 2025
0651a34
feat(react): SSR 서버 띄우는 코드 구현
DEV4N4 Sep 4, 2025
561c156
feat(react): SSG 구현
DEV4N4 Sep 4, 2025
522e493
chore: 배포 세팅
DEV4N4 Sep 4, 2025
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
116 changes: 116 additions & 0 deletions packages/lib/src/ServerRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { AnyFunction, StringRecord } from "./types";

export type ServerRoute<Handler extends AnyFunction> = Route<Handler>;

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

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

export type ServerRouterInstance<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>>;
query: StringRecord = {};

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

constructor() {
this.#routes = new Map();
this.#route = null;
}

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(`^${regexPath}$`);

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

#findRoute(url: string) {
console.log("url", url);
const { pathname } = new URL(url, "http://localhost");
console.log("pathname", pathname);

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() {}

start(url = "/") {
this.#route = this.#findRoute(url);
console.log("this.target", this.target);
}

static parseQuery = (search = "") => {
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 = () => {
return "";
};

subscribe() {
return () => {};
}
}
18 changes: 17 additions & 1 deletion packages/lib/src/createStorage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import { createObserver } from "./createObserver.ts";

export const createStorage = <T>(key: string, storage = window.localStorage) => {
const memoryStorage = () => {
const state: Record<string, string> = {};
return {
getItem: (key: string) => state[key],
setItem: (key: string, value: string) => {
state[key] = value;
},
removeItem: (key: string) => {
delete state[key];
},
};
};

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

Expand Down
12 changes: 10 additions & 2 deletions packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@ import type { RouterInstance } from "../Router";
import type { AnyFunction } from "../types";
import { useSyncExternalStore } from "react";
import { useShallowSelector } from "./useShallowSelector";
import type { ServerRouterInstance } 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>) => {
type RouterType = RouterInstance<AnyFunction> | ServerRouterInstance<AnyFunction>;

export const useRouter = <T extends RouterType, 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";
24 changes: 13 additions & 11 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"build:client-for-ssg": "rm -rf ../../dist/react && vite build --outDir ../../dist/react",
"build:server": "vite build --outDir ./dist/react-ssr --ssr src/main-server.tsx",
"build:without-ssg": "pnpm run build:client && npm run build:server",
"build:ssg": "pnpm run build:client-for-ssg && node static-site-generate.js",
"build:ssg": "pnpm run build:client-for-ssg && NODE_ENV=production node static-site-generate.js",
"build": "npm run build:client && npm run build:server && npm run build:ssg",
"preview:csr": "vite preview --outDir ./dist/react --port 4175",
"preview:csr-with-build": "pnpm run build:client && pnpm run preview:csr",
Expand All @@ -24,33 +24,35 @@
"serve:test": "pnpm run build:without-ssg && pnpm run build:ssg && (pnpm run dev & pnpm run dev:ssr & pnpm run preview:csr & pnpm run preview:ssr & pnpm run preview:ssg)",
"tsc": "tsc --noEmit",
"lint:fix": "eslint --fix ./src",
"prettier:write": "prettier --write ./src"
"prettier:write": "prettier --write ./src",
"deploy": "pnpm run build && npx gh-pages -d dist/react --dest react"
},
"dependencies": {
"@hanghae-plus/lib": "workspace:*",
"react": "latest",
"react-dom": "latest",
"@hanghae-plus/lib": "workspace:*"
"react-dom": "latest"
},
"devDependencies": {
"@babel/core": "latest",
"@babel/plugin-transform-react-jsx": "latest",
"@eslint/js": "^9.16.0",
"@types/node": "^24.0.13",
"@types/react": "latest",
"@types/react-dom": "latest",
"@types/use-sync-external-store": "latest",
"@vitejs/plugin-react": "latest",
"@types/node": "^24.0.13",
"compression": "^1.7.5",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"express": "^5.1.0",
"gh-pages": "^6.3.0",
"msw": "^2.10.2",
"prettier": "^3.4.2",
"sirv": "^3.0.0",
"typescript": "^5.8.3",
"vite": "npm:rolldown-vite@latest",
"express": "^5.1.0",
"compression": "^1.7.5",
"sirv": "^3.0.0"
"vite": "npm:rolldown-vite@latest"
}
}
64 changes: 46 additions & 18 deletions packages/react/server.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,60 @@
import express from "express";
import { renderToString } from "react-dom/server";
import { createElement } from "react";
import compression from "compression";
import sirv from "sirv";
import { createServer as createViteServer } from "vite";
import { readFileSync } from "fs";
import { mswServer } from "./src/mocks/node.js";

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 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(),
mswServer.listen({ onUnhandledRequest: "bypass" });

let vite;
if (!prod) {
vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
base,
});
app.use(vite.middlewares);
} else {
app.use(compression());
app.use(
base,
sirv("./dist/react", {
extensions: [],
}),
);
}

async function importModule(path) {
if (!prod) {
return await vite.ssrLoadModule(`/src/${path}`);
} else {
return await import(`./dist/react-ssr/${path}`);
}
}

app.use("*all", async (req, res) => {
// TODO: render 를 import 더 잘해보기
const { render } = await importModule("main-server.js");
const { html, head, initialData } = await render(req.originalUrl.replace(base, ""), req.query);

const template = prod ? readFileSync("./dist/react/index.html", "utf-8") : readFileSync("./index.html", "utf-8");
const initialDataScript = `<script>window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};</script>`;

const finalHtml = template
.replace("<!--app-head-->", head)
.replace("<!--app-html-->", html)
.replace("</head>", `${initialDataScript}</head>`);

res.send(finalHtml);
});

// Start http server
app.listen(port, () => {
console.log(`React Server started at http://localhost:${port}`);
});
8 changes: 1 addition & 7 deletions packages/react/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { router, useCurrentPage } from "./router";
import { HomePage, NotFoundPage, ProductDetailPage } from "./pages";
import { useCurrentPage } from "./router";
import { useLoadCartStore } from "./entities";
import { ModalProvider, ToastProvider } from "./components";

// 홈 페이지 (상품 목록)
router.addRoute("/", HomePage);
router.addRoute("/product/:id/", ProductDetailPage);
router.addRoute(".*", NotFoundPage);

const CartInitializer = () => {
useLoadCartStore();
return null;
Expand Down
15 changes: 12 additions & 3 deletions packages/react/src/api/productApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
// 상품 목록 조회
import type { Categories, Product } from "../entities";
import type { StringRecord } from "../types.ts";
import { isServer } from "../utils/ssrUtils.ts";

const withBaseUrl = (url: string) => {
if (isServer()) {
return `http://localhost:${process.env.PORT ?? 5174}${url}`;
}

return `${url}`;
};

interface ProductsResponse {
products: Product[];
Expand Down Expand Up @@ -33,19 +42,19 @@ export async function getProducts(params: StringRecord = {}): Promise<ProductsRe
sort,
});

const response = await fetch(`/api/products?${searchParams}`);
const response = await fetch(`${withBaseUrl(`/api/products?${searchParams}`)}`);

return await response.json();
}

// 상품 상세 조회
export async function getProduct(productId: string): Promise<Product> {
const response = await fetch(`/api/products/${productId}`);
const response = await fetch(`${withBaseUrl(`/api/products/${productId}`)}`);
return await response.json();
}

// 카테고리 목록 조회
export async function getCategories(): Promise<Categories> {
const response = await fetch("/api/categories");
const response = await fetch(`${withBaseUrl("/api/categories")}`);
return await response.json();
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { useState } from "react";
import { router } from "../../../router";
import type { StringRecord } from "../../../types";
import type { Product } from "../types";
import { PublicImage } from "../../../components";
import RelatedProducts from "./RelatedProducts";
import { useCartAddCommand } from "../../carts";
import { log } from "../../../utils";
import { useRouterContext } from "../../../router/hooks/useRouterContext";

export function ProductDetail(product: Readonly<Product>) {
log(`ProductDetail: ${product.productId}`);
const addToCart = useCartAddCommand();
const { productId, title, image, lprice, brand, category1, category2 } = product;
const [cartQuantity, setCartQuantity] = useState(1);
const router = useRouterContext();

const description = "",
rating = 0,
Expand Down
Loading