Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 56 additions & 21 deletions packages/vanilla/server.js
Original file line number Diff line number Diff line change
@@ -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 `<div>안녕하세요</div>`;
};

app.get("*all", (req, res) => {
res.send(
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vanilla Javascript SSR</title>
</head>
<body>
<div id="app">${render()}</div>
</body>
</html>
`.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(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-html-->`, 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
Expand Down
11 changes: 8 additions & 3 deletions packages/vanilla/src/api/productApi.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
}
37 changes: 24 additions & 13 deletions packages/vanilla/src/lib/Router.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,20 @@ 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() {
return this.#baseUrl;
}

get query() {
return Router.parseQuery(window.location.search);
return Router.parseQuery();
}

set query(newQuery) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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}` : ""}`;
};
}
25 changes: 23 additions & 2 deletions packages/vanilla/src/lib/createStorage.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Loading
Loading