diff --git a/packages/vanilla/package.json b/packages/vanilla/package.json index 0916430b..1fc33123 100644 --- a/packages/vanilla/package.json +++ b/packages/vanilla/package.json @@ -47,6 +47,7 @@ "lint-staged": "^15.2.11", "msw": "^2.10.2", "prettier": "^3.4.2", + "sirv": "^3.0.1", "vite": "npm:rolldown-vite@latest", "vitest": "latest" }, @@ -57,7 +58,6 @@ }, "dependencies": { "compression": "^1.8.1", - "express": "^5.1.0", - "sirv": "^3.0.1" + "express": "^5.1.0" } } diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..8df4364d 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,31 +1,71 @@ import express from "express"; +import compression from "compression"; +import sirv from "sirv"; +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +const __dirname = dirname(fileURLToPath(import.meta.url)); const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); +// HTML 템플릿 로드 및 분할 +const template = readFileSync(join(__dirname, "index.html"), "utf-8"); + const app = express(); -const render = () => { - return `
안녕하세요
`; -}; - -app.get("*all", (req, res) => { - res.send( - ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- - - `.trim(), - ); +let vite; +// 환경 분기 +if (!prod) { + // 개발 환경: Vite dev server + const { createServer: createViteServer } = await import("vite"); + vite = await createViteServer({ + server: { middlewareMode: true }, + appType: "custom", + }); + + // Vite의 미들웨어 사용 + app.use(vite.middlewares); +} else { + app.use(compression()); + // 프로덕션 환경: sirv로 정적 파일 서빙 + app.use(base, sirv("dist/vanilla", { extensions: [] })); +} + +// 렌더링 파이프라인 +app.use("*all", async (req, res) => { + try { + const url = req.originalUrl; + + let htmlTemplate = template; + let render; + + // 개발 환경에서 Vite transform 적용 + if (!prod) { + htmlTemplate = await vite.transformIndexHtml(url, template); + render = (await vite.ssrLoadModule("/src/main-server.js")).render; + } else { + render = (await import("./dist/vanilla-ssr/main-server.js")).render; + } + + const rendered = await render(url); + + const initialDataScript = rendered.initialData + ? `` + : ""; + + const html = htmlTemplate + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace(``, `${initialDataScript}`); + + // Template 치환 + res.status(200).set({ "Content-Type": "text/html" }).send(html); + } catch (e) { + console.error(e.stack); + res.status(500).end(e.stack); + } }); // Start http server diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js index 2238a878..cfe43fff 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/Router.js @@ -3,7 +3,7 @@ */ import { createObserver } from "./createObserver.js"; -export class Router { +export class GlobalRouter { #routes; #route; #observer = createObserver(); @@ -14,10 +14,13 @@ export class Router { this.#route = null; this.#baseUrl = baseUrl.replace(/\/$/, ""); - window.addEventListener("popstate", () => { - this.#route = this.#findRoute(); - this.#observer.notify(); - }); + // 브라우저 환경에서만 popstate 이벤트 리스너 등록 + if (typeof window !== "undefined") { + window.addEventListener("popstate", () => { + this.#route = this.#findRoute(); + this.#observer.notify(); + }); + } } get baseUrl() { @@ -25,11 +28,12 @@ export class Router { } get query() { - return Router.parseQuery(window.location.search); + if (typeof window === "undefined") return {}; + return GlobalRouter.parseQuery(window.location.search); } set query(newQuery) { - const newUrl = Router.getUrl(newQuery, this.#baseUrl); + const newUrl = GlobalRouter.getUrl(newQuery, this.#baseUrl); this.push(newUrl); } @@ -73,8 +77,9 @@ export class Router { }); } - #findRoute(url = window.location.pathname) { - const { pathname } = new URL(url, window.location.origin); + #findRoute(url = typeof window !== "undefined" ? window.location.pathname : "/") { + const { pathname } = + typeof window !== "undefined" ? new URL(url, window.location.origin) : new URL(url, "http://localhost"); for (const [routePath, route] of this.#routes) { const match = pathname.match(route.regex); if (match) { @@ -103,11 +108,14 @@ export class Router { // baseUrl이 없으면 자동으로 붙여줌 let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); - const prevFullUrl = `${window.location.pathname}${window.location.search}`; + // 브라우저 환경에서만 히스토리 업데이트 + if (typeof window !== "undefined") { + const prevFullUrl = `${window.location.pathname}${window.location.search}`; - // 히스토리 업데이트 - if (prevFullUrl !== fullUrl) { - window.history.pushState(null, "", fullUrl); + // 히스토리 업데이트 + if (prevFullUrl !== fullUrl) { + window.history.pushState(null, "", fullUrl); + } } this.#route = this.#findRoute(fullUrl); @@ -130,7 +138,7 @@ export class Router { * @param {string} search - location.search 또는 쿼리 문자열 * @returns {Object} 파싱된 쿼리 객체 */ - static parseQuery = (search = window.location.search) => { + static parseQuery = (search = typeof window !== "undefined" ? window.location.search : "") => { const params = new URLSearchParams(search); const query = {}; for (const [key, value] of params) { @@ -155,7 +163,7 @@ export class Router { }; static getUrl = (newQuery, baseUrl = "") => { - const currentQuery = Router.parseQuery(); + const currentQuery = GlobalRouter.parseQuery(); const updatedQuery = { ...currentQuery, ...newQuery }; // 빈 값들 제거 @@ -165,7 +173,8 @@ export class Router { } }); - const queryString = Router.stringifyQuery(updatedQuery); - return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; + const queryString = GlobalRouter.stringifyQuery(updatedQuery); + const pathname = typeof window !== "undefined" ? window.location.pathname.replace(baseUrl, "") : "/"; + return `${baseUrl}${pathname}${queryString ? `?${queryString}` : ""}`; }; } diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js new file mode 100644 index 00000000..93faa528 --- /dev/null +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -0,0 +1,125 @@ +/** + * 서버사이드 렌더링용 라우터 + * 브라우저 API에 의존하지 않고 URL 문자열만으로 동작 + */ +export class ServerRouter { + #routes; + #url; + #route; + #baseUrl; + + constructor(urlString, baseUrl = "") { + this.#routes = new Map(); + this.#url = new URL(urlString, "http://localhost"); + this.#baseUrl = baseUrl.replace(/\/$/, ""); + this.#route = null; + } + + get baseUrl() { + return this.#baseUrl; + } + + get query() { + return ServerRouter.parseQuery(this.#url.search); + } + + get params() { + return this.#route?.params ?? {}; + } + + get route() { + return this.#route; + } + + get target() { + return this.#route?.handler; + } + + get pathname() { + return this.#url.pathname; + } + + /** + * 라우트 등록 (클라이언트 Router와 동일한 로직) + * @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, + }); + } + + /** + * 현재 URL에 맞는 라우트 찾기 + */ + findRoute(pathname = this.#url.pathname) { + 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; + } + + /** + * 라우터 시작 - 현재 URL에 맞는 라우트 찾기 + */ + start() { + this.#route = this.findRoute(); + return this.#route; + } + + /** + * 쿼리 파라미터를 객체로 파싱 + * @param {string} search - 쿼리 문자열 + * @returns {Object} 파싱된 쿼리 객체 + */ + static parseQuery(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(); + } +} diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..8c3f8386 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -1,11 +1,12 @@ /** * 로컬스토리지 추상화 함수 * @param {string} key - 스토리지 키 - * @param {Storage} storage - 기본값은 localStorage + * @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 = () => { + if (!storage) return null; // 서버 환경에서는 null 반환 try { const item = storage.getItem(key); return item ? JSON.parse(item) : null; @@ -16,6 +17,7 @@ export const createStorage = (key, storage = window.localStorage) => { }; const set = (value) => { + if (!storage) return; // 서버 환경에서는 아무것도 하지 않음 try { storage.setItem(key, JSON.stringify(value)); } catch (error) { @@ -24,6 +26,7 @@ export const createStorage = (key, storage = window.localStorage) => { }; const reset = () => { + if (!storage) return; // 서버 환경에서는 아무것도 하지 않음 try { storage.removeItem(key); } catch (error) { diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..f827a928 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,228 @@ -export const render = async (url, query) => { - console.log({ url, query }); - return ""; +import { ServerRouter } from "./lib/ServerRouter.js"; +import { mockGetProducts, mockGetProduct, mockGetCategories } from "./mocks/server.js"; +import { productStore } from "./stores/productStore.js"; +import { PRODUCT_ACTIONS } from "./stores/actionTypes.js"; + +// 스토어와 통합된 데이터 프리페칭 함수 +async function prefetchData(route, query, params) { + try { + if (route.path === "/") { + // 홈페이지: 상품 목록과 카테고리 데이터 미리 로드 + const productsData = mockGetProducts(query); + const categories = mockGetCategories(); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: productsData.products, + categories, + totalCount: productsData.pagination.total, + loading: false, + status: "done", + error: null, + }, + }); + + return { + products: productsData.products, + categories, + totalCount: productsData.pagination.total, + }; + } else if (route.path === "/product/:id/") { + // 상품 상세 페이지: 해당 상품 데이터 미리 로드 + const productId = params.id; + const product = mockGetProduct(productId); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, + payload: product, + }); + + // 관련 상품도 로드 + let relatedProducts = []; + if (product.category2) { + try { + const relatedData = mockGetProducts({ + category2: product.category2, + limit: 20, + page: 1, + }); + relatedProducts = relatedData.products.filter((p) => p.productId !== productId); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, + payload: relatedProducts, + }); + } catch (error) { + console.error("관련 상품 로드 실패:", error); + } + } + + return { + currentProduct: product, + relatedProducts, + }; + } + return null; + } catch (error) { + console.error("데이터 프리페칭 오류:", error); + return null; + } +} + +// 라우트 핸들러들 +const routeHandlers = { + // 홈페이지 + "/": async (url, params, query) => { + try { + // 라우트 정보 객체 생성 + const route = { path: "/" }; + + // 데이터 프리페칭 및 스토어 업데이트 + const prefetchedData = await prefetchData(route, query, params); + + if (!prefetchedData) { + throw new Error("데이터 프리페칭 실패"); + } + + // 스토어에서 현재 상태 가져오기 + const productState = productStore.getState(); + + const initialData = { + products: productState.products || [], + totalCount: productState.totalCount || 0, + categories: productState.categories || {}, + currentProduct: null, + relatedProducts: [], + loading: false, + error: null, + status: "done", + }; + + // 서버사이드 HTML 렌더링 + const { HomePage } = await import("./pages/HomePage.js"); + const html = HomePage(initialData); + + return { + head: `쇼핑몰 - 홈`, + html, + initialData, + }; + } catch (error) { + console.error("홈페이지 렌더링 오류:", error); + return { + head: `오류 - 쇼핑몰`, + html: `
서버 렌더링 중 오류가 발생했습니다: ${error.message}
`, + initialData: { error: error.message }, + }; + } + }, + + // 상품 상세 페이지 + "/product/:id/": async (url, params, query) => { + try { + // 라우트 정보 객체 생성 + const route = { path: "/product/:id/" }; + + // 데이터 프리페칭 및 스토어 업데이트 + const prefetchedData = await prefetchData(route, query, params); + + if (!prefetchedData) { + throw new Error("상품 데이터를 찾을 수 없습니다"); + } + + // 스토어에서 현재 상태 가져오기 + const productState = productStore.getState(); + + const initialData = { + products: [], + totalCount: 0, + categories: productState.categories || {}, + currentProduct: productState.currentProduct, + relatedProducts: productState.relatedProducts || [], + loading: false, + error: null, + status: "done", + }; + + // 서버사이드 HTML 렌더링 + const { ProductDetailPage } = await import("./pages/ProductDetailPage.js"); + const html = ProductDetailPage(initialData); + + return { + head: `${productState.currentProduct?.title || "상품"} - 쇼핑몰`, + html, + initialData, + }; + } catch (error) { + console.error("상품 상세 렌더링 오류:", error); + return { + head: `상품을 찾을 수 없습니다 - 쇼핑몰`, + html: `
상품을 찾을 수 없습니다: ${error.message}
`, + initialData: { error: error.message }, + }; + } + }, + + // 404 페이지 + ".*": async () => { + try { + // 서버사이드 HTML 렌더링 + const { NotFoundPage } = await import("./pages/NotFoundPage.js"); + const html = NotFoundPage(); + + return { + head: `404 - 페이지를 찾을 수 없습니다`, + html, + initialData: { error: "Page not found" }, + }; + } catch (error) { + console.error("404 페이지 렌더링 오류:", error); + return { + head: `404 - 페이지를 찾을 수 없습니다`, + html: ` +
+
+

