diff --git a/packages/lib/src/MemoryRouter.ts b/packages/lib/src/MemoryRouter.ts new file mode 100644 index 00000000..980fb89a --- /dev/null +++ b/packages/lib/src/MemoryRouter.ts @@ -0,0 +1,155 @@ +import { createObserver } from "./createObserver"; +import type { AnyFunction, StringRecord } from "./types"; + +interface MemoryRoute { + regex: RegExp; + paramNames: string[]; + handler: Handler; + params?: StringRecord; +} + +type QueryPayload = Record; + +export type MemoryRouterInstance< + T extends AnyFunction & { + loader?: (router: MemoryRouterInstance) => Promise<{ data: unknown; title: string } | undefined>; + }, +> = InstanceType>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class MemoryRouter any> { + readonly #routes: Map>; + readonly #observer = createObserver(); + readonly #baseUrl; + #query; + #currentUrl; + + #route: null | (MemoryRoute & { params: StringRecord; path: string }); + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + this.#query = {}; + this.#currentUrl = ""; + } + + get query(): StringRecord { + return this.#query; + } + + set query(newQuery: QueryPayload) { + this.#query = newQuery; + } + + /** + * SSR용: 현재 URL 설정 및 쿼리 파싱 + * @param {string} url - 요청 URL + */ + setUrl(url: string) { + this.#currentUrl = url; + + // URL에서 쿼리 파라미터 추출 + try { + const urlObj = new URL(url, "http://localhost"); + this.#query = MemoryRouter.parseQuery(urlObj.search); + } catch { + this.#query = {}; + } + } + + 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: string | null = null) { + const targetUrl = url ?? this.#currentUrl; + const { pathname } = new URL(targetUrl || "", "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; + } + + start() { + this.#route = this.#findRoute(); + this.#observer.notify(); + } + + static parseQuery = (search: string | null = null) => { + const searchString = search ?? ""; + const params = new URLSearchParams(searchString); + 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 = (newQuery: QueryPayload, baseUrl = "") => { + const currentQuery = MemoryRouter.parseQuery(); + const updatedQuery = { ...currentQuery, ...newQuery }; + + // 빈 값들 제거 + Object.keys(updatedQuery).forEach((key) => { + if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { + delete updatedQuery[key]; + } + }); + + const queryString = MemoryRouter.stringifyQuery(updatedQuery); + return `${baseUrl}${queryString ? `?${queryString}` : ""}` || "/"; + }; +} diff --git a/packages/lib/src/createStorage.ts b/packages/lib/src/createStorage.ts index fdf2986c..0cf2f592 100644 --- a/packages/lib/src/createStorage.ts +++ b/packages/lib/src/createStorage.ts @@ -1,7 +1,15 @@ import { createObserver } from "./createObserver.ts"; -export const createStorage = (key: string, storage = window.localStorage) => { - let data: T | null = JSON.parse(storage.getItem(key) ?? "null"); +const dummyStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +}; + +export const createStorage = (key: string, storage = null) => { + const storageInstance = storage || (typeof window !== "undefined" ? window.localStorage : dummyStorage); + + let data: T | null = JSON.parse(storageInstance.getItem(key) ?? "null"); const { subscribe, notify } = createObserver(); const get = () => data; @@ -9,7 +17,7 @@ export const createStorage = (key: string, storage = window.localStorage) => const set = (value: T) => { try { data = value; - storage.setItem(key, JSON.stringify(data)); + storageInstance.setItem(key, JSON.stringify(data)); notify(); } catch (error) { console.error(`Error setting storage item for key "${key}":`, error); @@ -19,7 +27,7 @@ export const createStorage = (key: string, storage = window.localStorage) => const reset = () => { try { data = null; - storage.removeItem(key); + storageInstance.removeItem(key); notify(); } catch (error) { console.error(`Error removing storage item for key "${key}":`, error); diff --git a/packages/lib/src/hooks/useRouter.ts b/packages/lib/src/hooks/useRouter.ts index 4a40cb5d..d1a9212d 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -7,5 +7,9 @@ const defaultSelector = (state: T) => state as unknown as S; export const useRouter = , S>(router: T, selector = defaultSelector) => { const shallowSelector = useShallowSelector(selector); - return useSyncExternalStore(router.subscribe, () => shallowSelector(router)); + return useSyncExternalStore( + router.subscribe, + () => shallowSelector(router), // 클라이언트 + () => shallowSelector(router), // 서버 (동일한 초기 상태) + ); }; diff --git a/packages/lib/src/hooks/useStorage.ts b/packages/lib/src/hooks/useStorage.ts index f620638c..57194ff5 100644 --- a/packages/lib/src/hooks/useStorage.ts +++ b/packages/lib/src/hooks/useStorage.ts @@ -4,5 +4,9 @@ import type { createStorage } from "../createStorage"; type Storage = ReturnType>; export const useStorage = (storage: Storage) => { - return useSyncExternalStore(storage.subscribe, storage.get); + return useSyncExternalStore( + storage.subscribe, + storage.get, // 클라이언트 + storage.get, // 서버 (동일한 초기 상태) + ); }; diff --git a/packages/lib/src/hooks/useStore.ts b/packages/lib/src/hooks/useStore.ts index 56fa8800..cb30deee 100644 --- a/packages/lib/src/hooks/useStore.ts +++ b/packages/lib/src/hooks/useStore.ts @@ -8,5 +8,9 @@ const defaultSelector = (state: T) => state as unknown as S; export const useStore = (store: Store, selector: (state: T) => S = defaultSelector) => { const shallowSelector = useShallowSelector(selector); - return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState())); + return useSyncExternalStore( + store.subscribe, + () => shallowSelector(store.getState()), // 클라이언트 + () => shallowSelector(store.getState()), // 서버 (동일한 초기 상태) + ); }; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 74605597..fd8339fa 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -2,6 +2,7 @@ export * from "./createObserver"; export * from "./createStorage"; export * from "./createStore"; export * from "./Router"; +export * from "./MemoryRouter"; export { useStore, useStorage, useRouter, useAutoCallback } from "./hooks"; export * from "./equals"; export * from "./types"; diff --git a/packages/react/package.json b/packages/react/package.json index 16822f42..dbe6d890 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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 && tsx static-site-generate.ts", "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", @@ -28,31 +28,32 @@ "prettier:write": "prettier --write ./src" }, "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", + "concurrently": "latest", "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", "msw": "^2.10.2", "prettier": "^3.4.2", - "typescript": "^5.8.3", - "vite": "npm:rolldown-vite@latest", - "express": "^5.1.0", - "compression": "^1.7.5", "sirv": "^3.0.0", - "concurrently": "latest" + "tsx": "^4.21.0", + "typescript": "^5.8.3", + "vite": "npm:rolldown-vite@latest" } } diff --git a/packages/react/server.js b/packages/react/server.js index 81e3e1d8..aaf8b01a 100644 --- a/packages/react/server.js +++ b/packages/react/server.js @@ -1,29 +1,66 @@ 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 base = process.env.BASE || (prod ? "/front_7th_chapter4-1/react/" : "/"); +// Cached production assets +const templateHtml = prod ? fs.readFileSync("./dist/react/index.html", "utf-8") : ""; + const app = express(); -app.get("*all", (req, res) => { - res.send( - ` - - - - - - React SSR - - -
${renderToString(createElement("div", null, "안녕하세요"))}
- - - `.trim(), - ); +// 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", + }); + 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, ""); + + /** @type {string} */ + let template; + /** @type {import('./src/entry-server.js').render} */ + let render; + if (!prod) { + // Always read fresh template in development + template = fs.readFileSync("./index.html", "utf-8"); + template = await vite.transformIndexHtml(url, template); + + //entry point + render = (await vite.ssrLoadModule("./src/main-server.tsx")).render; + } else { + template = templateHtml; + render = (await import("./dist/react-ssr/main-server.js")).render; + } + + const { head, html, initialDataScript } = await render(url); + + const finalHtml = template + .replace(``, head ?? "") + .replace(``, html ?? "") + .replace("", `${initialDataScript}`); + + res.status(200).set({ "Content-Type": "text/html" }).send(finalHtml); + } catch (e) { + vite?.ssrFixStacktrace(e); + console.log(e.stack); + res.status(500).end(e.stack); + } }); // Start http server diff --git a/packages/react/src/api/getBaseUrl.ts b/packages/react/src/api/getBaseUrl.ts new file mode 100644 index 00000000..8e7b7f13 --- /dev/null +++ b/packages/react/src/api/getBaseUrl.ts @@ -0,0 +1,11 @@ +const getBaseUrl = () => { + if (typeof window !== "undefined") { + return ""; // 브라우저: 상대 경로 + } + + const port = process.env.PORT || 5176; + + return `http://localhost:${port}`; // SSR: 절대 경로 (MSW가 매칭) +}; + +export default getBaseUrl; diff --git a/packages/react/src/api/productApi.ts b/packages/react/src/api/productApi.ts index 29956155..2b052a92 100644 --- a/packages/react/src/api/productApi.ts +++ b/packages/react/src/api/productApi.ts @@ -1,7 +1,7 @@ // 상품 목록 조회 import type { Categories, Product } from "../entities"; import type { StringRecord } from "../types.ts"; - +import getBaseUrl from "./getBaseUrl"; interface ProductsResponse { products: Product[]; pagination: { @@ -33,19 +33,19 @@ export async function getProducts(params: StringRecord = {}): Promise { - const response = await fetch(`/api/products/${productId}`); + const response = await fetch(`${getBaseUrl()}/api/products/${productId}`); return await response.json(); } // 카테고리 목록 조회 export async function getCategories(): Promise { - const response = await fetch("/api/categories"); + const response = await fetch(`${getBaseUrl()}/api/categories`); return await response.json(); } diff --git a/packages/react/src/components/ErrorContent.tsx b/packages/react/src/components/ErrorContent.tsx index 48e58f63..89c28599 100644 --- a/packages/react/src/components/ErrorContent.tsx +++ b/packages/react/src/components/ErrorContent.tsx @@ -9,7 +9,11 @@ export const ErrorContent = ({ error }: { error: string }) => (

상품을 찾을 수 없습니다

{error || "요청하신 상품이 존재하지 않습니다."}