diff --git a/packages/lib/src/Router.ts b/packages/lib/src/Router.ts index eb3bd157..a06d3e1c 100644 --- a/packages/lib/src/Router.ts +++ b/packages/lib/src/Router.ts @@ -12,6 +12,8 @@ type QueryPayload = Record; export type RouterInstance = InstanceType>; +const isServer = () => typeof window === "undefined"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export class Router any> { readonly #routes: Map>; @@ -19,31 +21,37 @@ export class Router any> { readonly #baseUrl; #route: null | (Route & { 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); } @@ -85,10 +93,15 @@ export class Router 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 = {}; @@ -106,7 +119,36 @@ export class Router 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); @@ -130,8 +172,9 @@ export class Router 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; @@ -150,6 +193,11 @@ export class Router 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 }; diff --git a/packages/lib/src/createStorage.ts b/packages/lib/src/createStorage.ts index fdf2986c..67b64f2d 100644 --- a/packages/lib/src/createStorage.ts +++ b/packages/lib/src/createStorage.ts @@ -1,15 +1,64 @@ import { createObserver } from "./createObserver.ts"; -export const createStorage = (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(); + + 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 = (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); @@ -18,8 +67,7 @@ export const createStorage = (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); diff --git a/packages/lib/src/hooks/useRouter.ts b/packages/lib/src/hooks/useRouter.ts index 4a40cb5d..120a43ac 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -7,5 +7,6 @@ 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)); + const getSnapshot = () => shallowSelector(router); + return useSyncExternalStore(router.subscribe, getSnapshot, getSnapshot); }; diff --git a/packages/lib/src/hooks/useStore.ts b/packages/lib/src/hooks/useStore.ts index 56fa8800..9a24f7bc 100644 --- a/packages/lib/src/hooks/useStore.ts +++ b/packages/lib/src/hooks/useStore.ts @@ -1,5 +1,5 @@ -import type { createStore } from "../createStore"; import { useSyncExternalStore } from "react"; +import type { createStore } from "../createStore"; import { useShallowSelector } from "./useShallowSelector"; type Store = ReturnType>; @@ -8,5 +8,6 @@ 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())); + const getSnapshot = () => shallowSelector(store.getState()); + return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); }; diff --git a/packages/react/index.html b/packages/react/index.html index c93c0168..6f9d6ccb 100644 --- a/packages/react/index.html +++ b/packages/react/index.html @@ -1,26 +1,27 @@ - - - - - - - - - -
- - + + + + + + + + + + +
+ + diff --git a/packages/react/package.json b/packages/react/package.json index 16822f42..ecfb55df 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", @@ -53,6 +53,7 @@ "express": "^5.1.0", "compression": "^1.7.5", "sirv": "^3.0.0", - "concurrently": "latest" + "concurrently": "latest", + "nodemon": "^3.1.11" } } diff --git a/packages/react/server.js b/packages/react/server.js index 81e3e1d8..d780d523 100644 --- a/packages/react/server.js +++ b/packages/react/server.js @@ -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( - ` - - - - - - React SSR - - -
${renderToString(createElement("div", null, "안녕하세요"))}
- - - `.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(``, head ?? "") + .replace(``, html ?? "") + .replace(``, 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 diff --git a/packages/react/src/App.tsx b/packages/react/src/App.tsx index 36b302ca..40bf75d5 100644 --- a/packages/react/src/App.tsx +++ b/packages/react/src/App.tsx @@ -1,12 +1,9 @@ -import { router, useCurrentPage } from "./router"; -import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; -import { useLoadCartStore } from "./entities"; +import { useRouter } from "@hanghae-plus/lib"; +import { useEffect } from "react"; import { ModalProvider, ToastProvider } from "./components"; - -// 홈 페이지 (상품 목록) -router.addRoute("/", HomePage); -router.addRoute("/product/:id/", ProductDetailPage); -router.addRoute(".*", NotFoundPage); +import { useLoadCartStore, useProductStore } from "./entities"; +import { useCurrentPage, useRouterContext } from "./router"; +import { updateTitle } from "./utils/updateTitle"; const CartInitializer = () => { useLoadCartStore(); @@ -17,7 +14,14 @@ const CartInitializer = () => { * 전체 애플리케이션 렌더링 */ export const App = () => { + const router = useRouterContext(); const PageComponent = useCurrentPage(); + const route = useRouter(router, ({ route: r }) => r); + const { currentProduct } = useProductStore(); + + useEffect(() => { + updateTitle(router); + }, [router, route, currentProduct]); return ( <> diff --git a/packages/react/src/api/productApiServer.ts b/packages/react/src/api/productApiServer.ts new file mode 100644 index 00000000..f02a9a4f --- /dev/null +++ b/packages/react/src/api/productApiServer.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "fs"; +import type { Categories, Product } from "../entities/products/types"; +import { enhanceProductDetail, filterProducts, getUniqueCategories, paginateProducts } from "./productUtils.js"; + +const items: Product[] = JSON.parse(readFileSync("./src/mocks/items.json", "utf-8")); + +interface GetProductsParams { + limit?: number; + search?: string; + category1?: string; + category2?: string; + sort?: string; + page?: number; +} + +export async function getProducts(params: GetProductsParams = {}) { + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.page || 1; + + const filteredProducts = filterProducts(items, { search, category1, category2, sort }); + return paginateProducts(filteredProducts, page, limit); +} + +export async function getProduct(productId: string) { + const product = items.find((item) => item.productId === productId); + if (!product) return null; + + return enhanceProductDetail(product); +} + +export async function getCategories(): Promise { + return getUniqueCategories(items); +} + +export async function getRelatedProducts( + category1: string, + category2: string, + currentProductId: string, + limit = 20, +): Promise { + const related = items.filter((item) => { + if (item.productId === currentProductId) return false; + if (category2 && item.category2 === category2) return true; + if (category1 && item.category1 === category1) return true; + return false; + }); + + return related.slice(0, limit); +} diff --git a/packages/react/src/api/productUtils.ts b/packages/react/src/api/productUtils.ts new file mode 100644 index 00000000..015feed3 --- /dev/null +++ b/packages/react/src/api/productUtils.ts @@ -0,0 +1,89 @@ +import type { Product } from "../entities"; +import type { StringRecord } from "../types.ts"; + +export function getUniqueCategories(items: Product[]) { + const categories: Record> = {}; + + items.forEach((item) => { + const cat1 = item.category1; + const cat2 = item.category2; + + if (!categories[cat1]) categories[cat1] = {}; + if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; + }); + + return categories; +} + +// 상품 검색 및 필터링 함수 +export function filterProducts(products: Product[], query: Record) { + let filtered = [...products]; + + // 검색어 필터링 + if (query.search) { + const searchTerm = query.search.toLowerCase(); + filtered = filtered.filter( + (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), + ); + } + + // 카테고리 필터링 + if (query.category1) { + filtered = filtered.filter((item) => item.category1 === query.category1); + } + if (query.category2) { + filtered = filtered.filter((item) => item.category2 === query.category2); + } + + // 정렬 + if (query.sort) { + switch (query.sort) { + case "price_asc": + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + break; + case "price_desc": + filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); + break; + case "name_asc": + filtered.sort((a, b) => a.title.localeCompare(b.title, "ko")); + break; + case "name_desc": + filtered.sort((a, b) => b.title.localeCompare(a.title, "ko")); + break; + default: + // 기본은 가격 낮은 순 + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + } + } + + return filtered; +} + +export function paginateProducts(filteredProducts: Product[], page: number, limit: number) { + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedProducts = filteredProducts.slice(startIndex, endIndex); + + return { + products: paginatedProducts, + pagination: { + page, + limit, + total: filteredProducts.length, + totalPages: Math.ceil(filteredProducts.length / limit), + hasNext: endIndex < filteredProducts.length, + hasPrev: page > 1, + }, + }; +} + +export function enhanceProductDetail(product: Product) { + return { + ...product, + description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`, + rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤 + reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤 + stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤 + images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], + }; +} diff --git a/packages/react/src/entities/products/components/ProductDetail.tsx b/packages/react/src/entities/products/components/ProductDetail.tsx index 8a0b8d34..23b162c0 100644 --- a/packages/react/src/entities/products/components/ProductDetail.tsx +++ b/packages/react/src/entities/products/components/ProductDetail.tsx @@ -1,14 +1,15 @@ import { useState } from "react"; -import { router } from "../../../router"; +import { PublicImage } from "../../../components"; +import { useRouterContext } from "../../../router/RouterContext"; import type { StringRecord } from "../../../types"; +import { log } from "../../../utils"; +import { useCartAddCommand } from "../../carts"; import type { Product } from "../types"; -import { PublicImage } from "../../../components"; import RelatedProducts from "./RelatedProducts"; -import { useCartAddCommand } from "../../carts"; -import { log } from "../../../utils"; export function ProductDetail(product: Readonly) { log(`ProductDetail: ${product.productId}`); + const router = useRouterContext(); const addToCart = useCartAddCommand(); const { productId, title, image, lprice, brand, category1, category2 } = product; const [cartQuantity, setCartQuantity] = useState(1); diff --git a/packages/react/src/entities/products/components/ProductList.tsx b/packages/react/src/entities/products/components/ProductList.tsx index 97b282cb..b8e136cd 100644 --- a/packages/react/src/entities/products/components/ProductList.tsx +++ b/packages/react/src/entities/products/components/ProductList.tsx @@ -1,29 +1,30 @@ -import { ProductCard, ProductCardSkeleton } from "./ProductCard"; -import { router } from "../../../router"; import { PublicImage } from "../../../components"; +import { useRouterContext } from "../../../router/RouterContext"; import { useProductStore } from "../hooks"; import { loadProducts } from "../productUseCase"; - -const retry = async () => { - try { - await loadProducts(true); - } catch (error) { - console.error("재시도 실패:", error); - } -}; - -const goToDetailPage = async (productId: string) => { - // 상품 상세 페이지로 이동 - router.push(`/product/${productId}/`); -}; +import { ProductCard, ProductCardSkeleton } from "./ProductCard"; /** * 상품 목록 컴포넌트 */ export function ProductList() { + const router = useRouterContext(); const { products, loading, error, totalCount } = useProductStore(); const hasMore = products.length < totalCount; + const retry = async () => { + try { + await loadProducts(router, true); + } catch (error) { + console.error("재시도 실패:", error); + } + }; + + const goToDetailPage = (productId: string) => { + // 상품 상세 페이지로 이동 + router.push(`/product/${productId}/`); + }; + // 에러 상태 if (error) { return ( diff --git a/packages/react/src/entities/products/components/RelatedProducts.tsx b/packages/react/src/entities/products/components/RelatedProducts.tsx index 2fd154f6..ba7aa144 100644 --- a/packages/react/src/entities/products/components/RelatedProducts.tsx +++ b/packages/react/src/entities/products/components/RelatedProducts.tsx @@ -1,7 +1,8 @@ -import { router } from "../../../router"; +import { useRouterContext } from "../../../router/RouterContext"; import { useProductStore } from "../hooks"; export default function RelatedProducts() { + const router = useRouterContext(); const { relatedProducts } = useProductStore(); if (relatedProducts.length === 0) { return null; diff --git a/packages/react/src/entities/products/components/SearchBar.tsx b/packages/react/src/entities/products/components/SearchBar.tsx index 19f9dc9f..63ad0ca9 100644 --- a/packages/react/src/entities/products/components/SearchBar.tsx +++ b/packages/react/src/entities/products/components/SearchBar.tsx @@ -1,8 +1,9 @@ import { type ChangeEvent, Fragment, type KeyboardEvent, type MouseEvent } from "react"; import { PublicImage } from "../../../components"; +import { useRouterContext } from "../../../router/RouterContext"; import { useProductStore } from "../hooks"; -import { useProductFilter } from "./hooks"; import { searchProducts, setCategory, setLimit, setSort } from "../productUseCase"; +import { useProductFilter } from "./hooks"; const OPTION_LIMITS = [10, 20, 50, 100]; const OPTION_SORTS = [ @@ -12,84 +13,85 @@ const OPTION_SORTS = [ { value: "name_desc", label: "이름 역순" }, ]; -// 검색 입력 (Enter 키) -const handleSearchKeyDown = async (e: KeyboardEvent) => { - if (e.key === "Enter") { - const query = e.currentTarget.value.trim(); +export function SearchBar() { + const router = useRouterContext(); + const { categories } = useProductStore(); + const { searchQuery, limit = "20", sort, category } = useProductFilter(); + + // 검색 입력 (Enter 키) + const handleSearchKeyDown = async (e: KeyboardEvent) => { + if (e.key === "Enter") { + const query = e.currentTarget.value.trim(); + try { + searchProducts(router, query); + } catch (error) { + console.error("검색 실패:", error); + } + } + }; + + // 페이지당 상품 수 변경 + const handleLimitChange = async (e: ChangeEvent) => { + const limit = parseInt(e.target.value); try { - searchProducts(query); + setLimit(router, limit); } catch (error) { - console.error("검색 실패:", error); + console.error("상품 수 변경 실패:", error); } - } -}; - -// 페이지당 상품 수 변경 -const handleLimitChange = async (e: ChangeEvent) => { - const limit = parseInt(e.target.value); - try { - setLimit(limit); - } catch (error) { - console.error("상품 수 변경 실패:", error); - } -}; + }; -// 정렬 변경 -const handleSortChange = async (e: ChangeEvent) => { - const sort = e.target.value; + // 정렬 변경 + const handleSortChange = async (e: ChangeEvent) => { + const sort = e.target.value; - try { - setSort(sort); - } catch (error) { - console.error("정렬 변경 실패:", error); - } -}; + try { + setSort(router, sort); + } catch (error) { + console.error("정렬 변경 실패:", error); + } + }; -// 브레드크럼 카테고리 네비게이션 -const handleBreadCrumbClick = async (e: MouseEvent) => { - const breadcrumbType = e.currentTarget.getAttribute("data-breadcrumb"); + // 브레드크럼 카테고리 네비게이션 + const handleBreadCrumbClick = async (e: MouseEvent) => { + const breadcrumbType = e.currentTarget.getAttribute("data-breadcrumb"); - try { - if (breadcrumbType === "reset") { - // "전체" 클릭 -> 카테고리 초기화 - setCategory({ category1: "", category2: "" }); - } else if (breadcrumbType === "category1") { - // 1depth 클릭 -> 2depth 제거하고 1depth만 유지 - const category1 = e.currentTarget.getAttribute("data-category1"); - setCategory({ ...(category1 && { category1 }), category2: "" }); + try { + if (breadcrumbType === "reset") { + // "전체" 클릭 -> 카테고리 초기화 + setCategory(router, { category1: "", category2: "" }); + } else if (breadcrumbType === "category1") { + // 1depth 클릭 -> 2depth 제거하고 1depth만 유지 + const category1 = e.currentTarget.getAttribute("data-category1"); + setCategory(router, { ...(category1 && { category1 }), category2: "" }); + } + } catch (error) { + console.error("브레드크럼 네비게이션 실패:", error); } - } catch (error) { - console.error("브레드크럼 네비게이션 실패:", error); - } -}; - -// 1depth 카테고리 선택 -const handleMainCategoryClick = async (e: MouseEvent) => { - const category1 = e.currentTarget.getAttribute("data-category1"); - if (!category1) return; + }; - try { - setCategory({ category1, category2: "" }); - } catch (error) { - console.error("1depth 카테고리 선택 실패:", error); - } -}; + // 1depth 카테고리 선택 + const handleMainCategoryClick = async (e: MouseEvent) => { + const category1 = e.currentTarget.getAttribute("data-category1"); + if (!category1) return; -const handleSubCategoryClick = async (e: MouseEvent) => { - const category1 = e.currentTarget.getAttribute("data-category1"); - const category2 = e.currentTarget.getAttribute("data-category2"); - if (!category1 || !category2) return; + try { + setCategory(router, { category1, category2: "" }); + } catch (error) { + console.error("1depth 카테고리 선택 실패:", error); + } + }; - try { - setCategory({ category1, category2 }); - } catch (error) { - console.error("2depth 카테고리 선택 실패:", error); - } -}; + const handleSubCategoryClick = async (e: MouseEvent) => { + const category1 = e.currentTarget.getAttribute("data-category1"); + const category2 = e.currentTarget.getAttribute("data-category2"); + if (!category1 || !category2) return; -export function SearchBar() { - const { categories } = useProductStore(); - const { searchQuery, limit = "20", sort, category } = useProductFilter(); + try { + setCategory(router, { category1, category2 }); + } catch (error) { + console.error("2depth 카테고리 선택 실패:", error); + } + }; const categoryList = Object.keys(categories).length > 0 ? Object.keys(categories) : []; const limitOptions = OPTION_LIMITS.map((value) => ( diff --git a/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts b/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts index 509f166f..4c4b60ce 100644 --- a/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts +++ b/packages/react/src/entities/products/components/hooks/useLoadProductDetail.ts @@ -1,10 +1,17 @@ -import { useRouterParams } from "../../../../router"; import { useEffect } from "react"; +import { useRouterParams } from "../../../../router"; +import { productStore } from "../../productStore"; import { loadProductDetailForPage } from "../../productUseCase"; export const useLoadProductDetail = () => { const productId = useRouterParams((params) => params.id); + useEffect(() => { - loadProductDetailForPage(productId); + if (!productId) return; + + const state = productStore.getState(); + if (!state.currentProduct || state.currentProduct.productId !== productId || state.loading) { + loadProductDetailForPage(productId); + } }, [productId]); }; diff --git a/packages/react/src/entities/products/components/hooks/useProductFilter.ts b/packages/react/src/entities/products/components/hooks/useProductFilter.ts index ab24c1ec..65f86692 100644 --- a/packages/react/src/entities/products/components/hooks/useProductFilter.ts +++ b/packages/react/src/entities/products/components/hooks/useProductFilter.ts @@ -1,14 +1,34 @@ -import { useEffect } from "react"; -import { useRouterQuery } from "../../../../router"; +import { useEffect, useRef } from "react"; +import { useRouterContext, useRouterQuery } from "../../../../router"; import { loadProducts } from "../../productUseCase"; export const useProductFilter = () => { + const router = useRouterContext(); const { search: searchQuery, limit, sort, category1, category2 } = useRouterQuery(); const category = { category1, category2 }; + const isFirstMount = useRef(true); + const prevQuery = useRef({ search: searchQuery, limit, sort, category1, category2 }); useEffect(() => { - loadProducts(true); - }, [searchQuery, limit, sort, category1, category2]); + if (isFirstMount.current) { + isFirstMount.current = false; + prevQuery.current = { search: searchQuery, limit, sort, category1, category2 }; + return; + } + + const currentQuery = { search: searchQuery, limit, sort, category1, category2 }; + const hasChanged = + prevQuery.current.search !== currentQuery.search || + prevQuery.current.limit !== currentQuery.limit || + prevQuery.current.sort !== currentQuery.sort || + prevQuery.current.category1 !== currentQuery.category1 || + prevQuery.current.category2 !== currentQuery.category2; + + if (hasChanged) { + prevQuery.current = currentQuery; + loadProducts(router, true); + } + }, [router, searchQuery, limit, sort, category1, category2]); return { searchQuery, diff --git a/packages/react/src/entities/products/productUseCase.ts b/packages/react/src/entities/products/productUseCase.ts index fa967b56..00d5f261 100644 --- a/packages/react/src/entities/products/productUseCase.ts +++ b/packages/react/src/entities/products/productUseCase.ts @@ -1,13 +1,14 @@ +import type { RouterInstance } from "@hanghae-plus/lib"; +import type { FunctionComponent } from "react"; import { getCategories, getProduct, getProducts } from "../../api/productApi"; -import { router } from "../../router"; import type { StringRecord } from "../../types"; -import { initialProductState, PRODUCT_ACTIONS, productStore } from "./productStore"; import { isNearBottom } from "../../utils"; +import { initialProductState, PRODUCT_ACTIONS, productStore } from "./productStore"; const createErrorMessage = (error: unknown, defaultMessage = "알 수 없는 오류 발생") => error instanceof Error ? error.message : defaultMessage; -export const loadProductsAndCategories = async () => { +export const loadProductsAndCategories = async (router: RouterInstance) => { router.query = { current: undefined }; // 항상 첫 페이지로 초기화 productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, @@ -47,7 +48,7 @@ export const loadProductsAndCategories = async () => { } }; -export const loadProducts = async (resetList = true) => { +export const loadProducts = async (router: RouterInstance, resetList = true) => { try { productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, @@ -75,7 +76,7 @@ export const loadProducts = async (resetList = true) => { } }; -export const loadMoreProducts = async () => { +export const loadMoreProducts = async (router: RouterInstance) => { const state = productStore.getState(); const hasMore = state.products.length < state.totalCount; @@ -84,21 +85,21 @@ export const loadMoreProducts = async () => { } router.query = { current: Number(router.query.current ?? 1) + 1 }; - await loadProducts(false); + await loadProducts(router, false); }; -export const searchProducts = (search: string) => { +export const searchProducts = (router: RouterInstance, search: string) => { router.query = { search, current: 1 }; }; -export const setCategory = (categoryData: StringRecord) => { +export const setCategory = (router: RouterInstance, categoryData: StringRecord) => { router.query = { ...categoryData, current: 1 }; }; -export const setSort = (sort: string) => { +export const setSort = (router: RouterInstance, sort: string) => { router.query = { sort, current: 1 }; }; -export const setLimit = (limit: number) => { +export const setLimit = (router: RouterInstance, limit: number) => { router.query = { limit, current: 1 }; }; @@ -172,7 +173,7 @@ export const loadRelatedProducts = async (category2: string, excludeProductId: s } }; -export const loadNextProducts = async () => { +export const loadNextProducts = async (router: RouterInstance) => { // 현재 라우트가 홈이 아니면 무한 스크롤 비활성화 if (router.route?.path !== "/") { return; @@ -188,7 +189,7 @@ export const loadNextProducts = async () => { } try { - await loadMoreProducts(); + await loadMoreProducts(router); } catch (error) { console.error("무한 스크롤 로드 실패:", error); } diff --git a/packages/react/src/main-server.tsx b/packages/react/src/main-server.tsx index 611b0a58..dad29d66 100644 --- a/packages/react/src/main-server.tsx +++ b/packages/react/src/main-server.tsx @@ -1,4 +1,118 @@ -export const render = async (url: string, query: Record) => { - console.log({ url, query }); - return ""; -}; +import { Router } from "@hanghae-plus/lib"; +import type { FunctionComponent } from "react"; +import { renderToString } from "react-dom/server"; +import { getCategories, getProduct, getProducts, getRelatedProducts } from "./api/productApiServer"; +import { initialProductState, PRODUCT_ACTIONS, productStore } from "./entities/products/productStore"; +import { HomePage } from "./pages/HomePage"; +import { NotFoundPage } from "./pages/NotFoundPage"; +import { ProductDetailPage } from "./pages/ProductDetailPage"; +import { RouterContext } from "./router/RouterContext"; +import { generateTitle } from "./utils/updateTitle"; + +async function prefetchData( + routeInfo: ReturnType>["match"]> | null, + query: Record, +) { + if (!routeInfo) return {}; + const { path, params } = routeInfo; + + if (path === "/") { + const [productsData, categories] = await Promise.all([ + getProducts({ + limit: parseInt(query.limit) || 20, + search: query.search || "", + category1: query.category1 || "", + category2: query.category2 || "", + sort: query.sort || "price_asc", + page: 1, + }), + getCategories(), + ]); + + return { + products: productsData.products, + totalCount: productsData.pagination.total, + categories, + loading: false, + error: null, + status: "done", + }; + } else if (path === "/product/:id/") { + const product = await getProduct(params.id); + + if (!product) return { error: "Product not found" }; + + const relatedProducts = await getRelatedProducts(product.category1, product.category2, product.productId, 20); + + return { + currentProduct: product, + relatedProducts, + loading: false, + error: null, + status: "done", + }; + } + + return {}; +} + +export type RouteInfoType = ReturnType>["match"]>; +export type PrefetchDataType = Awaited>; + +function generateHead(routeInfo: RouteInfoType, data: PrefetchDataType) { + const product = routeInfo?.path === "/product/:id/" ? (data.currentProduct ?? null) : null; + const title = generateTitle(routeInfo, product); + return `${title}`; +} + +export async function render(url: string, query: Record = {}) { + const router = new Router(""); + router.addRoute("/", HomePage); + router.addRoute("/product/:id/", ProductDetailPage); + router.addRoute(".*", NotFoundPage); + + const routeInfo = router.match(url, query); + const storeData = await prefetchData(routeInfo, query); + + if (!routeInfo || storeData.error) { + return { + head: "404 - 쇼핑몰", + html: renderToString(), + data: null, + }; + } + + productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, payload: { ...initialProductState, ...storeData } }); + + const PageComponent = routeInfo.handler; + const html = renderToString( + + + , + ); + + productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, payload: { ...initialProductState } }); + + const head = generateHead(routeInfo, storeData); + + let initialData; + + if (routeInfo.path === "/") { + initialData = { + products: storeData.products, + categories: storeData.categories, + totalCount: storeData.totalCount, + }; + } else if (routeInfo.path === "/product/:id/") { + initialData = { + currentProduct: storeData.currentProduct, + relatedProducts: storeData.relatedProducts, + }; + } else { + initialData = storeData; + } + + const data = initialData ? `` : null; + + return { html, head, data }; +} diff --git a/packages/react/src/main.tsx b/packages/react/src/main.tsx index 0c5b8a67..0d9e8481 100644 --- a/packages/react/src/main.tsx +++ b/packages/react/src/main.tsx @@ -1,7 +1,24 @@ +import { Router } from "@hanghae-plus/lib"; +import type { FunctionComponent } from "react"; +import { createRoot, hydrateRoot } from "react-dom/client"; import { App } from "./App"; -import { router } from "./router"; import { BASE_URL } from "./constants.ts"; -import { createRoot } from "react-dom/client"; +import { PRODUCT_ACTIONS, productStore } from "./entities/products/productStore"; +import type { Categories, Product } from "./entities/products/types"; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { RouterContext } from "./router"; + +declare global { + interface Window { + __INITIAL_DATA__?: { + products?: Product[]; + categories?: Categories; + totalCount?: number; + currentProduct?: Product; + relatedProducts?: Product[]; + }; + } +} const enableMocking = () => import("./mocks/browser").then(({ worker }) => @@ -14,10 +31,63 @@ const enableMocking = () => ); function main() { + const router = new Router(BASE_URL); + + // 서버 데이터 복원 + if (window.__INITIAL_DATA__) { + const data = window.__INITIAL_DATA__; + if (data.products) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: data.products, + categories: data.categories, + totalCount: data.totalCount, + loading: false, + error: null, + status: "done", + }, + }); + } + if (data.currentProduct) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, + payload: data.currentProduct, + }); + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_STATUS, + payload: "done", + }); + } + if (data.relatedProducts) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, + payload: data.relatedProducts, + }); + } + delete window.__INITIAL_DATA__; + } + + // 라우트 등록 + router.addRoute("/", HomePage); + router.addRoute("/product/:id/", ProductDetailPage); + router.addRoute(".*", NotFoundPage); + + // 클라이언트 렌더링 시작 router.start(); const rootElement = document.getElementById("root")!; - createRoot(rootElement).render(); + const app = ( + + + + ); + + if (rootElement.hasChildNodes()) { + hydrateRoot(rootElement, app); + } else { + createRoot(rootElement).render(app); + } } // 애플리케이션 시작 diff --git a/packages/react/src/mocks/handlers.ts b/packages/react/src/mocks/handlers.ts index 55bc312a..761d8462 100644 --- a/packages/react/src/mocks/handlers.ts +++ b/packages/react/src/mocks/handlers.ts @@ -1,69 +1,9 @@ import { http, HttpResponse } from "msw"; +import { enhanceProductDetail, filterProducts, getUniqueCategories, paginateProducts } from "../api/productUtils.ts"; import items from "./items.json" with { type: "json" }; -import type { StringRecord } from "../types.ts"; -import type { Product } from "../entities"; const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); -// 카테고리 추출 함수 -function getUniqueCategories() { - const categories: Record> = {}; - - items.forEach((item) => { - const cat1 = item.category1; - const cat2 = item.category2; - - if (!categories[cat1]) categories[cat1] = {}; - if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; - }); - - return categories; -} - -// 상품 검색 및 필터링 함수 -function filterProducts(products: Product[], query: Record) { - let filtered = [...products]; - - // 검색어 필터링 - if (query.search) { - const searchTerm = query.search.toLowerCase(); - filtered = filtered.filter( - (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), - ); - } - - // 카테고리 필터링 - if (query.category1) { - filtered = filtered.filter((item) => item.category1 === query.category1); - } - if (query.category2) { - filtered = filtered.filter((item) => item.category2 === query.category2); - } - - // 정렬 - if (query.sort) { - switch (query.sort) { - case "price_asc": - filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); - break; - case "price_desc": - filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); - break; - case "name_asc": - filtered.sort((a, b) => a.title.localeCompare(b.title, "ko")); - break; - case "name_desc": - filtered.sort((a, b) => b.title.localeCompare(a.title, "ko")); - break; - default: - // 기본은 가격 낮은 순 - filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); - } - } - - return filtered; -} - export const handlers = [ // 상품 목록 API http.get("/api/products", async ({ request }) => { @@ -84,21 +24,12 @@ export const handlers = [ }); // 페이지네이션 - const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - const paginatedProducts = filteredProducts.slice(startIndex, endIndex); + const { products, pagination } = paginateProducts(filteredProducts, page, limit); // 응답 데이터 const response = { - products: paginatedProducts, - pagination: { - page, - limit, - total: filteredProducts.length, - totalPages: Math.ceil(filteredProducts.length / limit), - hasNext: endIndex < filteredProducts.length, - hasPrev: page > 1, - }, + products, + pagination, filters: { search, category1, @@ -122,21 +53,14 @@ export const handlers = [ } // 상세 정보에 추가 데이터 포함 - const detailProduct = { - ...product, - description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`, - rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤 - reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤 - stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤 - images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], - }; + const detailProduct = enhanceProductDetail(product); return HttpResponse.json(detailProduct); }), // 카테고리 목록 API http.get("/api/categories", async () => { - const categories = getUniqueCategories(); + const categories = getUniqueCategories(items); await delay(); return HttpResponse.json(categories); }), diff --git a/packages/react/src/pages/HomePage.tsx b/packages/react/src/pages/HomePage.tsx index 4edbccc6..d40d7a53 100644 --- a/packages/react/src/pages/HomePage.tsx +++ b/packages/react/src/pages/HomePage.tsx @@ -1,5 +1,7 @@ import { useEffect } from "react"; import { loadNextProducts, loadProductsAndCategories, ProductList, SearchBar } from "../entities"; +import { productStore } from "../entities/products/productStore"; +import { useRouterContext } from "../router/RouterContext"; import { PageWrapper } from "./PageWrapper"; const headerLeft = ( @@ -10,29 +12,23 @@ const headerLeft = ( ); -// 무한 스크롤 이벤트 등록 -let scrollHandlerRegistered = false; - -const registerScrollHandler = () => { - if (scrollHandlerRegistered) return; - - window.addEventListener("scroll", loadNextProducts); - scrollHandlerRegistered = true; -}; - -const unregisterScrollHandler = () => { - if (!scrollHandlerRegistered) return; - window.removeEventListener("scroll", loadNextProducts); - scrollHandlerRegistered = false; -}; - export const HomePage = () => { - useEffect(() => { - registerScrollHandler(); - loadProductsAndCategories(); + const router = useRouterContext(); - return unregisterScrollHandler; - }, []); + // 무한 스크롤 이벤트 등록 + useEffect(() => { + const scrollHandler = () => loadNextProducts(router); + window.addEventListener("scroll", scrollHandler); + + const state = productStore.getState(); + if (state.products.length === 0 || state.loading) { + loadProductsAndCategories(router); + } + + return () => { + window.removeEventListener("scroll", scrollHandler); + }; + }, [router]); return ( diff --git a/packages/react/src/router/RouterContext.tsx b/packages/react/src/router/RouterContext.tsx new file mode 100644 index 00000000..37050c26 --- /dev/null +++ b/packages/react/src/router/RouterContext.tsx @@ -0,0 +1,11 @@ +import type { RouterInstance } from "@hanghae-plus/lib"; +import type { FunctionComponent } from "react"; +import { createContext, useContext } from "react"; + +export const RouterContext = createContext | null>(null); + +export const useRouterContext = () => { + const router = useContext(RouterContext); + if (!router) throw new Error("useRouterContext must be used within RouterContext.Provider"); + return router; +}; diff --git a/packages/react/src/router/hooks/useCurrentPage.ts b/packages/react/src/router/hooks/useCurrentPage.ts index 888e4d06..be039145 100644 --- a/packages/react/src/router/hooks/useCurrentPage.ts +++ b/packages/react/src/router/hooks/useCurrentPage.ts @@ -1,6 +1,7 @@ -import { router } from "../router"; import { useRouter } from "@hanghae-plus/lib"; +import { useRouterContext } from "../RouterContext"; export const useCurrentPage = () => { + const router = useRouterContext(); return useRouter(router, ({ target }) => target); }; diff --git a/packages/react/src/router/hooks/useRouterParams.ts b/packages/react/src/router/hooks/useRouterParams.ts index 88bf4e22..0c2283fc 100644 --- a/packages/react/src/router/hooks/useRouterParams.ts +++ b/packages/react/src/router/hooks/useRouterParams.ts @@ -1,10 +1,11 @@ -import { router } from "../router"; import { useRouter } from "@hanghae-plus/lib"; +import { useRouterContext } from "../RouterContext"; type Params = Record; const defaultSelector = (params: Params) => params as S; export const useRouterParams = (selector = defaultSelector) => { + const router = useRouterContext(); return useRouter(router, ({ params }) => selector(params)); }; diff --git a/packages/react/src/router/hooks/useRouterQuery.ts b/packages/react/src/router/hooks/useRouterQuery.ts index 261299da..567e419e 100644 --- a/packages/react/src/router/hooks/useRouterQuery.ts +++ b/packages/react/src/router/hooks/useRouterQuery.ts @@ -1,6 +1,7 @@ import { useRouter } from "@hanghae-plus/lib"; -import { router } from "../router"; +import { useRouterContext } from "../RouterContext"; export const useRouterQuery = () => { + const router = useRouterContext(); return useRouter(router, ({ query }) => query); }; diff --git a/packages/react/src/router/index.ts b/packages/react/src/router/index.ts index 704bc03c..1a9e39f2 100644 --- a/packages/react/src/router/index.ts +++ b/packages/react/src/router/index.ts @@ -1,2 +1,2 @@ -export * from "./router"; export * from "./hooks"; +export * from "./RouterContext"; diff --git a/packages/react/src/router/router.ts b/packages/react/src/router/router.ts deleted file mode 100644 index ddb3a7cd..00000000 --- a/packages/react/src/router/router.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 글로벌 라우터 인스턴스 -import { Router } from "@hanghae-plus/lib"; -import { BASE_URL } from "../constants"; -import type { FunctionComponent } from "react"; - -export const router = new Router(BASE_URL); diff --git a/packages/react/src/utils/isServer.ts b/packages/react/src/utils/isServer.ts new file mode 100644 index 00000000..f0400ae3 --- /dev/null +++ b/packages/react/src/utils/isServer.ts @@ -0,0 +1,3 @@ +export function isServer() { + return typeof window === "undefined"; +} diff --git a/packages/react/src/utils/log.ts b/packages/react/src/utils/log.ts index 00aa2d47..7641cc97 100644 --- a/packages/react/src/utils/log.ts +++ b/packages/react/src/utils/log.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { isServer } from "./isServer"; + declare global { interface Window { __spyCalls: any[]; @@ -6,12 +8,16 @@ declare global { } } -window.__spyCalls = []; -window.__spyCallsClear = () => { +if (!isServer()) { window.__spyCalls = []; -}; + window.__spyCallsClear = () => { + window.__spyCalls = []; + }; +} export const log: typeof console.log = (...args) => { - window.__spyCalls.push(args); + if (!isServer()) { + window.__spyCalls.push(args); + } return console.log(...args); }; diff --git a/packages/react/src/utils/updateTitle.ts b/packages/react/src/utils/updateTitle.ts new file mode 100644 index 00000000..287a2fb6 --- /dev/null +++ b/packages/react/src/utils/updateTitle.ts @@ -0,0 +1,43 @@ +import type { RouterInstance } from "@hanghae-plus/lib"; +import type { FunctionComponent } from "react"; +import { productStore } from "../entities/products/productStore"; + +/** + * 라우트 정보와 상품 데이터를 기반으로 title 문자열 생성 + */ +export function generateTitle( + routeInfo: { path: string; params?: Record } | null, + product: { title: string } | null, +): string { + if (!routeInfo) { + return "쇼핑몰"; + } + + if (routeInfo.path === "/product/:id/") { + if (product?.title) { + return `${product.title} - 쇼핑몰`; + } + return "상품 상세 - 쇼핑몰"; + } else if (routeInfo.path === "/") { + return "쇼핑몰 - 홈"; + } else { + return "404 - 쇼핑몰"; + } +} + +/** + * 현재 라우트에 따라 document.title 업데이트 + */ +export function updateTitle(router: RouterInstance) { + if (typeof window === "undefined") return; + + const routeInfo = router.route; + let product: { title: string } | null = null; + + if (routeInfo?.path === "/product/:id/") { + const state = productStore.getState(); + product = state.currentProduct; + } + + document.title = generateTitle(routeInfo, product); +} diff --git a/packages/react/static-site-generate.js b/packages/react/static-site-generate.js index 145c957b..c3c9e524 100644 --- a/packages/react/static-site-generate.js +++ b/packages/react/static-site-generate.js @@ -1,18 +1,37 @@ -import { renderToString } from "react-dom/server"; -import { createElement } from "react"; import fs from "fs"; +import path from "path"; async function generateStaticSite() { - // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/react/index.html", "utf-8"); + const { render } = await import("./dist/react-ssr/main-server.js"); + const template = fs.readFileSync("./dist/react/index.html", "utf-8"); + const products = JSON.parse(fs.readFileSync("./src/mocks/items.json", "utf-8")); + const pages = [{ url: "/", filePath: "../../dist/react/index.html" }]; - // 어플리케이션 렌더링하기 - const appHtml = renderToString(createElement("div", null, "안녕하세요")); + products.forEach((product) => { + pages.push({ + url: `/product/${product.productId}/`, + filePath: `../../dist/react/product/${product.productId}/index.html`, + }); + }); - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/react/index.html", result); + for (const page of pages) { + const { html, head, data } = await render(page.url, {}); + + const result = template + .replace(``, head ?? "") + .replace(``, html ?? "") + .replace(``, data ?? ""); + + const dir = path.dirname(page.filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(page.filePath, result); + } } -// 실행 -generateStaticSite(); +generateStaticSite().catch((error) => { + console.error("SSG 실패", error); + process.exit(1); +}); diff --git a/packages/vanilla/index.html b/packages/vanilla/index.html index 483a6d5e..56f70659 100644 --- a/packages/vanilla/index.html +++ b/packages/vanilla/index.html @@ -5,18 +5,19 @@ - + + diff --git a/packages/vanilla/package.json b/packages/vanilla/package.json index ab5ae3fd..2a16a25a 100644 --- a/packages/vanilla/package.json +++ b/packages/vanilla/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite --port 5173", - "dev:ssr": "PORT=5174 node server.js", + "dev:ssr": "PORT=5174 nodemon server.js", "build:client": "rm -rf ./dist/vanilla && vite build --outDir ./dist/vanilla && cp ./dist/vanilla/index.html ./dist/vanilla/404.html", "build:client-for-ssg": "rm -rf ../../dist/vanilla && vite build --outDir ../../dist/vanilla", "build:server": "vite build --outDir ./dist/vanilla-ssr --ssr src/main-server.js", @@ -50,7 +50,8 @@ "prettier": "^3.4.2", "vite": "npm:rolldown-vite@latest", "vitest": "latest", - "concurrently": "latest" + "concurrently": "latest", + "nodemon": "^3.1.11" }, "msw": { "workerDirectory": [ diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 67f03afa..5ca3aba2 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,34 +1,58 @@ import express from "express"; +import fs from "fs"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; const base = process.env.BASE || (prod ? "/front_7th_chapter4-1/vanilla/" : "/"); const app = express(); +let vite; -const render = () => { - return `
안녕하세요
`; -}; - -app.get("*all", (req, res) => { - res.send( - ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- - - `.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/vanilla", { 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/vanilla/index.html", "utf-8"); + render = (await import("./dist/vanilla-ssr/main-server.js")).render; + } + + const { head, html, data } = await render(url, req.query); + + const appHtml = template + .replace(``, head ?? "") + .replace(``, html ?? "") + .replace(``, 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 app.listen(port, () => { - console.log(`React Server started at http://localhost:${port}`); + console.log(`Vanilla Server started at http://localhost:${port}`); }); diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index c2330fbe..31daa500 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,3 +1,5 @@ +import { BASE_URL } from "../constants.js"; + export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; @@ -11,17 +13,17 @@ export async function getProducts(params = {}) { sort, }); - const response = await fetch(`/api/products?${searchParams}`); + const response = await fetch(`${BASE_URL}api/products?${searchParams}`); return await response.json(); } export async function getProduct(productId) { - const response = await fetch(`/api/products/${productId}`); + const response = await fetch(`${BASE_URL}api/products/${productId}`); return await response.json(); } export async function getCategories() { - const response = await fetch("/api/categories"); + const response = await fetch(`${BASE_URL}api/categories`); return await response.json(); } diff --git a/packages/vanilla/src/api/productApiServer.js b/packages/vanilla/src/api/productApiServer.js new file mode 100644 index 00000000..a7ed7b23 --- /dev/null +++ b/packages/vanilla/src/api/productApiServer.js @@ -0,0 +1,34 @@ +import { readFileSync } from "fs"; +import { enhanceProductDetail, filterProducts, getUniqueCategories, paginateProducts } from "./productUtils.js"; + +const items = JSON.parse(readFileSync("./src/mocks/items.json", "utf-8")); + +export async function getProducts(params = {}) { + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.page || 1; + + const filteredProducts = filterProducts(items, { search, category1, category2, sort }); + return paginateProducts(filteredProducts, page, limit); +} + +export async function getProduct(productId) { + const product = items.find((item) => item.productId === productId); + if (!product) return null; + + return enhanceProductDetail(product); +} + +export async function getCategories() { + return getUniqueCategories(items); +} + +export async function getRelatedProducts(category1, category2, currentProductId, limit = 20) { + let related = items.filter((item) => { + if (item.productId === currentProductId) return false; + if (category2 && item.category2 === category2) return true; + if (category1 && item.category1 === category1) return true; + return false; + }); + + return related.slice(0, limit); +} diff --git a/packages/vanilla/src/api/productUtils.js b/packages/vanilla/src/api/productUtils.js new file mode 100644 index 00000000..9eac95b7 --- /dev/null +++ b/packages/vanilla/src/api/productUtils.js @@ -0,0 +1,78 @@ +export function getUniqueCategories(items) { + const categories = {}; + items.forEach((item) => { + const cat1 = item.category1; + const cat2 = item.category2; + if (!categories[cat1]) categories[cat1] = {}; + if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; + }); + return categories; +} + +export function filterProducts(products, query) { + let filtered = [...products]; + + if (query.search) { + const searchTerm = query.search.toLowerCase(); + filtered = filtered.filter( + (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), + ); + } + + if (query.category1) { + filtered = filtered.filter((item) => item.category1 === query.category1); + } + if (query.category2) { + filtered = filtered.filter((item) => item.category2 === query.category2); + } + + if (query.sort) { + switch (query.sort) { + case "price_asc": + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + break; + case "price_desc": + filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); + break; + case "name_asc": + filtered.sort((a, b) => a.title.localeCompare(b.title, "ko")); + break; + case "name_desc": + filtered.sort((a, b) => b.title.localeCompare(a.title, "ko")); + break; + default: + filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + } + } + + return filtered; +} + +export function paginateProducts(filteredProducts, page, limit) { + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedProducts = filteredProducts.slice(startIndex, endIndex); + + return { + products: paginatedProducts, + pagination: { + page, + limit, + total: filteredProducts.length, + totalPages: Math.ceil(filteredProducts.length / limit), + hasNext: endIndex < filteredProducts.length, + hasPrev: page > 1, + }, + }; +} + +export function enhanceProductDetail(product) { + return { + ...product, + description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`, + rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤 + reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤 + stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤 + images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], + }; +} diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/ClientRouter.js similarity index 93% rename from packages/vanilla/src/lib/Router.js rename to packages/vanilla/src/lib/ClientRouter.js index 2238a878..854b7718 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/ClientRouter.js @@ -3,7 +3,7 @@ */ import { createObserver } from "./createObserver.js"; -export class Router { +export class ClientRouter { #routes; #route; #observer = createObserver(); @@ -25,11 +25,11 @@ export class Router { } get query() { - return Router.parseQuery(window.location.search); + return ClientRouter.parseQuery(window.location.search); } set query(newQuery) { - const newUrl = Router.getUrl(newQuery, this.#baseUrl); + const newUrl = ClientRouter.getUrl(newQuery, this.#baseUrl); this.push(newUrl); } @@ -155,7 +155,7 @@ export class Router { }; static getUrl = (newQuery, baseUrl = "") => { - const currentQuery = Router.parseQuery(); + const currentQuery = ClientRouter.parseQuery(); const updatedQuery = { ...currentQuery, ...newQuery }; // 빈 값들 제거 @@ -165,7 +165,7 @@ export class Router { } }); - const queryString = Router.stringifyQuery(updatedQuery); + const queryString = ClientRouter.stringifyQuery(updatedQuery); return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; }; } diff --git a/packages/vanilla/src/lib/InMemoryStorage.js b/packages/vanilla/src/lib/InMemoryStorage.js new file mode 100644 index 00000000..53363cfe --- /dev/null +++ b/packages/vanilla/src/lib/InMemoryStorage.js @@ -0,0 +1,25 @@ +/** + * 서버 사이드에서 사용할 메모리 기반 스토리지 + * window.localStorage 대신 사용하여 브라우저 의존성 제거 + */ +export class InMemoryStorage { + constructor() { + this.data = new Map(); + } + + getItem(key) { + return this.data.get(key) || null; + } + + setItem(key, value) { + this.data.set(key, value); + } + + removeItem(key) { + this.data.delete(key); + } + + clear() { + this.data.clear(); + } +} diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js new file mode 100644 index 00000000..6ab1cdfc --- /dev/null +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -0,0 +1,84 @@ +export class ServerRouter { + #routes; + #route; + #params; + #query; + #baseUrl; + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#params = {}; + this.#query = {}; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + get baseUrl() { + return this.#baseUrl; + } + + get query() { + return this.#query; + } + + get params() { + return this.#params; + } + + get route() { + return this.#route; + } + + get target() { + return this.#route?.handler; + } + + /** + * 라우트 등록 + * @param {string} path - 경로 패턴 (예: "/product/:id") + * @param {Function} handler - 라우트 핸들러 + */ + addRoute(path, handler) { + // 경로 패턴을 정규식으로 변환 + const paramNames = []; + 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, + }); + } + + match(url, query = {}) { + const pathname = url.split("?")[0]; + + for (const [routePath, route] of this.#routes) { + const match = pathname.match(route.regex); + if (match) { + const params = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + this.#route = route; + this.#params = params; + this.#query = query; + + return { + path: routePath, + params, + handler: route.handler, + }; + } + } + return null; + } +} diff --git a/packages/vanilla/src/lib/UniversalRouter.js b/packages/vanilla/src/lib/UniversalRouter.js new file mode 100644 index 00000000..110efbda --- /dev/null +++ b/packages/vanilla/src/lib/UniversalRouter.js @@ -0,0 +1,55 @@ +import { isServer } from "../utils/isServer.js"; +import { ClientRouter } from "./ClientRouter.js"; +import { ServerRouter } from "./ServerRouter.js"; + +export class UniversalRouter { + #router; + + constructor(baseUrl = "") { + this.#router = isServer() ? new ServerRouter(baseUrl) : new ClientRouter(baseUrl); + } + + get baseUrl() { + return this.#router.baseUrl; + } + + get query() { + return this.#router.query; + } + + set query(newQuery) { + this.#router.query = newQuery; + } + + get params() { + return this.#router.params; + } + + get route() { + return this.#router.route; + } + + get target() { + return this.#router.target; + } + + subscribe(...args) { + return this.#router.subscribe?.(...args); + } + + addRoute(...args) { + return this.#router.addRoute(...args); + } + + match(...args) { + return this.#router.match?.(...args); + } + + push(...args) { + return this.#router.push?.(...args); + } + + start() { + return this.#router.start?.(); + } +} diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..d3ec2d49 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -1,10 +1,14 @@ +import { isServer } from "../utils/isServer.js"; +import { InMemoryStorage } from "./InMemoryStorage.js"; + /** * 로컬스토리지 추상화 함수 + * 서버에서는 InMemoryStorage를 사용하여 브라우저 의존성 제거 * @param {string} key - 스토리지 키 - * @param {Storage} storage - 기본값은 localStorage + * @param {Storage} storage - 기본값은 클라이언트는 localStorage, 서버는 InMemoryStorage * @returns {Object} { get, set, reset } */ -export const createStorage = (key, storage = window.localStorage) => { +export const createStorage = (key, storage = isServer() ? new InMemoryStorage() : window.localStorage) => { const get = () => { try { const item = storage.getItem(key); diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index a598ef30..dbb46ca8 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -1,4 +1,4 @@ export * from "./createObserver"; export * from "./createStore"; export * from "./createStorage"; -export * from "./Router"; +export * from "./UniversalRouter"; diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..60b69eb5 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,103 @@ -export const render = async (url, query) => { - console.log({ url, query }); - return ""; -}; +import { getCategories, getProduct, getProducts, getRelatedProducts } from "./api/productApiServer.js"; +import { HomePage } from "./pages/HomePage.js"; +import { NotFoundPage } from "./pages/NotFoundPage.js"; +import { ProductDetailPage } from "./pages/ProductDetailPage.js"; +import { router } from "./router/router.js"; +import { generateTitle } from "./utils/updateTitle.js"; + +async function prefetchData(routeInfo, query) { + const { path, params } = routeInfo; + + if (path === "/") { + const [productsData, categories] = await Promise.all([ + getProducts({ + limit: parseInt(query.limit) || 20, + search: query.search || "", + category1: query.category1 || "", + category2: query.category2 || "", + sort: query.sort || "price_asc", + page: 1, + }), + getCategories(), + ]); + + return { + products: productsData.products, + totalCount: productsData.pagination.total, + categories, + loading: false, + error: null, + status: "done", + }; + } else if (path === "/product/:id/") { + const product = await getProduct(params.id); + + if (!product) return { error: "Product not found" }; + + const relatedProducts = await getRelatedProducts(product.category1, product.category2, product.productId, 20); + + return { + currentProduct: product, + relatedProducts, + loading: false, + error: null, + status: "done", + }; + } + + return {}; +} + +function generateHead(routeInfo, data) { + const product = routeInfo?.path === "/product/:id/" ? data.currentProduct : null; + const title = generateTitle(routeInfo, product); + return `${title}`; +} + +export async function render(url, query = {}) { + router.addRoute("/", HomePage); + router.addRoute("/product/:id/", ProductDetailPage); + router.addRoute(".*", NotFoundPage); + + const routeInfo = router.match(url, query); + const storeData = await prefetchData(routeInfo, query); + + if (!routeInfo || storeData.error) { + return { + head: "404 - 쇼핑몰", + html: NotFoundPage(), + data: null, + }; + } + + global.__SSR_DATA__ = storeData; + global.router = router; + + const html = routeInfo.handler(); + + delete global.__SSR_DATA__; + delete global.router; + + const head = generateHead(routeInfo, storeData); + + let initialData; + + if (routeInfo.path === "/") { + initialData = { + products: storeData.products, + categories: storeData.categories, + totalCount: storeData.totalCount, + }; + } else if (routeInfo.path === "/product/:id/") { + initialData = { + currentProduct: storeData.currentProduct, + relatedProducts: storeData.relatedProducts, + }; + } else { + initialData = storeData; + } + + const data = initialData ? `` : null; + + return { head, html, data }; +} diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..1eecd5d9 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -1,9 +1,10 @@ -import { registerGlobalEvents } from "./utils"; -import { initRender } from "./render"; +import { BASE_URL } from "./constants.js"; import { registerAllEvents } from "./events"; -import { loadCartFromStorage } from "./services"; +import { initRender } from "./render"; import { router } from "./router"; -import { BASE_URL } from "./constants.js"; +import { loadCartFromStorage } from "./services"; +import { PRODUCT_ACTIONS, productStore } from "./stores"; +import { registerGlobalEvents } from "./utils"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -16,6 +17,38 @@ const enableMocking = () => ); function main() { + // 서버 데이터 복원 + if (window.__INITIAL_DATA__) { + const data = window.__INITIAL_DATA__; + if (data.products) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: data.products, + categories: data.categories, + totalCount: data.totalCount, + loading: false, + error: null, + status: "done", + }, + }); + } + if (data.currentProduct) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, + payload: data.currentProduct, + }); + } + if (data.relatedProducts) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, + payload: data.relatedProducts, + }); + } + delete window.__INITIAL_DATA__; + } + + // 클라이언트 렌더링 시작 registerAllEvents(); registerGlobalEvents(); loadCartFromStorage(); diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 6e3035e6..dd945eef 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -1,70 +1,12 @@ import { http, HttpResponse } from "msw"; +import { enhanceProductDetail, filterProducts, getUniqueCategories, paginateProducts } from "../api/productUtils.js"; import items from "./items.json"; const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); -// 카테고리 추출 함수 -function getUniqueCategories() { - const categories = {}; - - items.forEach((item) => { - const cat1 = item.category1; - const cat2 = item.category2; - - if (!categories[cat1]) categories[cat1] = {}; - if (cat2 && !categories[cat1][cat2]) categories[cat1][cat2] = {}; - }); - - return categories; -} - -// 상품 검색 및 필터링 함수 -function filterProducts(products, query) { - let filtered = [...products]; - - // 검색어 필터링 - if (query.search) { - const searchTerm = query.search.toLowerCase(); - filtered = filtered.filter( - (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), - ); - } - - // 카테고리 필터링 - if (query.category1) { - filtered = filtered.filter((item) => item.category1 === query.category1); - } - if (query.category2) { - filtered = filtered.filter((item) => item.category2 === query.category2); - } - - // 정렬 - if (query.sort) { - switch (query.sort) { - case "price_asc": - filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); - break; - case "price_desc": - filtered.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); - break; - case "name_asc": - filtered.sort((a, b) => a.title.localeCompare(b.title, "ko")); - break; - case "name_desc": - filtered.sort((a, b) => b.title.localeCompare(a.title, "ko")); - break; - default: - // 기본은 가격 낮은 순 - filtered.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); - } - } - - return filtered; -} - export const handlers = [ // 상품 목록 API - http.get("/api/products", async ({ request }) => { + http.get("*/api/products", async ({ request }) => { const url = new URL(request.url); const page = parseInt(url.searchParams.get("page") ?? url.searchParams.get("current")) || 1; const limit = parseInt(url.searchParams.get("limit")) || 20; @@ -82,21 +24,12 @@ export const handlers = [ }); // 페이지네이션 - const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - const paginatedProducts = filteredProducts.slice(startIndex, endIndex); + const { products, pagination } = paginateProducts(filteredProducts, page, limit); // 응답 데이터 const response = { - products: paginatedProducts, - pagination: { - page, - limit, - total: filteredProducts.length, - totalPages: Math.ceil(filteredProducts.length / limit), - hasNext: endIndex < filteredProducts.length, - hasPrev: page > 1, - }, + products, + pagination, filters: { search, category1, @@ -111,7 +44,7 @@ export const handlers = [ }), // 상품 상세 API - http.get("/api/products/:id", ({ params }) => { + http.get("*/api/products/:id", ({ params }) => { const { id } = params; const product = items.find((item) => item.productId === id); @@ -120,21 +53,14 @@ export const handlers = [ } // 상세 정보에 추가 데이터 포함 - const detailProduct = { - ...product, - description: `${product.title}에 대한 상세 설명입니다. ${product.brand} 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다.`, - rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤 - reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤 - stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤 - images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], - }; + const detailProduct = enhanceProductDetail(product); return HttpResponse.json(detailProduct); }), // 카테고리 목록 API - http.get("/api/categories", async () => { - const categories = getUniqueCategories(); + http.get("*/api/categories", async () => { + const categories = getUniqueCategories(items); await delay(); return HttpResponse.json(categories); }), diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index ca08c26c..7412d9b7 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -1,13 +1,17 @@ import { ProductList, SearchBar } from "../components"; -import { productStore } from "../stores"; import { router, withLifecycle } from "../router"; import { loadProducts, loadProductsAndCategories } from "../services"; +import { productStore } from "../stores"; +import { isServer } from "../utils/isServer.js"; import { PageWrapper } from "./PageWrapper.js"; export const HomePage = withLifecycle( { onMount: () => { - loadProductsAndCategories(); + const state = productStore.getState(); + if (state.products.length === 0 || state.loading) { + loadProductsAndCategories(); + } }, watches: [ () => { @@ -18,7 +22,7 @@ export const HomePage = withLifecycle( ], }, () => { - const productState = productStore.getState(); + const productState = isServer() && global.__SSR_DATA__ ? global.__SSR_DATA__ : productStore.getState(); const { search: searchQuery, limit, sort, category1, category2 } = router.query; const { products, loading, error, totalCount, categories } = productState; const category = { category1, category2 }; diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 73d0ec30..30bfbd34 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -1,6 +1,7 @@ -import { productStore } from "../stores"; -import { loadProductDetailForPage } from "../services"; import { router, withLifecycle } from "../router"; +import { loadProductDetailForPage } from "../services"; +import { productStore } from "../stores"; +import { isServer } from "../utils/isServer.js"; import { PageWrapper } from "./PageWrapper.js"; const loadingContent = ` @@ -237,12 +238,16 @@ function ProductDetail({ product, relatedProducts = [] }) { export const ProductDetailPage = withLifecycle( { onMount: () => { - loadProductDetailForPage(router.params.id); + const state = productStore.getState(); + if (!state.currentProduct || state.currentProduct.productId !== router.params.id || state.loading) { + loadProductDetailForPage(router.params.id); + } }, watches: [() => [router.params.id], () => loadProductDetailForPage(router.params.id)], }, () => { - const { currentProduct: product, relatedProducts = [], error, loading } = productStore.getState(); + const storeData = isServer() && global.__SSR_DATA__ ? global.__SSR_DATA__ : productStore.getState(); + const { currentProduct: product, relatedProducts = [], error, loading } = storeData; return PageWrapper({ headerLeft: ` diff --git a/packages/vanilla/src/render.js b/packages/vanilla/src/render.js index 87f30f19..18fed001 100644 --- a/packages/vanilla/src/render.js +++ b/packages/vanilla/src/render.js @@ -1,7 +1,8 @@ -import { cartStore, productStore, uiStore } from "./stores"; -import { router } from "./router"; import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { router } from "./router"; +import { cartStore, productStore, uiStore } from "./stores"; import { withBatch } from "./utils"; +import { updateTitle } from "./utils/updateTitle.js"; // 홈 페이지 (상품 목록) router.addRoute("/", HomePage); @@ -19,6 +20,9 @@ export const render = withBatch(() => { // App 컴포넌트 렌더링 rootElement.innerHTML = PageComponent(); + + // 클라이언트 사이드 네비게이션 시 title 업데이트 + updateTitle(); }); /** diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index d897ee76..daea56cf 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,5 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; +import { UniversalRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -export const router = new Router(BASE_URL); +export const router = new UniversalRouter(BASE_URL); diff --git a/packages/vanilla/src/router/withLifecycle.js b/packages/vanilla/src/router/withLifecycle.js index ccb21113..548191a9 100644 --- a/packages/vanilla/src/router/withLifecycle.js +++ b/packages/vanilla/src/router/withLifecycle.js @@ -1,3 +1,5 @@ +import { isServer } from "../utils/isServer.js"; + const lifeCycles = new WeakMap(); const pageState = { current: null, previous: null }; const initLifecycle = { mount: null, unmount: null, watches: [], deps: [], mounted: false }; @@ -63,6 +65,8 @@ export const withLifecycle = ({ onMount, onUnmount, watches } = {}, page) => { } return (...args) => { + if (isServer()) return page(...args); + const wasNewPage = pageState.current !== page; // 이전 페이지 언마운트 diff --git a/packages/vanilla/src/services/productService.js b/packages/vanilla/src/services/productService.js index 8a12e8bd..492750e1 100644 --- a/packages/vanilla/src/services/productService.js +++ b/packages/vanilla/src/services/productService.js @@ -90,29 +90,33 @@ export const loadMoreProducts = async () => { /** * 상품 검색 */ -export const searchProducts = (search) => { +export const searchProducts = async (search) => { router.query = { search, current: 1 }; + await loadProducts(true); }; /** * 카테고리 필터 설정 */ -export const setCategory = (categoryData) => { +export const setCategory = async (categoryData) => { router.query = { ...categoryData, current: 1 }; + await loadProducts(true); }; /** * 정렬 옵션 변경 */ -export const setSort = (sort) => { +export const setSort = async (sort) => { router.query = { sort, current: 1 }; + await loadProducts(true); }; /** * 페이지당 상품 수 변경 */ -export const setLimit = (limit) => { +export const setLimit = async (limit) => { router.query = { limit, current: 1 }; + await loadProducts(true); }; /** diff --git a/packages/vanilla/src/utils/isServer.js b/packages/vanilla/src/utils/isServer.js new file mode 100644 index 00000000..f0400ae3 --- /dev/null +++ b/packages/vanilla/src/utils/isServer.js @@ -0,0 +1,3 @@ +export function isServer() { + return typeof window === "undefined"; +} diff --git a/packages/vanilla/src/utils/updateTitle.js b/packages/vanilla/src/utils/updateTitle.js new file mode 100644 index 00000000..4f0500ca --- /dev/null +++ b/packages/vanilla/src/utils/updateTitle.js @@ -0,0 +1,42 @@ +import { router } from "../router/router.js"; +import { productStore } from "../stores/index.js"; + +/** + * 라우트 정보와 상품 데이터를 기반으로 title 문자열 생성 + * @param {Object} routeInfo - 라우트 정보 (path 포함) + * @param {Object} product - 상품 정보 (optional) + * @returns {string} title 문자열 + */ +export function generateTitle(routeInfo, product = null) { + if (!routeInfo) { + return "쇼핑몰"; + } + + if (routeInfo.path === "/product/:id/") { + if (product?.title) { + return `${product.title} - 쇼핑몰`; + } + return "상품 상세 - 쇼핑몰"; + } else if (routeInfo.path === "/") { + return "쇼핑몰 - 홈"; + } else { + return "404 - 쇼핑몰"; + } +} + +/** + * 현재 라우트에 따라 document.title 업데이트 + */ +export function updateTitle() { + if (typeof window === "undefined") return; + + const routeInfo = router.route; + let product = null; + + if (routeInfo?.path === "/product/:id/") { + const state = productStore.getState(); + product = state.currentProduct; + } + + document.title = generateTitle(routeInfo, product); +} diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..29bc9cba 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,20 +1,37 @@ import fs from "fs"; - -const render = () => { - return `
안녕하세요
`; -}; +import path from "path"; async function generateStaticSite() { - // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + const { render } = await import("./dist/vanilla-ssr/main-server.js"); + const template = fs.readFileSync("./dist/vanilla/index.html", "utf-8"); + const products = JSON.parse(fs.readFileSync("./src/mocks/items.json", "utf-8")); + const pages = [{ url: "/", filePath: "../../dist/vanilla/index.html" }]; + + products.forEach((product) => + pages.push({ + url: `/product/${product.productId}/`, + filePath: `../../dist/vanilla/product/${product.productId}/index.html`, + }), + ); + + for (const page of pages) { + const { html, head, data } = await render(page.url, {}); + + const result = template + .replace(``, head ?? "") + .replace(``, html ?? "") + .replace(``, data ?? ""); - // 어플리케이션 렌더링하기 - const appHtml = render(); + const dir = path.dirname(page.filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + fs.writeFileSync(page.filePath, result); + } } -// 실행 -generateStaticSite(); +generateStaticSite().catch((error) => { + console.error("SSG 실패", error); + process.exit(1); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 697cd2f9..c1495364 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,9 @@ importers: msw: specifier: ^2.10.2 version: 2.10.3(@types/node@24.0.13)(typescript@5.8.3) + nodemon: + specifier: ^3.1.11 + version: 3.1.11 prettier: specifier: ^3.4.2 version: 3.6.2 @@ -293,6 +296,9 @@ importers: msw: specifier: ^2.10.2 version: 2.10.3(@types/node@24.0.13)(typescript@5.8.3) + nodemon: + specifier: ^3.1.11 + version: 3.1.11 prettier: specifier: ^3.4.2 version: 3.6.2 @@ -1313,6 +1319,10 @@ packages: resolution: {integrity: sha512-BGcItUBWSMRgOCe+SVZJ+S7yTRG0eGt9cXAHev72yuGcY23hnLA7Bky5L/xLyPINoSN95geovfBkqoTlNZYa7w==} engines: {node: '>=14'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1343,6 +1353,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -1401,6 +1415,10 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -1893,6 +1911,10 @@ packages: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1944,6 +1966,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1971,6 +1996,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2311,6 +2340,15 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + nodemon@3.1.11: + resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + npm-run-path@5.3.0: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2481,6 +2519,9 @@ packages: psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2519,6 +2560,10 @@ packages: resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} engines: {node: '>=0.10.0'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -2678,6 +2723,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + sirv@3.0.1: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} @@ -2758,6 +2807,10 @@ packages: resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} engines: {node: '>=0.10.0'} + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2822,6 +2875,10 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + tough-cookie@4.1.4: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} @@ -2879,6 +2936,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} @@ -3126,7 +3186,7 @@ snapshots: '@babel/traverse': 7.28.3 '@babel/types': 7.28.2 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3234,7 +3294,7 @@ snapshots: '@babel/parser': 7.28.3 '@babel/template': 7.27.2 '@babel/types': 7.28.2 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -3246,7 +3306,7 @@ snapshots: '@babel/parser': 7.28.3 '@babel/template': 7.27.2 '@babel/types': 7.28.2 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -3396,7 +3456,7 @@ snapshots: '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -3414,7 +3474,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -3825,7 +3885,7 @@ snapshots: '@typescript-eslint/types': 8.36.0 '@typescript-eslint/typescript-estree': 8.36.0(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.36.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) eslint: 9.30.1 typescript: 5.8.3 transitivePeerDependencies: @@ -3835,7 +3895,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.36.0(typescript@5.8.3) '@typescript-eslint/types': 8.36.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -3853,7 +3913,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.36.0(typescript@5.8.3) '@typescript-eslint/utils': 8.36.0(eslint@9.30.1)(typescript@5.8.3) - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) eslint: 9.30.1 ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 @@ -3868,7 +3928,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.36.0(typescript@5.8.3) '@typescript-eslint/types': 8.36.0 '@typescript-eslint/visitor-keys': 8.36.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 @@ -3924,7 +3984,7 @@ snapshots: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 ast-v8-to-istanbul: 0.3.4 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -4055,6 +4115,11 @@ snapshots: ansis@4.1.0: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + argparse@2.0.1: {} aria-query@5.3.0: @@ -4079,11 +4144,13 @@ snapshots: balanced-match@1.0.2: {} + binary-extensions@2.3.0: {} + body-parser@2.2.0: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -4148,6 +4215,18 @@ snapshots: check-error@2.1.1: {} + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -4244,9 +4323,11 @@ snapshots: dependencies: ms: 2.0.0 - debug@4.4.1: + debug@4.4.1(supports-color@5.5.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 decimal.js@10.6.0: {} @@ -4397,7 +4478,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -4467,7 +4548,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -4539,7 +4620,7 @@ snapshots: finalhandler@2.1.0: dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -4676,6 +4757,8 @@ snapshots: graphql@16.11.0: {} + has-flag@3.0.0: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -4707,14 +4790,14 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -4726,6 +4809,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ignore-by-default@1.0.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4743,6 +4828,10 @@ snapshots: ipaddr.js@1.9.1: {} + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -4780,7 +4869,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.29 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -4936,7 +5025,7 @@ snapshots: dependencies: chalk: 5.4.1 commander: 13.1.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -5088,6 +5177,21 @@ snapshots: node-releases@2.0.19: {} + nodemon@3.1.11: + dependencies: + chokidar: 3.6.0 + debug: 4.4.1(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.2 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + npm-run-path@5.3.0: dependencies: path-key: 4.0.0 @@ -5229,6 +5333,8 @@ snapshots: dependencies: punycode: 2.3.1 + pstree.remy@1.1.8: {} + punycode@2.3.1: {} qs@6.14.0: @@ -5259,6 +5365,10 @@ snapshots: react@19.1.1: {} + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -5342,7 +5452,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -5378,7 +5488,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -5443,6 +5553,10 @@ snapshots: signal-exit@4.1.0: {} + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.2 + sirv@3.0.1: dependencies: '@polka/url': 1.0.0-next.29 @@ -5517,6 +5631,10 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -5568,6 +5686,8 @@ snapshots: totalist@3.0.1: {} + touch@3.1.1: {} + tough-cookie@4.1.4: dependencies: psl: 1.15.0 @@ -5621,6 +5741,8 @@ snapshots: typescript@5.8.3: {} + undefsafe@2.0.5: {} + undici-types@7.8.0: {} universalify@0.2.0: {} @@ -5653,7 +5775,7 @@ snapshots: vite-node@3.2.4(@types/node@24.0.13)(lightningcss@1.30.1)(yaml@2.8.0): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 6.0.3(@types/node@24.0.13)(lightningcss@1.30.1)(yaml@2.8.0) @@ -5693,7 +5815,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.2.1 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) expect-type: 1.2.2 magic-string: 0.30.17 pathe: 2.0.3 @@ -5736,7 +5858,7 @@ snapshots: '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 chai: 5.2.1 - debug: 4.4.1 + debug: 4.4.1(supports-color@5.5.0) expect-type: 1.2.2 magic-string: 0.30.17 pathe: 2.0.3