404 - 페이지를 찾을 수 없습니다

+ 홈으로 돌아가기 +
+
+ `, + initialData: { error: "Page not found" }, + }; + } + }, +}; + +export const render = async (url) => { + try { + const serverRouter = new ServerRouter(url); + + // 라우트 등록 + Object.entries(routeHandlers).forEach(([path, handler]) => { + serverRouter.addRoute(path, handler); + }); + + // 라우팅 시작 + const route = serverRouter.start(); + + if (!route) { + return routeHandlers[".*"](); + } + + // URL 쿼리 파라미터 가져오기 + const mergedQuery = { ...serverRouter.query }; + + // 라우트 핸들러 실행 + const result = await route.handler(url, serverRouter.params, mergedQuery); + + return result; + } catch (error) { + console.error("SSR 렌더링 오류:", error); + return { + head: `서버 오류 - 쇼핑몰`, + html: `
서버 렌더링 중 오류가 발생했습니다: ${error.message}
`, + initialData: { error: error.message }, + }; + } }; diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..279bd3fe 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -4,6 +4,8 @@ import { registerAllEvents } from "./events"; import { loadCartFromStorage } from "./services"; import { router } from "./router"; import { BASE_URL } from "./constants.js"; +import { productStore } from "./stores/productStore.js"; +import { PRODUCT_ACTIONS } from "./stores/actionTypes.js"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -15,7 +17,47 @@ const enableMocking = () => }), ); +function hydrateWithServerData() { + // 서버에서 전달받은 초기 데이터가 있는지 확인 + if (typeof window !== "undefined" && window.__INITIAL_DATA__) { + const initialData = window.__INITIAL_DATA__; + + if (initialData.products && initialData.categories) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: initialData.products, + categories: initialData.categories, + totalCount: initialData.totalCount, + loading: false, + status: "done", + error: null, + }, + }); + } else if (initialData.currentProduct) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, + payload: initialData.currentProduct, + }); + + if (initialData.relatedProducts) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, + payload: initialData.relatedProducts, + }); + } + } + + // 초기 데이터 사용 후 제거 + delete window.__INITIAL_DATA__; + } else { + console.log("❌ 서버 데이터 없음"); + } +} + function main() { + // 서버 데이터로 스토어 하이드레이션 + hydrateWithServerData(); registerAllEvents(); registerGlobalEvents(); loadCartFromStorage(); diff --git a/packages/vanilla/src/mocks/server.js b/packages/vanilla/src/mocks/server.js new file mode 100644 index 00000000..0ed5876e --- /dev/null +++ b/packages/vanilla/src/mocks/server.js @@ -0,0 +1,119 @@ +import items from "./items.json" with { type: "json" }; + +// 카테고리 추출 함수 +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 mockGetProducts(params = {}) { + const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const page = params.current ?? params.page ?? 1; + + // 필터링된 상품들 + 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, + }, + }; +} + +export function mockGetProduct(productId) { + const product = items.find((item) => item.productId === productId); + + if (!product) { + throw new Error("Product not found"); + } + + // 상세 정보에 추가 데이터 포함 + 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")], + }; +} + +export function mockGetCategories() { + return getUniqueCategories(); +} diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index d897ee76..a0e8c9fb 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,5 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; +import { GlobalRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -export const router = new Router(BASE_URL); +export const router = new GlobalRouter(BASE_URL); diff --git a/packages/vanilla/src/router/withLifecycle.js b/packages/vanilla/src/router/withLifecycle.js index ccb21113..cceb5751 100644 --- a/packages/vanilla/src/router/withLifecycle.js +++ b/packages/vanilla/src/router/withLifecycle.js @@ -49,6 +49,11 @@ const unmount = (pageFunction) => { }; export const withLifecycle = ({ onMount, onUnmount, watches } = {}, page) => { + // 서버 환경에서는 라이프사이클을 무시하고 페이지 함수만 반환 + if (typeof window === "undefined") { + return page; + } + const lifecycle = getPageLifecycle(page); if (typeof onMount === "function") { lifecycle.mount = onMount; diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..de564e8f 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,20 +1,105 @@ -import fs from "fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -const render = () => { - return `
안녕하세요
`; -}; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// NODE_ENV을 development로 설정 (BASE_URL을 빈 문자열로 사용하기 위해) +process.env.NODE_ENV = "development"; + +// Constants +const DIST_DIR = path.resolve(__dirname, "../../dist/vanilla"); +const SSR_DIR = path.resolve(__dirname, "./dist/vanilla-ssr"); async function generateStaticSite() { - // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + console.log("🚀 Static Site Generation 시작..."); + + try { + // 1. 템플릿 HTML 로드 + const templatePath = path.join(DIST_DIR, "index.html"); + const template = await fs.readFile(templatePath, "utf-8"); + + // 2. SSR 렌더 함수 로드 + const ssrModulePath = path.join(SSR_DIR, "main-server.js"); + + const ssrModule = await import(`file://${ssrModulePath}`); + const { render } = ssrModule; + + if (!render) { + throw new Error("render 함수를 찾을 수 없습니다"); + } + + // 3. 생성할 페이지 목록 정의 + const pagesToGenerate = await getPages(); + + // 4. 각 페이지별로 HTML 생성 + for (const page of pagesToGenerate) { + try { + const rendered = await render(page.url); + + // 서버 데이터를 클라이언트로 전달하기 위한 스크립트 생성 + const initialDataScript = rendered.initialData + ? `` + : ""; + + const html = template + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? "") + .replace(``, `${initialDataScript}`); + + // HTML 파일 저장 + await saveHtmlFile(page.filePath, html); + } catch (error) { + console.error(`❌ ${page.url} 생성 실패:`, error.message); + } + } + } catch (error) { + console.error("💥 SSG 실패:", error); + process.exit(1); + } +} + +async function getPages() { + const pages = []; + + // 홈페이지 + pages.push({ + url: "/", + filePath: path.join(DIST_DIR, "index.html"), + }); + + // 404 페이지 + pages.push({ + url: "/404", + filePath: path.join(DIST_DIR, "404.html"), + }); + + // 상품 상세 페이지들 + try { + const { mockGetProducts } = await import("./src/mocks/server.js"); + const productsData = mockGetProducts({ limit: 20 }); // 20개의 상품 가져오기 + + for (const product of productsData.products) { + pages.push({ + url: `/product/${product.productId}/`, + filePath: path.join(DIST_DIR, "product", product.productId, "index.html"), + }); + } + } catch (error) { + console.error("상품 목록 로드 실패:", error); + } + + return pages; +} - // 어플리케이션 렌더링하기 - const appHtml = render(); +async function saveHtmlFile(filePath, html) { + // 디렉토리 생성 + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + // HTML 파일 저장 + await fs.writeFile(filePath, html, "utf-8"); } // 실행 -generateStaticSite(); +generateStaticSite().catch(console.error); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 766adcbc..4c66ae47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,7 +101,7 @@ importers: version: 6.8.0 '@testing-library/react': specifier: latest - version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@testing-library/user-event': specifier: latest version: 14.6.1(@testing-library/dom@10.4.1) @@ -110,10 +110,10 @@ importers: version: 24.0.13 '@types/react': specifier: latest - version: 19.1.11 + version: 19.1.12 '@types/react-dom': specifier: latest - version: 19.1.7(@types/react@19.1.11) + version: 19.1.9(@types/react@19.1.12) '@types/use-sync-external-store': specifier: latest version: 1.5.0 @@ -177,16 +177,16 @@ importers: version: 24.0.13 '@types/react': specifier: latest - version: 19.1.11 + version: 19.1.12 '@types/react-dom': specifier: latest - version: 19.1.7(@types/react@19.1.11) + version: 19.1.9(@types/react@19.1.12) '@types/use-sync-external-store': specifier: latest version: 1.5.0 '@vitejs/plugin-react': specifier: latest - version: 5.0.1(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)) + version: 5.0.2(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)) compression: specifier: ^1.7.5 version: 1.8.1 @@ -232,9 +232,6 @@ importers: express: specifier: ^5.1.0 version: 5.1.0 - sirv: - specifier: ^3.0.1 - version: 3.0.1 devDependencies: '@eslint/js': specifier: ^9.16.0 @@ -287,6 +284,9 @@ importers: prettier: specifier: ^3.4.2 version: 3.6.2 + sirv: + specifier: ^3.0.1 + version: 3.0.1 vite: specifier: npm:rolldown-vite@latest version: rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0) @@ -1103,13 +1103,13 @@ packages: '@types/node@24.0.13': resolution: {integrity: sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==} - '@types/react-dom@19.1.7': - resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} + '@types/react-dom@19.1.9': + resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} peerDependencies: '@types/react': ^19.0.0 - '@types/react@19.1.11': - resolution: {integrity: sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==} + '@types/react@19.1.12': + resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -1191,8 +1191,8 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 - '@vitejs/plugin-react@5.0.1': - resolution: {integrity: sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==} + '@vitejs/plugin-react@5.0.2': + resolution: {integrity: sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -3701,15 +3701,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@babel/runtime': 7.27.6 '@testing-library/dom': 10.4.1 react: 19.1.1 react-dom: 19.1.1(react@19.1.1) optionalDependencies: - '@types/react': 19.1.11 - '@types/react-dom': 19.1.7(@types/react@19.1.11) + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -3759,11 +3759,11 @@ snapshots: dependencies: undici-types: 7.8.0 - '@types/react-dom@19.1.7(@types/react@19.1.11)': + '@types/react-dom@19.1.9(@types/react@19.1.12)': dependencies: - '@types/react': 19.1.11 + '@types/react': 19.1.12 - '@types/react@19.1.11': + '@types/react@19.1.12': dependencies: csstype: 3.1.3 @@ -3878,12 +3878,12 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@5.0.1(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0))': + '@vitejs/plugin-react@5.0.2(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0))': dependencies: '@babel/core': 7.28.3 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.3) - '@rolldown/pluginutils': 1.0.0-beta.32 + '@rolldown/pluginutils': 1.0.0-beta.34 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 vite: rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0) @@ -3905,7 +3905,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.0.13)(@vitest/ui@3.2.4)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.10.3(@types/node@24.0.13)(typescript@5.8.3))(yaml@2.8.0) + vitest: 3.2.4(@types/node@24.0.13)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.3(@types/node@24.0.13)(typescript@5.8.3))(yaml@2.8.0) transitivePeerDependencies: - supports-color