diff --git a/packages/vanilla/deploy.js b/packages/vanilla/deploy.js new file mode 100644 index 00000000..289d2f15 --- /dev/null +++ b/packages/vanilla/deploy.js @@ -0,0 +1,95 @@ +import fs from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * 파일 시스템 기반 배포 스크립트 + * 빌드된 정적 파일들을 배포 디렉토리로 복사 + */ +async function deploy() { + console.log("🚀 파일 시스템 기반 배포 시작..."); + + const sourceDir = join(__dirname, "../../dist/vanilla"); + const deployDir = process.env.DEPLOY_DIR || join(__dirname, "../../dist/deploy/vanilla"); + + // 소스 디렉토리 확인 + if (!fs.existsSync(sourceDir)) { + console.error(`❌ 소스 디렉토리를 찾을 수 없습니다: ${sourceDir}`); + console.error(" 먼저 'pnpm run build:ssg'를 실행해주세요."); + process.exit(1); + } + + // 배포 디렉토리 생성 + if (fs.existsSync(deployDir)) { + console.log(`🗑️ 기존 배포 디렉토리 삭제: ${deployDir}`); + fs.rmSync(deployDir, { recursive: true, force: true }); + } + fs.mkdirSync(deployDir, { recursive: true }); + + console.log(`📦 소스: ${sourceDir}`); + console.log(`📤 배포: ${deployDir}`); + + // 디렉토리 복사 함수 + function copyDirectory(src, dest) { + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = join(src, entry.name); + const destPath = join(dest, entry.name); + + if (entry.isDirectory()) { + fs.mkdirSync(destPath, { recursive: true }); + copyDirectory(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } + } + + // 파일 복사 + console.log("📋 파일 복사 중..."); + copyDirectory(sourceDir, deployDir); + + // 배포 정보 파일 생성 + const deployInfo = { + timestamp: new Date().toISOString(), + source: sourceDir, + destination: deployDir, + files: countFiles(deployDir), + }; + + fs.writeFileSync(join(deployDir, ".deploy-info.json"), JSON.stringify(deployInfo, null, 2)); + + console.log(`✅ 배포 완료!`); + console.log(` 배포 디렉토리: ${deployDir}`); + console.log(` 생성된 파일 수: ${deployInfo.files}`); + console.log(` 배포 정보: .deploy-info.json`); +} + +/** + * 디렉토리 내 파일 개수 계산 + */ +function countFiles(dir) { + let count = 0; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + count += countFiles(fullPath); + } else { + count++; + } + } + + return count; +} + +// 실행 +deploy().catch((error) => { + console.error("배포 중 오류 발생:", error); + process.exit(1); +}); diff --git a/packages/vanilla/package.json b/packages/vanilla/package.json index ab5ae3fd..1da4426a 100644 --- a/packages/vanilla/package.json +++ b/packages/vanilla/package.json @@ -5,11 +5,11 @@ "type": "module", "scripts": { "dev": "vite --port 5173", - "dev:ssr": "PORT=5174 node server.js", + "dev:ssr": "PORT=5173 node 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", - "build:ssg": "pnpm run build:client-for-ssg && node static-site-generate.js", + "build:ssg": "pnpm run build:client-for-ssg && pnpm run build:server && node static-site-generate.js", "build:without-ssg": "pnpm run build:client && pnpm run build:server", "build": "pnpm run build:client && pnpm run build:server && pnpm run build:ssg", "lint:fix": "eslint --fix ./src", @@ -20,6 +20,8 @@ "preview:ssr-with-build": "pnpm run build:without-ssg && pnpm run preview:ssr", "preview:ssg": "vite preview --outDir ../../dist/vanilla --port 4178", "preview:ssg-with-build": "pnpm run build && pnpm run preview:ssg", + "deploy": "node deploy.js", + "deploy:build": "pnpm run build:ssg && pnpm run deploy", "serve:test:dev": "concurrently -n \"DevCSR,DevSSR,ProdCSR,ProdSSR,SSG\" -c \"#FF6B6B,#006D77,#FFD166,#6A5ACD,#00C2A8\" \"pnpm run dev\" \"pnpm run dev:ssr\" \"pnpm run preview:csr\" \"pnpm run preview:ssr\" \"pnpm run preview:ssg\"", "serve:test": "pnpm run build:without-ssg && pnpm run build:ssg && pnpm run serve:test:dev", "prepare": "husky" diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index 67f03afa..21e484a8 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,4 +1,13 @@ import express from "express"; +import compression from "compression"; +import sirv from "sirv"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import { readdirSync, readFileSync } from "fs"; +import { render } from "./dist/vanilla-ssr/main-server.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; @@ -6,29 +15,206 @@ const base = process.env.BASE || (prod ? "/front_7th_chapter4-1/vanilla/" : "/") const app = express(); -const render = () => { - return `
안녕하세요
`; -}; +/** + * 프로덕션 환경에서 빌드된 파일명 찾기 + */ +function findAssetFiles() { + if (!prod) { + return { js: "index.js", css: "index.css" }; + } + + try { + const assetsDir = join(__dirname, "dist/vanilla/assets"); + const files = readdirSync(assetsDir); + + const jsFile = files.find((file) => file.startsWith("index-") && file.endsWith(".js")); + const cssFile = files.find((file) => file.startsWith("index-") && file.endsWith(".css")); + + return { + js: jsFile ? `assets/${jsFile}` : "assets/index.js", + css: cssFile ? `assets/${cssFile}` : "assets/index.css", + }; + } catch (error) { + console.warn("Failed to find asset files, using default names:", error.message); + return { js: "assets/index.js", css: "assets/index.css" }; + } +} + +const assetFiles = findAssetFiles(); + +/** + * HTML 템플릿 생성 함수 + */ +function createHtmlTemplate(html, headContent = "", baseUrl, isProd, assets, initialData = null) { + const cssPath = `${baseUrl}${assets.css}`; + const jsPath = `${baseUrl}${assets.js}`; + + // 개발 환경에서는 index.html을 읽어서 사용 + // 프로덕션 환경에서는 빌드된 index.html을 사용 + let template; + try { + const templatePath = prod ? join(__dirname, "dist/vanilla/index.html") : join(__dirname, "index.html"); + template = readFileSync(templatePath, "utf-8"); + } catch { + // 템플릿 파일이 없으면 기본 템플릿 사용 + template = ` + + + + + + + + + + + +
+ + +`.trim(); + } + + // 플레이스홀더 치환 + let result = template.replace("", html); + + // app-head 치환 (headContent가 있으면 추가, 없으면 제거) + if (headContent) { + result = result.replace("", headContent); + } else { + result = result.replace("", ""); + } + + // 프로덕션 환경에서 에셋 경로 업데이트 + if (prod) { + result = result.replace(/href="\/src\/styles\.css"/g, `href="${cssPath}"`); + result = result.replace(/src="\/src\/main\.js"/g, `src="${jsPath}"`); + } + + // 초기 데이터 스크립트 주입 (Hydration을 위해) + if (initialData) { + const initialDataScript = ``; + // 태그 앞에 스크립트 삽입 + result = result.replace("", `${initialDataScript}\n `); + } -app.get("*all", (req, res) => { - res.send( - ` + return result; +} + +/** + * SSR 렌더링 미들웨어 + */ +async function ssrMiddleware(req, res, next) { + try { + const url = req.originalUrl.replace(base, "/") || "/"; + const query = req.query; + + // SSR 렌더링 (html과 initialData 반환) + const { html, initialData } = await render(url, query); + + // HTML 템플릿 생성 및 응답 (initialData 포함) + const template = createHtmlTemplate(html, "", base, prod, assetFiles, initialData); + res.setHeader("Content-Type", "text/html"); + res.send(template); + } catch (error) { + if (prod) { + console.error("SSR Error:", error.message); + } else { + console.error("SSR Error:", error); + } + next(error); + } +} + +/** + * 에러 핸들링 미들웨어 + */ +// eslint-disable-next-line no-unused-vars +function errorMiddleware(err, req, res, next) { + if (prod) { + console.error("Server Error:", err.message); + } else { + console.error("Server Error:", err); + } + + const errorMessage = prod ? "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요." : err.message; + const errorDetails = prod + ? "" + : `
${err.stack}
`; + + res.status(500).send(` - - - - - Vanilla Javascript SSR - - -
${render()}
- + + + + + Server Error + + + +
+
+
+ + + +
+

서버 오류가 발생했습니다

+

${errorMessage}

+ ${errorDetails} + + 홈으로 돌아가기 + +
+
+ - `.trim(), - ); -}); + `); +} + +// 압축 미들웨어 +app.use(compression()); + +// 정적 파일 서빙 (빌드된 클라이언트 파일들) +if (prod) { + app.use(base, sirv(join(__dirname, "dist/vanilla"), { gzip: true, maxAge: 31536000 })); +} else { + // 개발 환경에서는 Vite가 정적 파일을 서빙하므로 여기서는 SSR만 처리 + // 개발 환경에서는 소스맵 등 디버깅 정보 제공 + app.use((req, res, next) => { + if (req.path.startsWith("/assets/")) { + console.log(`[DEV] Asset request: ${req.path}`); + } + next(); + }); +} + +// SSR 렌더링 미들웨어 +app.use("*", ssrMiddleware); + +// 에러 핸들링 미들웨어 +app.use(errorMiddleware); -// Start http server +// 서버 시작 app.listen(port, () => { - console.log(`React Server started at http://localhost:${port}`); + console.log("=".repeat(50)); + console.log(`🚀 Vanilla SSR Server started`); + console.log(`📍 URL: http://localhost:${port}`); + console.log(`📂 Base path: ${base}`); + console.log(`🌍 Environment: ${prod ? "production" : "development"}`); + if (prod) { + console.log(`📦 Assets: ${assetFiles.js}, ${assetFiles.css}`); + } + console.log("=".repeat(50)); }); diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js index 2238a878..15184c5d 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/Router.js @@ -8,16 +8,21 @@ export class Router { #route; #observer = createObserver(); #baseUrl; + #serverQuery = {}; // 서버 환경에서 사용할 query + #serverParams = {}; // 서버 환경에서 사용할 params constructor(baseUrl = "") { this.#routes = new Map(); this.#route = null; this.#baseUrl = baseUrl.replace(/\/$/, ""); - window.addEventListener("popstate", () => { - this.#route = this.#findRoute(); - this.#observer.notify(); - }); + // SSR 환경에서는 window가 없으므로 이벤트 리스너를 등록하지 않음 + if (typeof window !== "undefined") { + window.addEventListener("popstate", () => { + this.#route = this.#findRoute(); + this.#observer.notify(); + }); + } } get baseUrl() { @@ -25,6 +30,10 @@ export class Router { } get query() { + // SSR 환경에서는 #serverQuery 반환 + if (typeof window === "undefined") { + return this.#serverQuery; + } return Router.parseQuery(window.location.search); } @@ -34,6 +43,10 @@ export class Router { } get params() { + // SSR 환경에서는 #serverParams 반환 + if (typeof window === "undefined") { + return this.#serverParams; + } return this.#route?.params ?? {}; } @@ -73,8 +86,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 origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + const { pathname } = new URL(url, origin); for (const [routePath, route] of this.#routes) { const match = pathname.match(route.regex); if (match) { @@ -100,6 +114,11 @@ export class Router { */ push(url) { try { + // SSR 환경에서는 push 메서드를 실행하지 않음 + if (typeof window === "undefined") { + return; + } + // baseUrl이 없으면 자동으로 붙여줌 let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); @@ -125,12 +144,48 @@ export class Router { this.#observer.notify(); } + /** + * 서버 환경에서 라우트 설정 + * @param {string} url - 요청 URL + * @param {Object} query - 쿼리 파라미터 객체 + * @param {Object} params - 라우트 파라미터 객체 + */ + setServerRoute(url, query = {}, params = {}) { + // 서버 환경에서만 사용 + if (typeof window === "undefined") { + this.#serverQuery = query; + this.#serverParams = params; + + // 라우트 찾기 + const pathname = url.split("?")[0]; + const cleanPath = pathname.replace(this.#baseUrl, "").replace(/\/$/, "") || "/"; + + for (const [routePath, route] of this.#routes) { + const regexPath = routePath.replace(/:\w+/g, "([^/]+)").replace(/\//g, "\\/"); + const regex = new RegExp(`^${regexPath}$`); + const match = cleanPath.match(regex); + + if (match) { + this.#route = { + ...route, + params: { ...params }, + path: routePath, + }; + return; + } + } + + // 매칭되는 라우트가 없으면 null + this.#route = null; + } + } + /** * 쿼리 파라미터를 객체로 파싱 * @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,6 +210,11 @@ export class Router { }; static getUrl = (newQuery, baseUrl = "") => { + // SSR 환경에서는 window가 없으므로 빈 문자열 반환 + if (typeof window === "undefined") { + return ""; + } + const currentQuery = Router.parseQuery(); const updatedQuery = { ...currentQuery, ...newQuery }; diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..3ec2a490 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,239 @@ -export const render = async (url, query) => { - console.log({ url, query }); - return ""; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { BASE_URL } from "./constants.js"; +import { router } from "./router"; +import { getCategories, getProduct, getProducts } from "./api/productApi.js"; +import { + productStore, + PRODUCT_ACTIONS, + initialProductState, + cartStore, + CART_ACTIONS, + uiStore, + UI_ACTIONS, +} from "./stores/index.js"; + +// 서버 환경에서 라우트 등록 +router.addRoute("/", HomePage); +router.addRoute("/product/:id", ProductDetailPage); + +/** + * 서버 환경에서 모든 스토어 초기화 + * 각 요청마다 독립적인 상태를 보장하기 위해 호출 + */ +function initializeStores() { + // Product Store 초기화 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + ...initialProductState, + loading: true, + status: "pending", + }, + }); + + // Cart Store 초기화 (서버에서는 빈 장바구니) + cartStore.dispatch({ + type: CART_ACTIONS.CLEAR_CART, + }); + + // UI Store 초기화 + uiStore.dispatch({ + type: UI_ACTIONS.CLOSE_CART_MODAL, + }); + uiStore.dispatch({ + type: UI_ACTIONS.HIDE_TOAST, + }); +} + +// 서버 사이드 라우트 매칭 함수 +function findRoute(url, baseUrl = "") { + const pathname = url.split("?")[0]; // 쿼리 제거 + const cleanPath = pathname.replace(baseUrl, "").replace(/\/$/, "") || "/"; + + // 라우트 패턴 매칭 + const routes = [ + { pattern: "/", handler: HomePage }, + { pattern: "/product/:id", handler: ProductDetailPage }, + ]; + + for (const route of routes) { + const paramNames = []; + const regexPath = route.pattern + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${regexPath}$`); + const match = cleanPath.match(regex); + + if (match) { + const params = {}; + paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + handler: route.handler, + params, + }; + } + } + + // 매칭되는 라우트가 없으면 NotFoundPage + return { + handler: NotFoundPage, + params: {}, + }; +} + +/** + * 서버 환경에서 상품 목록 데이터 프리패칭 + */ +async function prefetchProducts(query = {}) { + try { + // 상품 목록과 카테고리 동시에 가져오기 + const [productsResponse, categories] = await Promise.all([getProducts(query), getCategories()]); + + // 스토어에 데이터 설정 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: productsResponse.products, + categories, + totalCount: productsResponse.pagination.total, + loading: false, + status: "done", + error: null, + }, + }); + } catch (error) { + console.error("상품 목록 프리패칭 실패:", error); + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_ERROR, + payload: error.message, + }); + } +} + +/** + * 서버 환경에서 상품 상세 데이터 프리패칭 + */ +async function prefetchProductDetail(productId) { + try { + // 상품 상세 정보 가져오기 + const product = await getProduct(productId); + + // 스토어에 현재 상품 설정 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_CURRENT_PRODUCT, + payload: product, + }); + + // 관련 상품도 가져오기 (같은 category2 기준) + if (product.category2) { + try { + const relatedResponse = await getProducts({ + category2: product.category2, + limit: 20, + page: 1, + }); + + // 현재 상품 제외 + const relatedProducts = relatedResponse.products.filter((p) => p.productId !== productId); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, + payload: relatedProducts, + }); + } catch (error) { + console.error("관련 상품 프리패칭 실패:", error); + // 관련 상품 로드 실패는 조용히 처리 + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, + payload: [], + }); + } + } + } catch (error) { + console.error("상품 상세 프리패칭 실패:", error); + productStore.dispatch({ + type: PRODUCT_ACTIONS.SET_ERROR, + payload: error.message, + }); + } +} + +/** + * 서버 사이드 렌더링 함수 + * @param {string} url - 요청 URL + * @param {Object} query - 쿼리 파라미터 객체 + * @returns {Promise<{html: string, initialData: Object}>} 렌더링된 HTML 문자열과 초기 데이터 + */ +export const render = async (url, query = {}) => { + try { + // BASE_URL 제거 + const cleanUrl = url.replace(BASE_URL, "").replace(/\/$/, "") || "/"; + + // 라우트 찾기 + const route = findRoute(cleanUrl, BASE_URL); + + // 서버 환경에서 router 객체 설정 + router.setServerRoute(cleanUrl, query, route.params); + + // 모든 스토어 초기화 (각 요청마다 독립적인 상태 보장) + initializeStores(); + + // 라우트에 따라 데이터 프리패칭 + if (route.params.id) { + // 상품 상세 페이지 + await prefetchProductDetail(route.params.id); + } else { + // 홈 페이지 (상품 목록) + await prefetchProducts(query); + } + + // 페이지 컴포넌트 실행 + const html = route.handler(); + + // 스토어 상태 수집 (Hydration을 위해) + const productState = productStore.getState(); + const cartState = cartStore.getState(); + const uiState = uiStore.getState(); + + // 데이터 일치를 위한 체크섬 생성 (간단한 해시) + const dataChecksum = JSON.stringify({ + products: productState.products?.length || 0, + currentProduct: productState.currentProduct?.productId || null, + categories: Object.keys(productState.categories || {}).length, + }).replace(/\s/g, ""); + + const initialData = { + productStore: productState, + cartStore: cartState, + uiStore: uiState, + _checksum: dataChecksum, // 데이터 일치 확인용 + }; + + return { + html: html || "", + initialData, + }; + } catch (error) { + console.error("SSR Render Error:", error); + // 에러 발생 시 기본 페이지 반환 + return { + html: NotFoundPage(), + initialData: { + productStore: initialProductState, + cartStore: { items: [], selectedAll: false }, + uiStore: { + cartModal: { isOpen: false }, + globalLoading: false, + toast: { isVisible: false, message: "", type: "info" }, + }, + }, + }; + } }; diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..ee14eb9d 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, uiStore, PRODUCT_ACTIONS, UI_ACTIONS } from "./stores"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -15,10 +16,91 @@ const enableMocking = () => }), ); +/** + * 서버에서 전달된 초기 데이터로 스토어 복원 (Hydration) + */ +function hydrateStores() { + if (typeof window !== "undefined" && window.__INITIAL_DATA__) { + const { productStore: productState, uiStore: uiState, _checksum: serverChecksum } = window.__INITIAL_DATA__; + + // Product Store 복원 + if (productState) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + ...productState, + // 서버에서 이미 로드된 상태이므로 loading을 false로 설정 + loading: false, + }, + }); + + // 서버-클라이언트 데이터 일치 확인 + if (serverChecksum) { + const clientChecksum = JSON.stringify({ + products: productState.products?.length || 0, + currentProduct: productState.currentProduct?.productId || null, + categories: Object.keys(productState.categories || {}).length, + }).replace(/\s/g, ""); + + if (serverChecksum !== clientChecksum) { + console.warn( + "[Hydration] 서버-클라이언트 데이터 불일치 감지. 서버 체크섬:", + serverChecksum, + "클라이언트 체크섬:", + clientChecksum, + ); + } else if (import.meta.env.DEV) { + console.log("[Hydration] 서버-클라이언트 데이터 일치 확인 완료"); + } + } + } + + // Cart Store 복원 + // 서버에서는 빈 상태이지만, 클라이언트에서 로컬스토리지에서 로드하므로 + // 서버 상태는 무시하고 loadCartFromStorage()에서 처리 + + // UI Store 복원 + if (uiState) { + // 전체 UI 상태를 복원 + // cartModal 상태 복원 + if (uiState.cartModal) { + if (uiState.cartModal.isOpen) { + // 서버에서는 모달이 열려있지 않으므로 복원할 필요 없음 + } else { + uiStore.dispatch({ type: UI_ACTIONS.CLOSE_CART_MODAL }); + } + } + + // globalLoading 상태는 클라이언트에서 관리하므로 복원하지 않음 + + // toast 상태 복원 + if (uiState.toast) { + if (uiState.toast.isVisible) { + uiStore.dispatch({ + type: UI_ACTIONS.SHOW_TOAST, + payload: { + message: uiState.toast.message, + type: uiState.toast.type || "info", + }, + }); + } else { + uiStore.dispatch({ type: UI_ACTIONS.HIDE_TOAST }); + } + } + } + + // 초기 데이터 삭제 (메모리 정리) + delete window.__INITIAL_DATA__; + } +} + function main() { + // 서버에서 전달된 초기 데이터로 스토어 복원 (Hydration) + hydrateStores(); + registerAllEvents(); registerGlobalEvents(); - loadCartFromStorage(); + loadCartFromStorage(); // 장바구니는 로컬스토리지에서 로드 initRender(); router.start(); } diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..2cf8bbc0 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,20 +1,151 @@ import fs from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import { render } from "./dist/vanilla-ssr/main-server.js"; -const render = () => { - return `
안녕하세요
`; -}; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +// BASE_URL 설정 +const BASE_URL = ""; + +/** + * HTML 템플릿 생성 함수 + */ +function createHtmlTemplate(html, initialData = null) { + // 빌드된 HTML 템플릿 읽기 + const templatePath = join(__dirname, "../../dist/vanilla/index.html"); + let template = fs.readFileSync(templatePath, "utf-8"); + + // app-html 플레이스홀더 치환 + template = template.replace("", html); + + // app-head 플레이스홀더 제거 + template = template.replace("", ""); + + // 초기 데이터 스크립트 주입 (Hydration을 위해) + if (initialData) { + const initialDataScript = ``; + template = template.replace("", `${initialDataScript}\n `); + } + + return template; +} + +/** + * 모든 상품 ID 가져오기 + */ +async function getAllProductIds() { + try { + // items.json 파일에서 모든 상품 ID 가져오기 + // 여러 경로 시도 + const possiblePaths = [ + join(__dirname, "src/mocks/items.json"), + join(__dirname, "../src/mocks/items.json"), + join(__dirname, "../../packages/vanilla/src/mocks/items.json"), + ]; + + for (const itemsPath of possiblePaths) { + if (fs.existsSync(itemsPath)) { + const items = JSON.parse(fs.readFileSync(itemsPath, "utf-8")); + console.log(`✅ items.json 파일 발견: ${itemsPath}`); + return items.map((item) => item.productId); + } + } + + throw new Error("items.json 파일을 찾을 수 없습니다. 시도한 경로: " + possiblePaths.join(", ")); + } catch (error) { + console.error("❌ 상품 ID 가져오기 실패:", error.message); + return []; + } +} + +/** + * 정적 사이트 생성 + */ async function generateStaticSite() { - // HTML 템플릿 읽기 - const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); + console.log("🚀 정적 사이트 생성 시작..."); + console.log(`📁 작업 디렉토리: ${__dirname}`); + + // 서버 빌드 파일 확인 + const serverBuildPath = join(__dirname, "dist/vanilla-ssr/main-server.js"); + if (!fs.existsSync(serverBuildPath)) { + console.error("❌ 서버 빌드 파일을 찾을 수 없습니다:", serverBuildPath); + console.error(" 먼저 'pnpm run build:server'를 실행해주세요."); + process.exit(1); + } + + const distDir = join(__dirname, "../../dist/vanilla"); + if (!fs.existsSync(distDir)) { + console.error("❌ 클라이언트 빌드 디렉토리를 찾을 수 없습니다:", distDir); + console.error(" 먼저 'pnpm run build:client-for-ssg'를 실행해주세요."); + process.exit(1); + } + + const baseUrl = BASE_URL; + + // 1. 홈 페이지 생성 + console.log("📄 홈 페이지 생성 중..."); + try { + const { html, initialData } = await render(`${baseUrl}/`, {}); + const homeHtml = createHtmlTemplate(html, initialData); + fs.writeFileSync(join(distDir, "index.html"), homeHtml); + console.log("✅ 홈 페이지 생성 완료"); + } catch (error) { + console.error("❌ 홈 페이지 생성 실패:", error); + } + + // 2. 모든 상품 상세 페이지 생성 + console.log("📄 상품 상세 페이지 생성 중..."); + const productIds = await getAllProductIds(); + console.log(` 총 ${productIds.length}개의 상품 페이지 생성 예정`); + + let successCount = 0; + let failCount = 0; + + for (const productId of productIds) { + try { + const url = `${baseUrl}/product/${productId}`; + const { html, initialData } = await render(url, {}); + + // product 디렉토리 생성 + const productDir = join(distDir, "product", productId); + if (!fs.existsSync(productDir)) { + fs.mkdirSync(productDir, { recursive: true }); + } + + // HTML 파일 생성 + const productHtml = createHtmlTemplate(html, initialData); + fs.writeFileSync(join(productDir, "index.html"), productHtml); + + successCount++; + if (successCount % 10 === 0) { + console.log(` 진행 중... ${successCount}/${productIds.length}`); + } + } catch (error) { + console.error(`❌ 상품 ${productId} 페이지 생성 실패:`, error.message); + failCount++; + } + } + + console.log(`✅ 상품 상세 페이지 생성 완료: 성공 ${successCount}개, 실패 ${failCount}개`); - // 어플리케이션 렌더링하기 - const appHtml = render(); + // 3. 404 페이지 생성 (NotFoundPage) + console.log("📄 404 페이지 생성 중..."); + try { + const { html, initialData } = await render(`${baseUrl}/not-found-page`, {}); + const notFoundHtml = createHtmlTemplate(html, initialData); + fs.writeFileSync(join(distDir, "404.html"), notFoundHtml); + console.log("✅ 404 페이지 생성 완료"); + } catch (error) { + console.error("❌ 404 페이지 생성 실패:", error); + } - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + console.log("🎉 정적 사이트 생성 완료!"); } // 실행 -generateStaticSite(); +generateStaticSite().catch((error) => { + console.error("정적 사이트 생성 중 오류 발생:", error); + process.exit(1); +});