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
4 changes: 2 additions & 2 deletions packages/vanilla/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -57,7 +58,6 @@
},
"dependencies": {
"compression": "^1.8.1",
"express": "^5.1.0",
"sirv": "^3.0.1"
"express": "^5.1.0"
}
}
80 changes: 60 additions & 20 deletions packages/vanilla/server.js
Original file line number Diff line number Diff line change
@@ -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 `<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) {
// 개발 환경: 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
? `<script>window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData)};</script>`
: "";

const html = htmlTemplate
.replace(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-html-->`, rendered.html ?? "")
.replace(`</head>`, `${initialDataScript}</head>`);

// 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
Expand Down
43 changes: 26 additions & 17 deletions packages/vanilla/src/lib/Router.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import { createObserver } from "./createObserver.js";

export class Router {
export class GlobalRouter {
#routes;
#route;
#observer = createObserver();
Expand All @@ -14,22 +14,26 @@ 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() {
return this.#baseUrl;
}

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);
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -155,7 +163,7 @@ export class Router {
};

static getUrl = (newQuery, baseUrl = "") => {
const currentQuery = Router.parseQuery();
const currentQuery = GlobalRouter.parseQuery();
const updatedQuery = { ...currentQuery, ...newQuery };

// 빈 값들 제거
Expand All @@ -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}` : ""}`;
};
}
125 changes: 125 additions & 0 deletions packages/vanilla/src/lib/ServerRouter.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
7 changes: 5 additions & 2 deletions packages/vanilla/src/lib/createStorage.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) {
Expand All @@ -24,6 +26,7 @@ export const createStorage = (key, storage = window.localStorage) => {
};

const reset = () => {
if (!storage) return; // 서버 환경에서는 아무것도 하지 않음
try {
storage.removeItem(key);
} catch (error) {
Expand Down
Loading
Loading