Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3161519
과제 제출을 위한 빈 커밋 날리기
ds92ko Dec 16, 2025
5039d2b
chore: nodemon 설치
ds92ko Dec 16, 2025
4553cc4
feat: express ssr 서버 구현
ds92ko Dec 18, 2025
e8cb154
feat: 서버 환경 감지 유틸리티 및 InMemoryStorage 추가
ds92ko Dec 18, 2025
63752a4
feat: 서버 사이드 Router 구현
ds92ko Dec 18, 2025
8ec52ed
feat: 서버 사이드 API 및 스토리지 구현
ds92ko Dec 18, 2025
97020c8
feat: 서버 사이드 렌더링 및 데이터 프리페칭 구현
ds92ko Dec 18, 2025
5cc7f30
feat: 클라이언트 Hydration 구현
ds92ko Dec 18, 2025
a74c45b
feat: Static Site Generation 구현
ds92ko Dec 18, 2025
bd6a989
fix: 클라이언트 네비게이션 시 document.title 업데이트
ds92ko Dec 18, 2025
5dee485
fix: enhanceProductDetail에서 includeImages 파라미터 제거
ds92ko Dec 18, 2025
12c995a
fix: generateHead에서 불필요한 query 파라미터 제거
ds92ko Dec 18, 2025
173d54f
feat: Universal React Router 구현 - 서버/클라이언트 단일 Router 사용
ds92ko Dec 19, 2025
e6215e1
feat: Router를 Context API로 관리하도록 변경
ds92ko Dec 19, 2025
3aaebd0
feat: router 직접 import를 Context를 통한 사용으로 전환
ds92ko Dec 19, 2025
5359050
refactor: typeof window 체크를 isServer() 유틸리티로 통합
ds92ko Dec 19, 2025
4102320
refactor: URL 쿼리 변경 감지 로직을 useProductFilter에 통합
ds92ko Dec 19, 2025
accb67f
feat: createStorage에 SSR 지원 추가
ds92ko Dec 19, 2025
d4f2b97
feat: CSR에서 라우터 변경 시 페이지 타이틀 자동 업데이트
ds92ko Dec 19, 2025
4f5ad71
feat: productApiServer에 타입 정의 추가
ds92ko Dec 19, 2025
8b76d14
fix: useRouter, useStore의 getSnapshot을 함수로 분리 (SSR 안정성)
ds92ko Dec 19, 2025
ecf12ff
fix: useLoadProductDetail에서 SSR 초기 데이터 체크 추가
ds92ko Dec 19, 2025
72ad0dc
feat: React SSR 서버 구현
ds92ko Dec 19, 2025
2d34086
refactor: vanilla main-server의 prefetchData 함수 시그니처 수정
ds92ko Dec 19, 2025
b134489
fix: React SSR 새로고침 시 깜빡임 제거
ds92ko Dec 19, 2025
4225599
fix: 검색 및 필터링 시 상품 목록이 로드되지 않는 문제 수정
ds92ko Dec 19, 2025
4fa5de9
fix: React 상세페이지 CSR 이동 시 페이지 타이틀 업데이트 문제 수정
ds92ko Dec 19, 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
90 changes: 69 additions & 21 deletions packages/lib/src/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,46 @@ type QueryPayload = Record<string, string | number | undefined>;

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

const isServer = () => typeof window === "undefined";

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

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

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

window.addEventListener("popstate", () => {
this.#route = this.#findRoute();
this.#observer.notify();
});

document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target?.closest("[data-link]")) {
return;
}
e.preventDefault();
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
if (url) {
this.push(url);
}
});
if (!isServer()) {
window.addEventListener("popstate", () => {
this.#route = this.#findRoute();
this.#observer.notify();
});

document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target?.closest("[data-link]")) {
return;
}
e.preventDefault();
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
if (url) {
this.push(url);
}
});
}
}

get query(): StringRecord {
if (isServer()) {
return this.#query;
}
return Router.parseQuery(window.location.search);
}

Expand Down Expand Up @@ -85,10 +93,15 @@ export class Router<Handler extends (...args: any[]) => any> {
});
}

#findRoute(url = window.location.pathname) {
const { pathname } = new URL(url, window.location.origin);
#findRoute(url?: string) {
const pathname = url || (isServer() ? "" : window.location.pathname);
if (!pathname) return null;

const origin = isServer() ? "http://localhost" : window.location.origin;
const { pathname: normalizedPath } = new URL(pathname, origin);

for (const [routePath, route] of this.#routes) {
const match = pathname.match(route.regex);
const match = normalizedPath.match(route.regex);
if (match) {
// 매치된 파라미터들을 객체로 변환
const params: StringRecord = {};
Expand All @@ -106,7 +119,36 @@ export class Router<Handler extends (...args: any[]) => any> {
return null;
}

match(url: string, query?: QueryPayload) {
const pathname = url.includes("?") ? url.split("?")[0] : url;
const route = this.#findRoute(pathname);

if (!route) return null;

this.#route = route;
const queryParams: StringRecord = {};
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
queryParams[key] = String(value);
}
});
}
this.#query = queryParams;

return {
path: route.path,
params: route.params ?? {},
handler: route.handler,
query: queryParams,
};
}

