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
15 changes: 8 additions & 7 deletions packages/vanilla/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,20 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<!--app-initial-data-->
<!--app-head-->
<link rel="stylesheet" href="/src/styles.css">
<link rel="stylesheet" href="/src/styles.css" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3b82f6',
secondary: '#6b7280'
}
}
}
}
primary: "#3b82f6",
secondary: "#6b7280",
},
},
},
};
</script>
</head>
<body class="bg-gray-50">
Expand Down
78 changes: 57 additions & 21 deletions packages/vanilla/server.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,67 @@
import express from "express";
import fs from "fs";

const prod = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5173;
const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/");
// const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/");
const base = process.env.BASE || "/front_6th_chapter4-1/vanilla/";

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(),
);
// Cached production assets
const templateHtml = prod ? fs.readFileSync("./dist/vanilla/index.html", "utf-8") : "";

let vite;

if (!prod) {
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;
const sirv = (await import("sirv")).default;
app.use(compression());
app.use(base, sirv("./dist/vanilla", { extensions: [] }));
}

app.use("*all", async (req, res) => {
try {
const url = req.originalUrl.replace(base, "");

/** @type {string} */
let template;
/** @type {import('./src/main-server.js').render} */
let render;
if (!prod) {
// Always read fresh template in development
template = fs.readFileSync("./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, req.query);

const html = template
.replace(`<!--app-head-->`, rendered.head ?? "")
.replace(
`<!--app-initial-data-->`,
`<script>window.__INITIAL_DATA__ = ${JSON.stringify(rendered.initialData)}</script>`,
)
.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
16 changes: 13 additions & 3 deletions packages/vanilla/src/api/productApi.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
const prod = process.env.NODE_ENV === "production";

const getBaseUrl = () => {
if (typeof window !== "undefined") {
return "";
}

return prod ? "http://localhost:4174" : "http://localhost:5174";
};

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 +21,17 @@ export async function getProducts(params = {}) {
sort,
});

const response = await fetch(`/api/products?${searchParams}`);
const response = await fetch(`${getBaseUrl()}/api/products?${searchParams}`);

return await response.json();
}

export async function getProduct(productId) {
const response = await fetch(`/api/products/${productId}`);
const response = await fetch(`${getBaseUrl()}/api/products/${productId}`);
return await response.json();
}

export async function getCategories() {
const response = await fetch("/api/categories");
const response = await fetch(`${getBaseUrl()}/api/categories`);
return await response.json();
}
20 changes: 14 additions & 6 deletions packages/vanilla/src/lib/Router.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export class Router {
this.#route = null;
this.#baseUrl = baseUrl.replace(/\/$/, "");

if (typeof window === "undefined") {
return;
}

window.addEventListener("popstate", () => {
this.#route = this.#findRoute();
this.#observer.notify();
Expand All @@ -25,7 +29,7 @@ export class Router {
}

get query() {
return Router.parseQuery(window.location.search);
return Router.parseQuery(typeof window !== "undefined" ? window.location.search : {});
}

set query(newQuery) {
Expand Down Expand Up @@ -73,8 +77,8 @@ 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 } = new URL(url, typeof window !== "undefined" ? window.location.origin : "/");
for (const [routePath, route] of this.#routes) {
const match = pathname.match(route.regex);
if (match) {
Expand Down Expand Up @@ -103,7 +107,9 @@ 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}`;
const prevFullUrl = `${typeof window !== "undefined" ? window.location.pathname : "/"}${
typeof window !== "undefined" ? window.location.search : ""
}`;

// 히스토리 업데이트
if (prevFullUrl !== fullUrl) {
Expand All @@ -130,7 +136,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 Down Expand Up @@ -166,6 +172,8 @@ export class Router {
});

const queryString = Router.stringifyQuery(updatedQuery);
return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
return `${baseUrl}${typeof window !== "undefined" ? window.location.pathname.replace(baseUrl, "") : "/"}${
queryString ? `?${queryString}` : ""
}`;
};
}
158 changes: 158 additions & 0 deletions packages/vanilla/src/lib/ServerRouter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Node.js 서버 라우터
*/
import { createObserver } from "./createObserver.js";

export class ServerRouter {
#routes;
#route;
#observer = createObserver();
#baseUrl;
#currentQuery;

constructor(baseUrl = "") {
this.#routes = new Map();
this.#route = null;
this.#baseUrl = baseUrl.replace(/\/$/, "");
}

get baseUrl() {
return this.#baseUrl;
}

get query() {
return this.#currentQuery;
}

set query(newQuery) {
const newUrl = ServerRouter.getUrl(newQuery, this.#baseUrl);
this.push(newUrl);
}

get params() {
return this.#route?.params ?? {};
}

get route() {
return this.#route;
}

get target() {
return this.#route?.handler;
}

subscribe(fn) {
this.#observer.subscribe(fn);
}

/**
* 라우트 등록
* @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,
});
}

#findRoute(url = "/", origin = "http://localhost") {
const { pathname } = new URL(url, origin);
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;
}

/**
* 네비게이션 실행
* @param {string} url - 이동할 경로
*/
push(url = "/") {
try {
this.#route = this.#findRoute(url);
} catch (error) {
console.error("라우터 네비게이션 오류:", error);
}
}

/**
* 라우터 시작
*/
start(url = "/", query = {}) {
this.#currentQuery = query;
this.#route = this.#findRoute(url);
this.#observer.notify();
}

/**
* 쿼리 파라미터를 객체로 파싱
* @param {string} search - location.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();
};

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

// 빈 값들 제거
Object.keys(updatedQuery).forEach((key) => {
if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") {
delete updatedQuery[key];
}
});

const queryString = ServerRouter.stringifyQuery(updatedQuery);
//TODO: 수정해야하나?
return `${baseUrl}/${queryString ? `?${queryString}` : ""}`;
};
}
Loading
Loading