diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..0a47841e 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,31 +1,66 @@ +import fs from "node:fs/promises"; import express from "express"; +import { getBaseUrl } from "./src/mocks/utils.js"; +import { server as mswServer } from "./src/mocks/node.js"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; -const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); +mswServer.listen({ onUnhandledRequest: "warn" }); + +// gh-pages 배포 기준 +const base = getBaseUrl(prod); + +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(), - ); +let vite; +if (!prod) { + // 개발 모드일 때, hmr을 제공하기 위함 + 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; + app.use(compression()); + // 👇 express 내장 static 사용 + app.use(base, express.static("./dist/vanilla-ssr", { extensions: [] })); +} + +// Serve HTML +app.get("*all", async (req, res) => { + try { + const url = req.originalUrl.replace(base, ""); + + /** @type {string} */ + let template; + let render; + if (!prod) { + // 실시간 index.html 반영 + 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(url); + + const html = template + .replace(``, rendered.head ?? "") + .replace(``, rendered.html ?? ""); + + 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..a946c097 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,3 +1,8 @@ +import { getBaseUrl } from "../mocks/utils.js"; + +const isProd = process.env.NODE_ENV === "production"; +const baseUrl = getBaseUrl(isProd); + export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; @@ -11,17 +16,17 @@ export async function getProducts(params = {}) { sort, }); - const response = await fetch(`/api/products?${searchParams}`); + const response = await fetch(`${baseUrl}api/products?${searchParams}`); return await response.json(); } export async function getProduct(productId) { - const response = await fetch(`/api/products/${productId}`); + const response = await fetch(`${baseUrl}api/products/${productId}`); return await response.json(); } export async function getCategories() { - const response = await fetch("/api/categories"); + const response = await fetch(`${baseUrl}api/categories`); return await response.json(); } diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js index 2238a878..a817cd63 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/Router.js @@ -14,10 +14,12 @@ export class Router { this.#route = null; this.#baseUrl = baseUrl.replace(/\/$/, ""); - window.addEventListener("popstate", () => { - this.#route = this.#findRoute(); - this.#observer.notify(); - }); + if (typeof window !== "undefined") { + window.addEventListener("popstate", () => { + this.#route = this.#findRoute(); + this.#observer.notify(); + }); + } } get baseUrl() { @@ -25,7 +27,7 @@ export class Router { } get query() { - return Router.parseQuery(window.location.search); + return Router.parseQuery(); } set query(newQuery) { @@ -73,8 +75,11 @@ export class Router { }); } - #findRoute(url = window.location.pathname) { - const { pathname } = new URL(url, window.location.origin); + #findRoute(url) { + const defaultUrl = typeof window !== "undefined" ? window.location.pathname : "/"; + const currentUrl = url || defaultUrl; + const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + const { pathname } = new URL(currentUrl, origin); for (const [routePath, route] of this.#routes) { const match = pathname.match(route.regex); if (match) { @@ -103,11 +108,13 @@ 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 +137,10 @@ export class Router { * @param {string} search - location.search 또는 쿼리 문자열 * @returns {Object} 파싱된 쿼리 객체 */ - static parseQuery = (search = window.location.search) => { + static parseQuery = (search) => { + if (search === undefined) { + search = typeof window !== "undefined" ? window.location.search : ""; + } const params = new URLSearchParams(search); const query = {}; for (const [key, value] of params) { @@ -166,6 +176,7 @@ export class Router { }); const queryString = Router.stringifyQuery(updatedQuery); - return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; + const pathname = typeof window !== "undefined" ? window.location.pathname : "/"; + return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; }; } diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..b4b67743 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -1,10 +1,31 @@ +/** + * 메모리 기반 스토리지 구현체 (서버 사이드용) + */ +const createMemoryStorage = () => { + const store = new Map(); + + return { + getItem: (key) => store.get(key) || null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), + get length() { + return store.size; + }, + key: (index) => Array.from(store.keys())[index] || null, + }; +}; + /** * 로컬스토리지 추상화 함수 * @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 : createMemoryStorage(), +) => { const get = () => { try { const item = storage.getItem(key); diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..0e5ddaf9 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,190 @@ -export const render = async (url, query) => { - console.log({ url, query }); - return ""; -}; +import { getBaseUrl } from "./mocks/utils"; +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { productStore } from "./stores/productStore.js"; +import { PRODUCT_ACTIONS } from "./stores/actionTypes.js"; + +class ServerRouter { + #routes; + // #route; + #baseUrl; + + constructor(baseUrl = "") { + this.#routes = new Map(); + // this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + get baseUrl() { + return this.#baseUrl; + } + + get routes() { + return this.#routes; + } + + addRoute(path, handler) { + // :id → (\\\\d+) 정규식 변환 + // paramNames 배열 저장 + const paramNames = []; + const regexPath = path + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); // ':id' -> 'id' + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${regexPath}/?$`); // pathname만 비교하면 됨 + + this.#routes.set(path, { + regex, + paramNames, // :id 값 반환 + handler, // 얘일 때 뭐실행? + }); + } + + findRoute(url) { + // full URL인 경우 pathname만 추출, 아니면 쿼리스트링만 제거 + let pathname; + if (url.startsWith("http")) { + pathname = new URL(url).pathname; + } else { + pathname = url.split("?")[0]; + } + + for (const [routePath, route] of this.#routes) { + const match = pathname.match(route.regex); + + if (match) { + // 매치된 파라미터들을 객체로 변환 + const params = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + ...route, + params, + path: routePath, + }; + } + } + return null; + } + + static getQueryString(url) { + const query = url.split("?")[1] || ""; + return query; + } + + static parseQueryString(url) { + // "?"로 나눠서 뒷부분만 가져오기 + const query = url.split("?")[1] || ""; + const params = new URLSearchParams(query); + const result = {}; + + for (const [key, value] of params.entries()) { + if (result[key]) { + if (Array.isArray(result[key])) { + result[key].push(value); + } else { + result[key] = [result[key], value]; + } + } else { + result[key] = value; + } + } + + return result; + } +} + +const isProd = process.env.NODE_ENV === "production"; +const baseUrl = getBaseUrl(isProd); + +async function prefetchData(route, { params, query }) { + if (!route || route.path === "/") { + const productsRes = await (await fetch(`${baseUrl}api/products${query ? `?${query}` : ""}`)).json(); + const categories = await (await fetch(`${baseUrl}api/categories`)).json(); + console.log("!?!?", productsRes, categories); + return { products: productsRes.products, categories: categories, totalCount: productsRes.pagination.total }; + } else { + const product = await fetch(`${baseUrl}api/products/${params.id}`).then((r) => r.json()); + + // 관련 상품도 prefetch + let relatedProducts = []; + if (product.category2) { + try { + const relatedParams = new URLSearchParams({ + category2: product.category2, + limit: "20", + page: "1", + }); + const relatedResponse = await fetch(`${baseUrl}api/products?${relatedParams}`).then((r) => r.json()); + // 현재 상품 제외 + relatedProducts = relatedResponse.products.filter((p) => p.productId !== params.id); + } catch (error) { + console.error("관련 상품 prefetch 실패:", error); + relatedProducts = []; + } + } + + return { product, relatedProducts }; + } +} + +// --- render.js --- + +export async function render(url = "/") { + const router = new ServerRouter("", url); + router.addRoute("/", HomePage); + router.addRoute("/product/:id", ProductDetailPage); + + const route = router.findRoute(url); + const queryString = ServerRouter.getQueryString(url); + const parsedQuery = ServerRouter.parseQueryString(url); + console.log(queryString); + if (!route) { + const rendered = { + html: NotFoundPage(), + head: "", + }; + return rendered; + } + + const initialData = await prefetchData(route, { params: route ? route.params : {}, query: queryString }); + + // SSR 환경에서만 임시로 store 초기화 (페이지 렌더링용) + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: initialData.products || [], + totalCount: initialData.totalCount || 0, + categories: initialData.categories || {}, + currentProduct: initialData.product || null, + relatedProducts: initialData.relatedProducts || [], + loading: false, + status: "done", + }, + }); + + let content = ""; + let head = `;`; + if (route.handler) { + if (route.handler === HomePage) { + content = HomePage(parsedQuery); + head += `쇼핑몰 - 홈`; + } else if (route.handler === ProductDetailPage) { + head += `${initialData.product.title} - 쇼핑몰`; + content = ProductDetailPage(route.params.id); + } + } else { + content = NotFoundPage(); + } + + const rendered = { + html: content, + head, + }; + + return rendered; +} diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..76c6fd37 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 }) => @@ -11,11 +13,30 @@ const enableMocking = () => serviceWorker: { url: `${BASE_URL}mockServiceWorker.js`, }, - onUnhandledRequest: "bypass", + onUnhandledRequest: "warn", }), ); function main() { + // SSR에서 주입된 초기 데이터 확인 및 hydration + const initialData = window.__INITIAL_DATA__; + if (initialData) { + console.log("Hydrating with initial data:", initialData); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: initialData.products || [], + totalCount: initialData.totalCount || 0, + categories: initialData.categories || {}, + currentProduct: initialData.product || null, + relatedProducts: initialData.relatedProducts || [], + loading: false, + status: "done", + }, + }); + } + registerAllEvents(); registerGlobalEvents(); loadCartFromStorage(); @@ -23,8 +44,7 @@ function main() { router.start(); } -if (import.meta.env.MODE !== "test") { - enableMocking().then(main); -} else { - main(); -} +// 현재 node, test, browser환경에서 모두 msw를 사용하므로 분기처리하지 않음. +enableMocking().then(main); + +// enableMocking().then(main); diff --git a/packages/vanilla/src/mocks/handlers.js b/packages/vanilla/src/mocks/handlers.js index 6e3035e6..417fa363 100644 --- a/packages/vanilla/src/mocks/handlers.js +++ b/packages/vanilla/src/mocks/handlers.js @@ -1,5 +1,6 @@ import { http, HttpResponse } from "msw"; -import items from "./items.json"; +import items from "./items.json" with { type: "json" }; +import { getBaseUrl } from "./utils.js"; const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); @@ -62,9 +63,12 @@ function filterProducts(products, query) { return filtered; } +const prod = process.env.NODE_ENV === "production"; +const baseUrl = getBaseUrl(prod); + export const handlers = [ // 상품 목록 API - http.get("/api/products", async ({ request }) => { + http.get(`${baseUrl}api/products`, async ({ request }) => { const url = new URL(request.url); const page = parseInt(url.searchParams.get("page") ?? url.searchParams.get("current")) || 1; const limit = parseInt(url.searchParams.get("limit")) || 20; @@ -111,7 +115,7 @@ export const handlers = [ }), // 상품 상세 API - http.get("/api/products/:id", ({ params }) => { + http.get(`${baseUrl}api/products/:id`, ({ params }) => { const { id } = params; const product = items.find((item) => item.productId === id); @@ -133,7 +137,7 @@ export const handlers = [ }), // 카테고리 목록 API - http.get("/api/categories", async () => { + http.get(`${baseUrl}api/categories`, async () => { const categories = getUniqueCategories(); await delay(); return HttpResponse.json(categories); diff --git a/packages/vanilla/src/mocks/node.js b/packages/vanilla/src/mocks/node.js new file mode 100644 index 00000000..2c0b4418 --- /dev/null +++ b/packages/vanilla/src/mocks/node.js @@ -0,0 +1,5 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers.js"; + +// Node 환경용 서버 MSW +export const server = setupServer(...handlers); diff --git a/packages/vanilla/src/mocks/utils.js b/packages/vanilla/src/mocks/utils.js new file mode 100644 index 00000000..bdf26c08 --- /dev/null +++ b/packages/vanilla/src/mocks/utils.js @@ -0,0 +1,23 @@ +export const getBaseUrl = (isProd) => { + // Node 환경 (process.env 있음) + const nodeEnv = typeof process !== "undefined" ? process.env : {}; + + // Browser 환경 (import.meta.env 있음) + const browserEnv = typeof import.meta !== "undefined" ? import.meta.env : {}; + + if (typeof window !== "undefined") { + // 브라우저 환경 + if (isProd) { + // gh-pages 같은 정적 배포 + return browserEnv.VITE_BASE || "/front_6th_chapter4-1/vanilla/"; + } else { + // 개발 환경 → origin만 사용 + + return window.location.origin + "/"; + } + } + + // Node 환경 (서버에서 실행될 때) + const port = nodeEnv.PORT || 5173; + return nodeEnv.BASE || (isProd ? "/front_6th_chapter4-1/vanilla/" : `http://localhost:${port}/`); +}; diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index ca08c26c..43ce10f6 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -17,9 +17,11 @@ export const HomePage = withLifecycle( () => loadProducts(true), ], }, - () => { + (serverQuery) => { const productState = productStore.getState(); - const { search: searchQuery, limit, sort, category1, category2 } = router.query; + + const currentQuery = serverQuery || router.query; + const { search: searchQuery, limit, sort, category1, category2 } = currentQuery; const { products, loading, error, totalCount, categories } = productState; const category = { category1, category2 }; const hasMore = products.length < totalCount; diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 73d0ec30..59f96aa2 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -239,10 +239,16 @@ export const ProductDetailPage = withLifecycle( onMount: () => { loadProductDetailForPage(router.params.id); }, - watches: [() => [router.params.id], () => loadProductDetailForPage(router.params.id)], + watches: [ + () => [router.params.id], + () => { + loadProductDetailForPage(router.params.id); + }, + ], }, () => { - const { currentProduct: product, relatedProducts = [], error, loading } = productStore.getState(); + const productStoreCopy = productStore.getState(); + const { currentProduct: product, relatedProducts = [], error, loading } = productStoreCopy; return PageWrapper({ headerLeft: ` diff --git a/packages/vanilla/src/services/productService.js b/packages/vanilla/src/services/productService.js index 8a12e8bd..2658506f 100644 --- a/packages/vanilla/src/services/productService.js +++ b/packages/vanilla/src/services/productService.js @@ -3,6 +3,14 @@ import { initialProductState, productStore, PRODUCT_ACTIONS } from "../stores"; import { router } from "../router"; export const loadProductsAndCategories = async () => { + const currentState = productStore.getState(); + + // 이미 상품 목록과 카테고리 데이터가 있으면 API 호출 스킵 (SSR hydration) + if (currentState.products.length > 0 && Object.keys(currentState.categories).length > 0 && !currentState.loading) { + console.log("이미 상품 목록 데이터가 있어서 API 호출 스킵"); + return; + } + router.query = { current: undefined }; // 항상 첫 페이지로 초기화 productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP, @@ -120,14 +128,11 @@ export const setLimit = (limit) => { */ export const loadProductDetailForPage = async (productId) => { try { - const currentProduct = productStore.getState().currentProduct; - if (productId === currentProduct?.productId) { - // 관련 상품 로드 (같은 category2 기준) - if (currentProduct.category2) { - await loadRelatedProducts(currentProduct.category2, productId); - } + // 이미 해당 상품 데이터가 있고, 관련 상품도 있으면 API 호출 스킵 + if (import.meta.env.SSR) { return; } + // 현재 상품 클리어 productStore.dispatch({ type: PRODUCT_ACTIONS.SETUP,