push(url: string) {
if (isServer()) {
return;
}

try {
// baseUrl이 없으면 자동으로 붙여줌
const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url);
Expand All @@ -130,8 +172,9 @@ export class Router<Handler extends (...args: any[]) => any> {
this.#observer.notify();
}

static parseQuery = (search = window.location.search) => {
const params = new URLSearchParams(search);
static parseQuery = (search?: string) => {
const searchString = search ?? (isServer() ? "" : window.location.search);
const params = new URLSearchParams(searchString);
const query: StringRecord = {};
for (const [key, value] of params) {
query[key] = value;
Expand All @@ -150,6 +193,11 @@ export class Router<Handler extends (...args: any[]) => any> {
};

static getUrl = (newQuery: QueryPayload, baseUrl = "") => {
if (isServer()) {
const queryString = Router.stringifyQuery(newQuery);
return `${baseUrl}${queryString ? `?${queryString}` : ""}`;
}

const currentQuery = Router.parseQuery();
const updatedQuery = { ...currentQuery, ...newQuery };

Expand Down
62 changes: 55 additions & 7 deletions packages/lib/src/createStorage.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,64 @@
import { createObserver } from "./createObserver.ts";

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

/**
* 서버 사이드에서 사용할 메모리 기반 스토리지
* window.localStorage 대신 사용하여 브라우저 의존성 제거
*/
class InMemoryStorage implements Storage {
private data = new Map<string, string>();

getItem(key: string): string | null {
return this.data.get(key) || null;
}

setItem(key: string, value: string): void {
this.data.set(key, value);
}

removeItem(key: string): void {
this.data.delete(key);
}

clear(): void {
this.data.clear();
}

get length(): number {
return this.data.size;
}

key(index: number): string | null {
const keys = Array.from(this.data.keys());
return keys[index] || null;
}
}

/**
* 로컬스토리지 추상화 함수
* 서버에서는 InMemoryStorage를 사용하여 브라우저 의존성 제거
* @param key - 스토리지 키
* @param storage - 기본값은 클라이언트는 localStorage, 서버는 InMemoryStorage
* @returns {Object} { get, set, reset, subscribe }
*/
export const createStorage = <T>(key: string, storage?: Storage) => {
const actualStorage: Storage = storage ?? (isServer() ? new InMemoryStorage() : window.localStorage);
const { subscribe, notify } = createObserver();

const get = () => data;
const get = (): T | null => {
try {
const item = actualStorage.getItem(key);
return item ? JSON.parse(item) : null;
} catch (error) {
console.error(`Error parsing storage item for key "${key}":`, error);
return null;
}
};

const set = (value: T) => {
try {
data = value;
storage.setItem(key, JSON.stringify(data));
actualStorage.setItem(key, JSON.stringify(value));
notify();
} catch (error) {
console.error(`Error setting storage item for key "${key}":`, error);
Expand All @@ -18,8 +67,7 @@ export const createStorage = <T>(key: string, storage = window.localStorage) =>

const reset = () => {
try {
data = null;
storage.removeItem(key);
actualStorage.removeItem(key);
notify();
} catch (error) {
console.error(`Error removing storage item for key "${key}":`, error);
Expand Down
3 changes: 2 additions & 1 deletion packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ 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>) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(router.subscribe, () => shallowSelector(router));
const getSnapshot = () => shallowSelector(router);
return useSyncExternalStore(router.subscribe, getSnapshot, getSnapshot);
};
5 changes: 3 additions & 2 deletions packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { createStore } from "../createStore";
import { useSyncExternalStore } from "react";
import type { createStore } from "../createStore";
import { useShallowSelector } from "./useShallowSelector";

type Store<T> = ReturnType<typeof createStore<T>>;
Expand All @@ -8,5 +8,6 @@ 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()));
const getSnapshot = () => shallowSelector(store.getState());
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
};
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>
5 changes: 3 additions & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"type": "module",
"scripts": {
"dev": "vite --port 5175",
"dev:ssr": "PORT=5176 node server.js",
"dev:ssr": "PORT=5176 nodemon server.js",
"build:client": "rm -rf ./dist/react && vite build --outDir ./dist/react && cp ./dist/react/index.html ./dist/react/404.html",
"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",
Expand Down Expand Up @@ -53,6 +53,7 @@
"express": "^5.1.0",
"compression": "^1.7.5",
"sirv": "^3.0.0",
"concurrently": "latest"
"concurrently": "latest",
"nodemon": "^3.1.11"
}
}
64 changes: 45 additions & 19 deletions packages/react/server.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,55 @@
import express from "express";
import { renderToString } from "react-dom/server";
import { createElement } from "react";
import fs from "fs";

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

const app = express();
let vite;

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(),
);
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: [] }));
}

app.use("*all", async (req, res) => {
try {
const url = req.originalUrl.replace(base, "/");
let template, render;

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

const { head, html, data } = await render(url, req.query);

const appHtml = template
.replace(`<!--app-head-->`, head ?? "")
.replace(`<!--app-html-->`, html ?? "")
.replace(`<!--app-data-->`, data ?? "");

res.status(200).set({ "Content-Type": "text/html" }).send(appHtml);
} catch (e) {
vite?.ssrFixStacktrace(e);
console.log(e.stack);
res.status(500).end(e.stack);
}
});

// Start http server
Expand Down
Loading