diff --git a/packages/lib/src/Router.ts b/packages/lib/src/Router.ts index eb3bd157..c201b1df 100644 --- a/packages/lib/src/Router.ts +++ b/packages/lib/src/Router.ts @@ -10,8 +10,6 @@ interface Route { type QueryPayload = Record; -export type RouterInstance = InstanceType>; - // eslint-disable-next-line @typescript-eslint/no-explicit-any export class Router any> { readonly #routes: Map>; diff --git a/packages/lib/src/ServerRouter.ts b/packages/lib/src/ServerRouter.ts new file mode 100644 index 00000000..2c24022c --- /dev/null +++ b/packages/lib/src/ServerRouter.ts @@ -0,0 +1,138 @@ +import { createObserver } from "./createObserver"; +import type { AnyFunction, StringRecord } from "./types"; + +interface Route { + regex: RegExp; + paramNames: string[]; + handler: Handler; + params?: StringRecord; +} + +type QueryPayload = Record; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class ServerRouter any> { + readonly #routes: Map>; + readonly #baseUrl; + readonly #observer = createObserver(); + + #route: null | (Route & { params: StringRecord; path: string }); + #currentUrl = "/"; + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + get query(): StringRecord { + return ServerRouter.parseQuery(this.#currentUrl); + } + + set query(newQuery: QueryPayload) { + const newUrl = ServerRouter.getUrl(newQuery, this.#currentUrl, this.#baseUrl); + this.push(newUrl); + } + + get params() { + return this.#route?.params ?? {}; + } + + get route() { + return this.#route; + } + + get target() { + return this.#route?.handler; + } + + readonly subscribe = this.#observer.subscribe; + + addRoute(path: string, handler: Handler) { + // 경로 패턴을 정규식으로 변환 + const paramNames: string[] = []; + const regexPath = path + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); // ':id' -> 'id' + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); + + this.#routes.set(path, { + regex, + paramNames, + handler, + }); + } + + #findRoute(url = this.#baseUrl) { + const { pathname } = new URL(url, "http://localhost"); + for (const [routePath, route] of this.#routes) { + const match = pathname.match(route.regex); + if (match) { + // 매치된 파라미터들을 객체로 변환 + const params: StringRecord = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + ...route, + params, + path: routePath, + }; + } + } + return null; + } + + push(url: string) { + this.#currentUrl = url; + try { + // baseUrl이 없으면 자동으로 붙여줌 + const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); + + this.#route = this.#findRoute(fullUrl); + this.#observer.notify(); + } catch (error) { + console.error("라우터 네비게이션 오류:", error); + } + } + + start() { + this.#route = this.#findRoute("/"); + } + + static parseQuery = (search: string) => { + const params = new URLSearchParams(search); + const query: StringRecord = {}; + for (const [key, value] of params) { + query[key] = value; + } + return query; + }; + + static stringifyQuery = (query: QueryPayload) => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== null && value !== undefined && value !== "") { + params.set(key, String(value)); + } + } + return params.toString(); + }; + + static getUrl = (updatedQuery: QueryPayload, pathname = "/", baseUrl = "") => { + // 빈 값들 제거 + Object.keys(updatedQuery).forEach((key) => { + if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { + delete updatedQuery[key]; + } + }); + + const queryString = ServerRouter.stringifyQuery(updatedQuery); + return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; + }; +} diff --git a/packages/lib/src/hooks/useRouter.ts b/packages/lib/src/hooks/useRouter.ts index 4a40cb5d..da7713ad 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -1,11 +1,19 @@ -import type { RouterInstance } from "../Router"; -import type { AnyFunction } from "../types"; import { useSyncExternalStore } from "react"; import { useShallowSelector } from "./useShallowSelector"; +import type { AnyFunction, RouterInstance } from "../types"; +import type { Router } from "../Router"; +import type { ServerRouter } from "../ServerRouter"; const defaultSelector = (state: T) => state as unknown as S; -export const useRouter = , S>(router: T, selector = defaultSelector) => { +export const useRouter = , S>( + router: T, + selector = defaultSelector, +) => { const shallowSelector = useShallowSelector(selector); - return useSyncExternalStore(router.subscribe, () => shallowSelector(router)); + return useSyncExternalStore( + router.subscribe, + () => shallowSelector(router), + () => shallowSelector(router), + ); }; diff --git a/packages/lib/src/hooks/useStorage.ts b/packages/lib/src/hooks/useStorage.ts index f620638c..1c51cd7a 100644 --- a/packages/lib/src/hooks/useStorage.ts +++ b/packages/lib/src/hooks/useStorage.ts @@ -4,5 +4,5 @@ import type { createStorage } from "../createStorage"; type Storage = ReturnType>; export const useStorage = (storage: Storage) => { - return useSyncExternalStore(storage.subscribe, storage.get); + return useSyncExternalStore(storage.subscribe, storage.get, storage.get); }; diff --git a/packages/lib/src/hooks/useStore.ts b/packages/lib/src/hooks/useStore.ts index 56fa8800..1613ea83 100644 --- a/packages/lib/src/hooks/useStore.ts +++ b/packages/lib/src/hooks/useStore.ts @@ -8,5 +8,9 @@ const defaultSelector = (state: T) => state as unknown as S; export const useStore = (store: Store, selector: (state: T) => S = defaultSelector) => { const shallowSelector = useShallowSelector(selector); - return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState())); + return useSyncExternalStore( + store.subscribe, + () => shallowSelector(store.getState()), + () => shallowSelector(store.getState()), + ); }; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 74605597..8acf9807 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -2,6 +2,7 @@ export * from "./createObserver"; export * from "./createStorage"; export * from "./createStore"; export * from "./Router"; +export * from "./ServerRouter"; export { useStore, useStorage, useRouter, useAutoCallback } from "./hooks"; export * from "./equals"; export * from "./types"; diff --git a/packages/lib/src/types.ts b/packages/lib/src/types.ts index 70271ab9..32df5c46 100644 --- a/packages/lib/src/types.ts +++ b/packages/lib/src/types.ts @@ -1,5 +1,13 @@ +import { Router } from "./Router"; +import type { ServerRouter } from "./ServerRouter"; + export type StringRecord = Record; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyFunction = (...args: any[]) => any; export type Selector = (state: T) => S; + +export type RouterInstance< + T extends AnyFunction, + R extends typeof Router | typeof ServerRouter = typeof Router, +> = InstanceType; diff --git a/packages/react/index.html b/packages/react/index.html index c93c0168..667b046b 100644 --- a/packages/react/index.html +++ b/packages/react/index.html @@ -1,26 +1,27 @@ - - - - - - - - - -
- - + + + + + + + + + +
+ + + diff --git a/packages/react/server.js b/packages/react/server.js index 6b9430ac..302b496d 100644 --- a/packages/react/server.js +++ b/packages/react/server.js @@ -1,29 +1,77 @@ import express from "express"; -import { renderToString } from "react-dom/server"; -import { createElement } from "react"; +import fs from "node:fs/promises"; +import path from "node:path"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5174; const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/react/" : "/"); +const templateHtml = prod ? await fs.readFile("./dist/react/index.html", "utf-8") : ""; + const app = express(); -app.get("*all", (req, res) => { - res.send( - ` - - - - - - React SSR - - -
${renderToString(createElement("div", null, "안녕하세요"))}
- - - `.trim(), - ); +// 불필요한 요청 무시 +app.get("/favicon.ico", (_, res) => { + res.status(204).end(); +}); +app.get("/.well-known/appspecific/com.chrome.devtools.json", (_, res) => { + res.status(204).end(); +}); + +// Add Vite or respective production middlewares +/** @type {import('vite').ViteDevServer | undefined} */ +let vite; +if (!prod) { + const { createServer } = await import("vite"); + vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", + 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: [] })); +} + +// Serve HTML +app.use("*all", async (req, res) => { + try { + const url = req.originalUrl.replace(base, ""); + const pathname = path.normalize(`/${url.split("?")[0]}`); + + /** @type {string} */ + let template; + /** @type {import('./src/main-server.js').render} */ + let render; + if (!prod) { + // Always read fresh template in development + template = await fs.readFile("./index.html", "utf-8"); + template = await vite.transformIndexHtml(url, template); + render = (await vite.ssrLoadModule("/src/main-server.js")).render; + } else { + template = templateHtml; + render = (await import("./dist/react-ssr/main-server.js")).render; + } + + const rendered = await render(pathname, req.query); + + const html = template + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace( + ``, + ``, + ); + + res.status(200).set({ "Content-Type": "text/html" }).send(html); + } 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..fc6f2e76 100644 --- a/packages/react/src/App.tsx +++ b/packages/react/src/App.tsx @@ -1,10 +1,32 @@ import { router, useCurrentPage } from "./router"; import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; -import { useLoadCartStore } from "./entities"; +import { PRODUCT_ACTIONS, productStore, useLoadCartStore } from "./entities"; import { ModalProvider, ToastProvider } from "./components"; +import { getProducts, getUniqueCategories } from "./mocks/server"; // 홈 페이지 (상품 목록) -router.addRoute("/", HomePage); +router.addRoute("/", () => { + if (typeof window === "undefined") { + const { + products, + pagination: { total: totalCount }, + } = getProducts(); + + const categories = getUniqueCategories(); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products, + categories, + totalCount, + loading: false, + status: "done", + }, + }); + } + return HomePage(); +}); router.addRoute("/product/:id/", ProductDetailPage); router.addRoute(".*", NotFoundPage); @@ -19,6 +41,29 @@ const CartInitializer = () => { export const App = () => { const PageComponent = useCurrentPage(); + // 서버에서 전달받은 초기 데이터 확인 + const initialData = + typeof window !== "undefined" ? (window as unknown as Record).__INITIAL_DATA__ : null; + + // 서버 데이터가 있으면 직접 렌더링 + if (initialData && (initialData as Record).products) { + const data = initialData as Record; + return ( +
+

쇼핑몰

+

총 {data.totalCount as string}개

+
+ {(data.products as Array>).map((product) => ( +
+

{product.title as string}

+

{product.lprice as string}원

+
+ ))} +
+
+ ); + } + return ( <> diff --git a/packages/react/src/entities/carts/storage/cartStorage.ts b/packages/react/src/entities/carts/storage/cartStorage.ts index 6af02204..a027e8c4 100644 --- a/packages/react/src/entities/carts/storage/cartStorage.ts +++ b/packages/react/src/entities/carts/storage/cartStorage.ts @@ -1,7 +1,13 @@ import { createStorage } from "@hanghae-plus/lib"; import type { Cart } from "../types"; +// 강제로 타입 캐스팅? +const storage = + typeof window !== "undefined" + ? window.localStorage + : ({ getItem: () => null, setItem: () => {}, removeItem: () => {} } as unknown as Storage); + export const cartStorage = createStorage<{ items: Cart[]; selectedAll: boolean; -}>("shopping_cart"); +}>("shopping_cart", storage); diff --git a/packages/react/src/entities/products/components/ProductCard.tsx b/packages/react/src/entities/products/components/ProductCard.tsx index 017dc4f5..40a0ef01 100644 --- a/packages/react/src/entities/products/components/ProductCard.tsx +++ b/packages/react/src/entities/products/components/ProductCard.tsx @@ -1,6 +1,5 @@ import { useCartAddCommand } from "../../carts"; import type { Product } from "../types"; -import { log } from "../../../utils"; export function ProductCard({ onClick, ...product }: Product & { onClick: (id: string) => void }) { const addCart = useCartAddCommand(); @@ -10,8 +9,6 @@ export function ProductCard({ onClick, ...product }: Product & { onClick: (id: s const handleClick = () => onClick(productId); - log(`ProductCard: ${productId}`); - return (
) { - log(`ProductDetail: ${product.productId}`); 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/productUseCase.ts b/packages/react/src/entities/products/productUseCase.ts index fa967b56..bb11eb19 100644 --- a/packages/react/src/entities/products/productUseCase.ts +++ b/packages/react/src/entities/products/productUseCase.ts @@ -7,7 +7,20 @@ import { isNearBottom } from "../../utils"; const createErrorMessage = (error: unknown, defaultMessage = "알 수 없는 오류 발생") => error instanceof Error ? error.message : defaultMessage; +export const hydrateProduct = () => { + if (window.__INITIAL_DATA__?.product?.length > 0) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { ...initialProductState, ...window.__INITIAL_DATA__.products, loafing: false, status: "done" }, + }); + } +}; + export const loadProductsAndCategories = async () => { + if (window.__INITIAL_DATA__?.product?.length > 0) { + return; + } + router.query = { current: undefined }; // 항상 첫 페이지로 초기화 productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, diff --git a/packages/react/src/main-server.tsx b/packages/react/src/main-server.tsx index 611b0a58..65ec4c19 100644 --- a/packages/react/src/main-server.tsx +++ b/packages/react/src/main-server.tsx @@ -1,4 +1,71 @@ +import { renderToString } from "react-dom/server"; +import { App } from "./App"; +import { loadHomePageData, loadProductDetailData } from "./ssr-data"; +import type { HomePageData, ProductDetailData } from "./ssr-data"; +import { ServerRouter } from "@hanghae-plus/lib"; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +// import { productStore } from "./entities"; + export const render = async (url: string, query: Record) => { - console.log({ url, query }); - return ""; + try { + // 서버 라우터 초기화 + const serverRouter = new ServerRouter(); + + // 라우트 등록 + serverRouter.addRoute("/", HomePage); + serverRouter.addRoute("/product/:id/", ProductDetailPage); + serverRouter.addRoute(".*", NotFoundPage); + + // 현재 URL 설정 + serverRouter.push(url); + + // URL에 따라 데이터 로딩 + let initialData: HomePageData | ProductDetailData | Record = {}; + let title = "쇼핑몰"; + + if (url === "/") { + // 홈페이지 데이터 로딩 + const homeData = await loadHomePageData(url, query); + // initialData: productStore.getState(); + initialData = { + products: homeData.products, + categories: homeData.categories, + totalCount: homeData.totalCount, + url, + query, + }; + title = "쇼핑몰 - 홈"; + } else if (url.startsWith("/product/")) { + // 상품 상세 페이지 데이터 로딩 + const productId = url.split("/product/")[1]?.split("/")[0]; + if (productId) { + const productData = await loadProductDetailData(productId); + if (productData) { + initialData = { + product: productData.product, + relatedProducts: productData.relatedProducts, + url, + query, + }; + title = `${productData.product.title} - 쇼핑몰`; + } + } + } + + // React 컴포넌트를 HTML 문자열로 렌더링 + const html = renderToString(); + + return { + html, + head: `${title}`, + initialData, + }; + } catch (error) { + console.error("SSR Error:", error); + return { + html: "
Error occurred
", + head: "Error", + initialData: {}, + }; + } }; diff --git a/packages/react/src/main.tsx b/packages/react/src/main.tsx index 0c5b8a67..de93d5fc 100644 --- a/packages/react/src/main.tsx +++ b/packages/react/src/main.tsx @@ -1,7 +1,8 @@ import { App } from "./App"; import { router } from "./router"; import { BASE_URL } from "./constants.ts"; -import { createRoot } from "react-dom/client"; +import { hydrateRoot } from "react-dom/client"; +import { hydrateProduct } from "./entities/index.ts"; const enableMocking = () => import("./mocks/browser").then(({ worker }) => @@ -15,9 +16,11 @@ const enableMocking = () => function main() { router.start(); + hydrateProduct(); const rootElement = document.getElementById("root")!; - createRoot(rootElement).render(); + // createRoot(rootElement).render(); + hydrateRoot(rootElement, ); // 뭐가 다른지 알아보자 } // 애플리케이션 시작 diff --git a/packages/react/src/mocks/mockServer.js b/packages/react/src/mocks/mockServer.js new file mode 100644 index 00000000..f0b5a1d5 --- /dev/null +++ b/packages/react/src/mocks/mockServer.js @@ -0,0 +1,6 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers.js"; + +// MSW 서버 설정 - Node.js 환경에서 API 요청을 가로채기 위한 설정 +// 이 서버는 SSR(Server-Side Rendering) 시 서버에서 발생하는 API 요청을 모킹합니다 +export const mockServer = setupServer(...handlers); diff --git a/packages/react/src/mocks/server.js b/packages/react/src/mocks/server.js new file mode 100644 index 00000000..ffcf44b6 --- /dev/null +++ b/packages/react/src/mocks/server.js @@ -0,0 +1,100 @@ +import items from "./items.json" with { type: "json" }; + +// 카테고리 추출 함수 +export 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 function getProducts(query = {}) { + const page = parseInt(query("page") ?? query("current") ?? "1"); + const limit = parseInt(query("limit") ?? "20"); + const search = query("search") || ""; + const category1 = query("category1") || ""; + const category2 = query("category2") || ""; + const sort = query("sort") || "price_asc"; + + // 필터링된 상품들 + const filteredProducts = filterProducts(items, { + search, + category1, + category2, + sort, + }); + + // 페이지네이션 + 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, + }, + filters: { + search, + category1, + category2, + sort, + }, + }; +} diff --git a/packages/react/src/pages/HomePage.tsx b/packages/react/src/pages/HomePage.tsx index 4edbccc6..ec7e5463 100644 --- a/packages/react/src/pages/HomePage.tsx +++ b/packages/react/src/pages/HomePage.tsx @@ -1,5 +1,12 @@ import { useEffect } from "react"; -import { loadNextProducts, loadProductsAndCategories, ProductList, SearchBar } from "../entities"; +import { + hydrateProduct, + loadNextProducts, + loadProductsAndCategories, + ProductList, + // productStore, + SearchBar, +} from "../entities"; import { PageWrapper } from "./PageWrapper"; const headerLeft = ( @@ -29,7 +36,11 @@ const unregisterScrollHandler = () => { export const HomePage = () => { useEffect(() => { registerScrollHandler(); - loadProductsAndCategories(); + if (window.__INITIAL_DATA__?.productStore.length === 0) { + loadProductsAndCategories(); + } else { + hydrateProduct(); + } return unregisterScrollHandler; }, []); diff --git a/packages/react/src/router/router.ts b/packages/react/src/router/router.ts index ddb3a7cd..fcf0fb0c 100644 --- a/packages/react/src/router/router.ts +++ b/packages/react/src/router/router.ts @@ -1,6 +1,8 @@ // 글로벌 라우터 인스턴스 -import { Router } from "@hanghae-plus/lib"; +import { Router, ServerRouter } from "@hanghae-plus/lib"; import { BASE_URL } from "../constants"; import type { FunctionComponent } from "react"; -export const router = new Router(BASE_URL); +const CurrentRouter = typeof window === "undefined" ? ServerRouter : Router; + +export const router = new CurrentRouter(BASE_URL); diff --git a/packages/react/src/ssr-data.ts b/packages/react/src/ssr-data.ts new file mode 100644 index 00000000..ebe7607f --- /dev/null +++ b/packages/react/src/ssr-data.ts @@ -0,0 +1,131 @@ +// 서버 데이터 로딩 시스템 +import items from "./mocks/items.json" with { type: "json" }; +import type { Product } from "./entities"; + +export interface Category { + [key: string]: { + [key: string]: { + [key: string]: { + [key: string]: Record; + }; + }; + }; +} + +export interface HomePageData { + products: Product[]; + categories: Category; + totalCount: number; +} + +export interface ProductDetailData { + product: Product; + relatedProducts: Product[]; +} + +// 카테고리 추출 함수 (handlers.ts에서 가져옴) +function getUniqueCategories(): Category { + const categories: Category = {}; + + items.forEach((item: Product) => { + if (!categories[item.category1]) { + categories[item.category1] = {}; + } + if (!categories[item.category1][item.category2]) { + categories[item.category1][item.category2] = {}; + } + if (!categories[item.category1][item.category2][item.category3]) { + categories[item.category1][item.category2][item.category3] = {}; + } + if (!categories[item.category1][item.category2][item.category3][item.category4]) { + categories[item.category1][item.category2][item.category3][item.category4] = {}; + } + }); + + return categories; +} + +// 상품 검색 및 필터링 함수 (handlers.ts에서 가져옴) +function filterProducts(products: Product[], query: Record): Product[] { + let filtered = [...products]; + + // 검색어 필터링 + if (query.search) { + const searchTerm = query.search.toLowerCase(); + filtered = filtered.filter((product) => product.title.toLowerCase().includes(searchTerm)); + } + + // 카테고리 필터링 + if (query.category1) { + filtered = filtered.filter((product) => product.category1 === query.category1); + } + if (query.category2) { + filtered = filtered.filter((product) => product.category2 === query.category2); + } + if (query.category3) { + filtered = filtered.filter((product) => product.category3 === query.category3); + } + if (query.category4) { + filtered = filtered.filter((product) => product.category4 === query.category4); + } + + // 정렬 + 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)); + break; + case "name_desc": + filtered.sort((a, b) => b.title.localeCompare(a.title)); + break; + } + } + + return filtered; +} + +// 상품 ID로 상품 찾기 (handlers.ts에서 가져옴) +function findProductById(productId: string): Product | undefined { + return items.find((item: Product) => item.productId === productId); +} + +// 관련 상품 가져오기 (handlers.ts에서 가져옴) +function getRelatedProducts(currentProductId: string): Product[] { + const currentProduct = findProductById(currentProductId); + if (!currentProduct) return []; + + return items + .filter((item: Product) => item.productId !== currentProductId && item.category1 === currentProduct.category1) + .slice(0, 4); +} + +export async function loadHomePageData(url: string, query: Record): Promise { + const filteredProducts = filterProducts(items as Product[], query); + const categories = getUniqueCategories(); + + return { + products: filteredProducts, + categories, + totalCount: filteredProducts.length, + }; +} + +export async function loadProductDetailData(productId: string): Promise { + const product = findProductById(productId); + if (!product) { + return null; + } + + const relatedProducts = getRelatedProducts(productId); + + return { + product, + relatedProducts, + }; +} diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index a65dba85..75c133fa 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,2 +1,9 @@ export type StringRecord = Record; export type AnyFunction = (...args: unknown[]) => unknown; + +declare global { + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + __INITIAL_DATA__?: any; + } +} diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index 8f590fd9..9069f657 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -1,3 +1,2 @@ export * from "./domUtils"; export * from "./debounce"; -export * from "./log"; diff --git a/packages/react/src/utils/log.ts b/packages/react/src/utils/log.ts deleted file mode 100644 index 00aa2d47..00000000 --- a/packages/react/src/utils/log.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -declare global { - interface Window { - __spyCalls: any[]; - __spyCallsClear: () => void; - } -} - -window.__spyCalls = []; -window.__spyCallsClear = () => { - window.__spyCalls = []; -}; - -export const log: typeof console.log = (...args) => { - window.__spyCalls.push(args); - return console.log(...args); -}; diff --git a/packages/vanilla/index.html b/packages/vanilla/index.html index 483a6d5e..c610980b 100644 --- a/packages/vanilla/index.html +++ b/packages/vanilla/index.html @@ -5,22 +5,23 @@ - +
+ diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..0428292c 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,31 +1,77 @@ import express from "express"; +import fs from "node:fs/promises"; +import path from "node:path"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); +const templateHtml = prod ? await fs.readFile("./dist/vanilla/index.html", "utf-8") : ""; + const app = express(); -const render = () => { - return `
안녕하세요
`; -}; - -app.get("*all", (req, res) => { - res.send( - ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- - - `.trim(), - ); +// 불필요한 요청 무시 +app.get("/favicon.ico", (_, res) => { + res.status(204).end(); +}); +app.get("/.well-known/appspecific/com.chrome.devtools.json", (_, res) => { + res.status(204).end(); +}); + +// Add Vite or respective production middlewares +/** @type {import('vite').ViteDevServer | undefined} */ +let vite; +if (!prod) { + const { createServer } = await import("vite"); + vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", + 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: [] })); +} + +// Serve HTML +app.use("*all", async (req, res) => { + try { + const url = req.originalUrl.replace(base, ""); + const pathname = path.normalize(`/${url.split("?")[0]}`); + + /** @type {string} */ + let template; + /** @type {import('./src/main-server.js').render} */ + let render; + if (!prod) { + // Always read fresh template in development + template = await fs.readFile("./index.html", "utf-8"); + template = await vite.transformIndexHtml(url, template); + render = (await vite.ssrLoadModule("/src/main-server.js")).render; + } else { + template = templateHtml; + render = (await import("./dist/vanilla-ssr/main-server.js")).render; + } + + const rendered = await render(pathname, req.query); + + const html = template + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace( + ``, + ``, + ); + + res.status(200).set({ "Content-Type": "text/html" }).send(html); + } catch (e) { + vite?.ssrFixStacktrace(e); + console.log(e.stack); + res.status(500).end(e.stack); + } }); // Start http server diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index c2330fbe..a7e4e954 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,7 +1,81 @@ +import { isServer } from "../utils/runtime.js"; +import fs from "node:fs"; +import path from "node:path"; + +// 서버 환경에서 items.json 데이터 로드 +let items = []; +if (isServer) { + try { + const itemsPath = path.join(process.cwd(), "src/mocks/items.json"); + items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); + } catch (error) { + console.error("items.json 로드 실패:", error); + items = []; + } +} + +const withBaseUrl = (url) => { + return isServer ? new URL(url, `http://localhost`) : url; +}; + export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; + // 서버 환경에서는 items.json 데이터 사용 + if (isServer) { + let filteredProducts = [...items]; + + if (search) { + filteredProducts = filteredProducts.filter((p) => p.title.toLowerCase().includes(search.toLowerCase())); + } + + if (category1) { + filteredProducts = filteredProducts.filter((p) => p.category1 === category1); + } + + if (category2) { + filteredProducts = filteredProducts.filter((p) => p.category2 === category2); + } + + // 정렬 + if (sort) { + switch (sort) { + case "price_asc": + filteredProducts.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + break; + case "price_desc": + filteredProducts.sort((a, b) => parseInt(b.lprice) - parseInt(a.lprice)); + break; + case "name_asc": + filteredProducts.sort((a, b) => a.title.localeCompare(b.title, "ko")); + break; + case "name_desc": + filteredProducts.sort((a, b) => b.title.localeCompare(a.title, "ko")); + break; + default: + filteredProducts.sort((a, b) => parseInt(a.lprice) - parseInt(b.lprice)); + } + } + + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedProducts = filteredProducts.slice(startIndex, endIndex); + + return { + products: paginatedProducts, + pagination: { + total: filteredProducts.length, + page, + limit, + totalPages: Math.ceil(filteredProducts.length / limit), + hasNext: endIndex < filteredProducts.length, + hasPrev: page > 1, + }, + }; + } + + // 클라이언트 환경에서는 기존 로직 사용 const searchParams = new URLSearchParams({ page: page.toString(), limit: limit.toString(), @@ -11,17 +85,36 @@ export async function getProducts(params = {}) { sort, }); - const response = await fetch(`/api/products?${searchParams}`); - + const response = await fetch(withBaseUrl(`/api/products?${searchParams}`)); return await response.json(); } export async function getProduct(productId) { - const response = await fetch(`/api/products/${productId}`); + // 서버 환경에서는 items.json 데이터 사용 + if (isServer) { + const product = items.find((p) => p.productId === productId); + return product || null; + } + + // 클라이언트 환경에서는 기존 로직 사용 + const response = await fetch(withBaseUrl(`/api/products/${productId}`)); return await response.json(); } export async function getCategories() { - const response = await fetch("/api/categories"); + // 서버 환경에서는 items.json에서 카테고리 추출 + if (isServer) { + 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; + } + + // 클라이언트 환경에서는 기존 로직 사용 + const response = await fetch(withBaseUrl("/api/categories")); return await response.json(); } diff --git a/packages/vanilla/src/lib/BaseRouter.js b/packages/vanilla/src/lib/BaseRouter.js new file mode 100644 index 00000000..282c0455 --- /dev/null +++ b/packages/vanilla/src/lib/BaseRouter.js @@ -0,0 +1,133 @@ +/** + * 기본 라우터 - 공통 기능을 제공하는 추상 클래스 + */ +import { createObserver } from "./createObserver.js"; + +export class BaseRouter { + #routes; + #route; + #observer = createObserver(); + #baseUrl; + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + get baseUrl() { + return this.#baseUrl; + } + + get params() { + return this.#route?.params ?? {}; + } + + get route() { + return this.#route; + } + + get target() { + return this.#route?.handler; + } + + subscribe(fn) { + this.#observer.subscribe(fn); + } + + /** + * 라우트 등록 + * @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)); + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); + + this.#routes.set(path, { + regex, + paramNames, + handler, + }); + } + + findRoute(url) { + const { pathname } = new URL(url, this.getOrigin()); + 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]; + }); + + return { + ...route, + params, + path: routePath, + }; + } + } + return null; + } + + updateRoute(url) { + this.#route = this.findRoute(url); + this.#observer.notify(); + } + + // 추상 메서드들 - 하위 클래스에서 구현 필요 + getCurrentUrl() { + throw new Error("getCurrentUrl must be implemented by subclass"); + } + + getOrigin() { + throw new Error("getOrigin must be implemented by subclass"); + } + + /** + * 쿼리 파라미터를 객체로 파싱 + */ + static parseQuery(search) { + const params = new URLSearchParams(search); + const query = {}; + for (const [key, value] of params) { + query[key] = value; + } + return query; + } + + /** + * 객체를 쿼리 문자열로 변환 + */ + static stringifyQuery(query) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== null && value !== undefined && value !== "") { + params.set(key, String(value)); + } + } + return params.toString(); + } + + static getUrl(newQuery, baseUrl = "", pathname = "", search = "") { + const currentQuery = BaseRouter.parseQuery(search); + const updatedQuery = { ...currentQuery, ...newQuery }; + + Object.keys(updatedQuery).forEach((key) => { + if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { + delete updatedQuery[key]; + } + }); + + const queryString = BaseRouter.stringifyQuery(updatedQuery); + return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; + } +} diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js index 2238a878..7907c4a3 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/Router.js @@ -1,117 +1,48 @@ /** - * 간단한 SPA 라우터 + * 클라이언트사이드 SPA 라우터 */ -import { createObserver } from "./createObserver.js"; - -export class Router { - #routes; - #route; - #observer = createObserver(); - #baseUrl; +import { BaseRouter } from "./BaseRouter.js"; +export class Router extends BaseRouter { constructor(baseUrl = "") { - this.#routes = new Map(); - this.#route = null; - this.#baseUrl = baseUrl.replace(/\/$/, ""); + super(baseUrl); window.addEventListener("popstate", () => { - this.#route = this.#findRoute(); - this.#observer.notify(); + this.updateRoute(this.getCurrentUrl()); }); } - get baseUrl() { - return this.#baseUrl; - } - get query() { - return Router.parseQuery(window.location.search); + return BaseRouter.parseQuery(window.location.search); } set query(newQuery) { - const newUrl = Router.getUrl(newQuery, this.#baseUrl); + const newUrl = BaseRouter.getUrl(newQuery, this.baseUrl, window.location.pathname, window.location.search); this.push(newUrl); } - get params() { - return this.#route?.params ?? {}; - } - - get route() { - return this.#route; + getCurrentUrl() { + return `${window.location.pathname}${window.location.search}`; } - get target() { - return this.#route?.handler; - } - - subscribe(fn) { - this.#observer.subscribe(fn); - } - - /** - * 라우트 등록 - * @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(`^${this.#baseUrl}${regexPath}$`); - - this.#routes.set(path, { - regex, - paramNames, - handler, - }); - } - - #findRoute(url = window.location.pathname) { - const { pathname } = new URL(url, window.location.origin); - 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]; - }); - - return { - ...route, - params, - path: routePath, - }; - } - } - return null; + getOrigin() { + return window.location.origin; } /** * 네비게이션 실행 - * @param {string} url - 이동할 경로 */ push(url) { try { - // baseUrl이 없으면 자동으로 붙여줌 - let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); + let fullUrl = url.startsWith(this.baseUrl) ? url : this.baseUrl + (url.startsWith("/") ? url : "/" + url); const prevFullUrl = `${window.location.pathname}${window.location.search}`; - // 히스토리 업데이트 if (prevFullUrl !== fullUrl) { window.history.pushState(null, "", fullUrl); } - this.#route = this.#findRoute(fullUrl); - this.#observer.notify(); + this.updateRoute(fullUrl); } catch (error) { console.error("라우터 네비게이션 오류:", error); } @@ -121,51 +52,6 @@ export class Router { * 라우터 시작 */ start() { - this.#route = this.#findRoute(); - this.#observer.notify(); + this.updateRoute(this.getCurrentUrl()); } - - /** - * 쿼리 파라미터를 객체로 파싱 - * @param {string} search - location.search 또는 쿼리 문자열 - * @returns {Object} 파싱된 쿼리 객체 - */ - static parseQuery = (search = window.location.search) => { - const params = new URLSearchParams(search); - const query = {}; - for (const [key, value] of params) { - query[key] = value; - } - return query; - }; - - /** - * 객체를 쿼리 문자열로 변환 - * @param {Object} query - 쿼리 객체 - * @returns {string} 쿼리 문자열 - */ - static stringifyQuery = (query) => { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(query)) { - if (value !== null && value !== undefined && value !== "") { - params.set(key, String(value)); - } - } - return params.toString(); - }; - - static getUrl = (newQuery, baseUrl = "") => { - const currentQuery = Router.parseQuery(); - const updatedQuery = { ...currentQuery, ...newQuery }; - - // 빈 값들 제거 - Object.keys(updatedQuery).forEach((key) => { - if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { - delete updatedQuery[key]; - } - }); - - const queryString = Router.stringifyQuery(updatedQuery); - return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; - }; } diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js new file mode 100644 index 00000000..9dcdea37 --- /dev/null +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -0,0 +1,142 @@ +/** + * 서버사이드 라우터 + */ +import { BaseRouter } from "./BaseRouter.js"; + +export class ServerRouter extends BaseRouter { + #currentUrl = "/"; + #origin = "http://localhost"; + #routes = new Map(); + #query = {}; + + constructor(baseUrl = "") { + super(baseUrl); + } + + set query(newQuery) { + // 서버사이드에서는 URL 재구성하지 않고 query만 저장 + this.#query = newQuery; + } + + get query() { + // query getter도 수정 + if (this.#query) { + return this.#query; + } + const url = new URL(this.#currentUrl, this.#origin); + return BaseRouter.parseQuery(url.search); + } + + getCurrentUrl() { + return this.#currentUrl; + } + + getOrigin() { + return this.#origin; + } + + /** + * 서버 URL 설정 + * @param {string} url - 요청 URL + * @param {string} [origin] - 서버 origin (선택적) + */ + setUrl(url, origin = "http://localhost") { + this.#currentUrl = url; + this.#origin = origin; + this.updateRoute(this.getCurrentUrl()); + } + + /** + * 서버사이드에서는 네비게이션 불가 + */ + push() { + throw new Error("Navigation is not supported in server-side routing"); + } + + /** + * 라우터 시작 + */ + start(url, query = {}) { + this.setUrl(url, this.#origin); + this.query = query; + } + + /** + * 라우트 등록 + */ + addRoute(path, handler) { + this.#routes.set(path, handler); + } + + /** + * 현재 라우트 찾기 + */ + findRoute(url) { + const { pathname } = new URL(url, this.#origin); + + // 정확한 매칭 먼저 시도 + if (this.#routes.has(pathname)) { + return { + path: pathname, + handler: this.#routes.get(pathname), + params: {}, + }; + } + + // 동적 라우트 매칭 + for (const [routePath, handler] of this.#routes) { + if (routePath.includes(":")) { + const paramNames = []; + const regexPath = routePath + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + // baseUrl을 고려한 정규식 생성 + const regex = new RegExp(`^${this.baseUrl}${regexPath}$`); + const match = pathname.match(regex); + + if (match) { + const params = {}; + paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + path: routePath, + handler, + params, + }; + } + } + } + + // 와일드카드 매칭 + if (this.#routes.has(".*")) { + return { + path: ".*", + handler: this.#routes.get(".*"), + params: {}, + }; + } + + return null; + } + + /** + * 현재 라우트 정보 가져오기 + */ + get route() { + return this.findRoute(this.#currentUrl); + } + + /** + * 현재 핸들러 가져오기 + */ + get target() { + const route = this.route; + return route ? route.handler : null; + } +} diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..73c29694 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -4,9 +4,10 @@ * @param {Storage} storage - 기본값은 localStorage * @returns {Object} { get, set, reset } */ -export const createStorage = (key, storage = window.localStorage) => { +export const createStorage = (key, storage = typeof window !== "undefined" ? window.localStorage : null) => { const get = () => { try { + if (!storage) return null; const item = storage.getItem(key); return item ? JSON.parse(item) : null; } catch (error) { @@ -17,6 +18,7 @@ export const createStorage = (key, storage = window.localStorage) => { const set = (value) => { try { + if (!storage) return; storage.setItem(key, JSON.stringify(value)); } catch (error) { console.error(`Error setting storage item for key "${key}":`, error); @@ -25,6 +27,7 @@ export const createStorage = (key, storage = window.localStorage) => { const reset = () => { try { + if (!storage) return; storage.removeItem(key); } catch (error) { console.error(`Error removing storage item for key "${key}":`, error); diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index a598ef30..da63f1df 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -2,3 +2,4 @@ export * from "./createObserver"; export * from "./createStore"; export * from "./createStorage"; export * from "./Router"; +export * from "./ServerRouter"; diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..9098d4ed 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,179 @@ +import { getCategories, getProduct, getProducts } from "./api/productApi"; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { serverRouter } from "./router/serverRouter"; +import { PRODUCT_ACTIONS, productStore } from "./stores"; + +// 서버 라우터 등록 +serverRouter.addRoute("/", HomePage); +serverRouter.addRoute("/product/:id/", ProductDetailPage); +serverRouter.addRoute(".*", NotFoundPage); + +// 스토어 디스패치 헬퍼 함수 +const updateStore = (payload) => { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload, + }); +}; + +// 기본 스토어 상태 생성 함수 +const createBaseStoreState = () => ({ + products: [], + totalCount: 0, + categories: {}, + currentProduct: null, + relatedProducts: [], + loading: false, + error: null, + status: "done", +}); + +/** + * 서버 렌더링 함수 + * @param {string} url - 요청 URL + * @param {Object} query - 요청 쿼리 + * @returns {Promise} - 렌더링 결과 + */ export const render = async (url, query) => { - console.log({ url, query }); - return ""; + try { + // URL에서 쿼리 파라미터 추출 + const urlObj = new URL(url, "http://localhost"); + const urlQuery = {}; + urlObj.searchParams.forEach((value, key) => { + urlQuery[key] = value; + }); + + // 전달된 query와 URL의 쿼리를 병합 + const mergedQuery = { ...urlQuery, ...query }; + + // 전체 URL을 사용하여 라우터 시작 + serverRouter.start(url, mergedQuery); + + // router 객체의 query도 설정 + if (typeof window === "undefined") { + const { router } = await import("./router/router.js"); + router.query = mergedQuery; + } + + const route = serverRouter.route; + if (!route) { + return { + head: "페이지를 찾을 수 없습니다", + html: NotFoundPage(), + initialData: {}, + }; + } + + let head; + let initialData; + + // 홈페이지 처리 + if (route.path === "/") { + try { + const [productsResponse, categories] = await Promise.all([getProducts(serverRouter.query), getCategories()]); + + const storeState = { + ...createBaseStoreState(), + products: productsResponse.products || [], + totalCount: productsResponse.pagination?.total || 0, + categories: categories || {}, + }; + + updateStore(storeState); + + // withLifecycle의 metadata 함수 호출 + const metadata = HomePage.metadata ? HomePage.metadata() : {}; + head = `${metadata.title || "쇼핑몰 - 홈"} +`; + initialData = { + products: storeState.products, + categories: storeState.categories, + totalCount: storeState.totalCount, + }; + } catch (error) { + const errorState = { + ...createBaseStoreState(), + error: error.message, + status: "error", + }; + + updateStore(errorState); + + initialData = { + products: [], + categories: {}, + totalCount: 0, + }; + } + } + // 상품 상세 페이지 처리 + else if (route.path === "/product/:id/") { + const productId = route.params.id; + + try { + const product = await getProduct(productId); + + let relatedProducts = []; + if (product?.category2) { + const relatedResponse = await getProducts({ + category2: product.category2, + limit: 20, + page: 1, + }); + relatedProducts = relatedResponse.products?.filter((p) => p.productId !== productId) || []; + } + + const storeState = { + ...createBaseStoreState(), + currentProduct: product, + relatedProducts, + }; + + updateStore(storeState); + + // 상품 데이터가 있을 때만 동적 메타데이터 생성 + if (product) { + head = `${product.title} - 쇼핑몰 +`; + } else { + head = `상품 상세 - 쇼핑몰 +`; + } + initialData = { + product, + relatedProducts, + }; + } catch (error) { + const errorState = { + ...createBaseStoreState(), + error: error.message, + status: "error", + }; + + updateStore(errorState); + + initialData = { + product: null, + relatedProducts: [], + }; + } + } + + const PageComponent = serverRouter.target; + const html = PageComponent(); + + return { + html, + head, + initialData, + }; + } catch (error) { + console.error("❌ SSR 에러:", error); + + return { + head: "에러", + html: "
서버 오류가 발생했습니다.
", + initialData: { error: error.message }, + }; + } }; diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..280929dc 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -4,6 +4,7 @@ import { registerAllEvents } from "./events"; import { loadCartFromStorage } from "./services"; import { router } from "./router"; import { BASE_URL } from "./constants.js"; +import { productStore, PRODUCT_ACTIONS } from "./stores"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -15,7 +16,50 @@ const enableMocking = () => }), ); +// 서버 데이터 복원 (Hydration) +function restoreServerData() { + if (window.__INITIAL_DATA__) { + console.log("🔄 서버 데이터 복원 중:", window.__INITIAL_DATA__); + const data = window.__INITIAL_DATA__; + + if (data.products && data.products.length > 0) { + // 홈페이지 데이터 복원 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: data.products, + totalCount: data.totalCount, + categories: data.categories, + loading: false, + 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__; + console.log("✅ 서버 데이터 복원 완료"); + } +} + function main() { + // 서버 데이터 복원을 먼저 실행 + restoreServerData(); + registerAllEvents(); registerGlobalEvents(); loadCartFromStorage(); @@ -23,7 +67,7 @@ function main() { router.start(); } -if (import.meta.env.MODE !== "test") { +if (import.meta.env.MODE !== "test" && typeof window !== "undefined") { enableMocking().then(main); } else { main(); diff --git a/packages/vanilla/src/mocks/mockServer.js b/packages/vanilla/src/mocks/mockServer.js new file mode 100644 index 00000000..f0b5a1d5 --- /dev/null +++ b/packages/vanilla/src/mocks/mockServer.js @@ -0,0 +1,6 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers.js"; + +// MSW 서버 설정 - Node.js 환경에서 API 요청을 가로채기 위한 설정 +// 이 서버는 SSR(Server-Side Rendering) 시 서버에서 발생하는 API 요청을 모킹합니다 +export const mockServer = setupServer(...handlers); diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index ca08c26c..bdb770e5 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -1,5 +1,5 @@ import { ProductList, SearchBar } from "../components"; -import { productStore } from "../stores"; +import { PRODUCT_ACTIONS, productStore } from "../stores"; import { router, withLifecycle } from "../router"; import { loadProducts, loadProductsAndCategories } from "../services"; import { PageWrapper } from "./PageWrapper.js"; @@ -7,6 +7,37 @@ import { PageWrapper } from "./PageWrapper.js"; export const HomePage = withLifecycle( { onMount: () => { + if (typeof window === "undefined") { + console.log("이 코드는 서버에서 실행이 되고 "); + return; + } + if (window.__INITIAL_DATA__?.products?.length > 0) { + console.log("이 코드는 클라이언트에서 실행이 되는데, __INITIAL_DATA__ 가 있을 때에만!"); + const { products, categories, totalCount } = window.__INITIAL_DATA__; + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products, + categories, + totalCount, + loading: false, + status: "done", + }, + }); + return; + } + console.log("이 코드는 아무것도 없을 때!"); + // CSR 환경에서 로딩 상태를 보기 위해 초기 상태 설정 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: [], + categories: {}, + totalCount: 0, + loading: true, + status: "pending", + }, + }); loadProductsAndCategories(); }, watches: [ @@ -16,9 +47,34 @@ export const HomePage = withLifecycle( }, () => loadProducts(true), ], + metadata: () => { + const { search, category1, category2 } = router.query; + let title = "쇼핑몰"; + let description = "다양한 상품을 만나보세요"; + + if (search) { + title = `"${search}" 검색 결과 - 쇼핑몰`; + description = `"${search}" 검색 결과를 확인해보세요.`; + } else if (category1 || category2) { + const category = category2 || category1; + title = `${category} 카테고리 - 쇼핑몰`; + description = `${category} 카테고리의 상품들을 확인해보세요.`; + } + + return { title, description }; + }, }, - () => { - const productState = productStore.getState(); + (props = {}) => { + const productState = + props.products?.length > 0 + ? { + products: props.products, + loading: false, + error: null, + totalCount: props.totalCount, + categories: props.categories, + } + : 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..1cb7ca80 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -237,9 +237,33 @@ function ProductDetail({ product, relatedProducts = [] }) { export const ProductDetailPage = withLifecycle( { onMount: () => { - loadProductDetailForPage(router.params.id); + // 서버 환경에서는 이미 데이터가 로드되어 있으므로 추가 로드하지 않음 + if (typeof window !== "undefined") { + loadProductDetailForPage(router.params.id); + } + }, + watches: [ + () => [router.params.id], + () => { + // 서버 환경에서는 추가 로드하지 않음 + if (typeof window !== "undefined") { + loadProductDetailForPage(router.params.id); + } + }, + ], + metadata: () => { + const { currentProduct: product } = productStore.getState(); + if (product) { + return { + title: `${product.title} - 쇼핑몰`, + description: `${product.title} 상품 정보를 확인해보세요.`, + }; + } + return { + title: "상품 상세 - 쇼핑몰", + description: "상품 정보를 확인해보세요.", + }; }, - watches: [() => [router.params.id], () => loadProductDetailForPage(router.params.id)], }, () => { const { currentProduct: product, relatedProducts = [], error, loading } = productStore.getState(); diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index d897ee76..9349bae3 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,7 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; +import { Router, ServerRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -export const router = new Router(BASE_URL); +const CurrentRouter = typeof window !== "undefined" ? Router : ServerRouter; + +export const router = new CurrentRouter(BASE_URL); diff --git a/packages/vanilla/src/router/serverRouter.js b/packages/vanilla/src/router/serverRouter.js new file mode 100644 index 00000000..a7f2a34b --- /dev/null +++ b/packages/vanilla/src/router/serverRouter.js @@ -0,0 +1,4 @@ +import { ServerRouter } from "../lib/ServerRouter.js"; + +// 서버 전용 라우터 인스턴스 +export const serverRouter = new ServerRouter(); diff --git a/packages/vanilla/src/storage/cartStorage.js b/packages/vanilla/src/storage/cartStorage.js index 7aa68383..c6ab6172 100644 --- a/packages/vanilla/src/storage/cartStorage.js +++ b/packages/vanilla/src/storage/cartStorage.js @@ -1,3 +1,12 @@ import { createStorage } from "../lib"; -export const cartStorage = createStorage("shopping_cart"); +const storage = + typeof window !== "undefined" + ? window.localStorage + : { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }; + +export const cartStorage = createStorage("shopping_cart", storage); diff --git a/packages/vanilla/src/utils/runtime.js b/packages/vanilla/src/utils/runtime.js new file mode 100644 index 00000000..c967b2a8 --- /dev/null +++ b/packages/vanilla/src/utils/runtime.js @@ -0,0 +1,15 @@ +/** + * 런타임 환경 유틸리티 + */ + +// 서버 환경인지 확인 +export const isServer = typeof window === "undefined"; + +// 클라이언트 환경인지 확인 +export const isClient = typeof window !== "undefined"; + +// Node.js 환경인지 확인 +export const isNode = typeof process !== "undefined" && process.versions && process.versions.node; + +// 브라우저 환경인지 확인 +export const isBrowser = typeof window !== "undefined" && typeof document !== "undefined"; diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..d55e31f2 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,19 +1,114 @@ import fs from "fs"; +import path from "path"; -const render = () => { - return `
안녕하세요
`; -}; +// 서버용 렌더링 함수 import +const { render } = await import("./dist/vanilla-ssr/main-server.js"); + +// 메타태그 생성 함수 +function generateHead(title, description = "") { + return ` + ${title} + + `; +} + +// HTML 템플릿에서 플레이스홀더 교체 +function replacePlaceholders(template, html, head, initialData) { + return template + .replace("", head) + .replace("", html) + .replace("", ``); +} + +// 디렉토리 생성 함수 +function ensureDirectoryExists(filePath) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} async function generateStaticSite() { - // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + try { + console.log("🚀 SSG 시작..."); + + // HTML 템플릿 읽기 + const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + console.log("✅ HTML 템플릿 로드 완료"); + + // 1. 홈페이지 생성 + console.log("📄 홈페이지 생성 중..."); + const homeResult = await render("/", {}); + const homeHtml = replacePlaceholders( + template, + homeResult.html, + generateHead("쇼핑몰 - 홈", "다양한 상품을 만나보세요"), + homeResult.initialData, + ); + fs.writeFileSync("../../dist/vanilla/index.html", homeHtml); + console.log("✅ 홈페이지 생성 완료"); + + // 2. 404 페이지 생성 + console.log("📄 404 페이지 생성 중..."); + const notFoundResult = await render("/404", {}); + const notFoundHtml = replacePlaceholders( + template, + notFoundResult.html, + generateHead("페이지를 찾을 수 없습니다 - 쇼핑몰"), + notFoundResult.initialData, + ); + fs.writeFileSync("../../dist/vanilla/404.html", notFoundHtml); + console.log("✅ 404 페이지 생성 완료"); + + // 3. 상품 상세 페이지들 생성 + console.log("📄 상품 상세 페이지들 생성 중..."); + + // 모든 상품 데이터 직접 로드 + const itemsPath = path.join(process.cwd(), "src/mocks/items.json"); + const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); + const products = items; // 모든 상품 사용 + + if (products && products.length > 0) { + // 상품별 디렉토리 생성 및 HTML 파일 생성 + for (const product of products) { + const productId = product.productId; + const productUrl = `/product/${productId}/`; + + console.log(`📦 상품 ${productId} 페이지 생성 중...`); + + const productResult = await render(productUrl, {}); + + if (productResult.initialData.product) { + const productHtml = replacePlaceholders( + template, + productResult.html, + productResult.head, + productResult.initialData, + ); + + // 상품별 디렉토리 생성 + const productDir = `../../dist/vanilla/product/${productId}`; + const productFilePath = `${productDir}/index.html`; + + // 디렉토리 생성 + ensureDirectoryExists(productFilePath); - // 어플리케이션 렌더링하기 - const appHtml = render(); + // index.html 파일 생성 + fs.writeFileSync(productFilePath, productHtml); + console.log(`✅ 상품 ${productId} 페이지 생성 완료`); + } + } + } - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + console.log("🎉 SSG 완료!"); + console.log(`📊 생성된 페이지:`); + console.log(` - 홈페이지: ../../dist/vanilla/index.html`); + console.log(` - 404 페이지: ../../dist/vanilla/404.html`); + console.log(` - 상품 상세 페이지: ../../dist/vanilla/product/*/index.html`); + } catch (error) { + console.error("❌ SSG 오류:", error); + throw error; + } } // 실행