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: `
+
+ `,
+ 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