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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@types/node": "^24.0.13",
"@vitest/coverage-v8": "latest",
"@vitest/ui": "latest",
"concurrently": "latest",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
Expand All @@ -49,7 +50,6 @@
"typescript": "^5.8.3",
"typescript-eslint": "^8.36.0",
"vite": "npm:rolldown-vite@latest",
"vitest": "latest",
"concurrently": "latest"
"vitest": "latest"
}
}
10 changes: 10 additions & 0 deletions packages/vanilla/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,14 @@ export default [
pluginJs.configs.recommended,
eslintPluginPrettier,
eslintConfigPrettier,
{
rules: {
"prettier/prettier": [
"error",
{
endOfLine: "auto",
},
],
},
},
];
16 changes: 10 additions & 6 deletions packages/vanilla/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite --port 5173",
"dev:ssr": "PORT=5174 node server.js",
"build:client": "rm -rf ./dist/vanilla && vite build --outDir ./dist/vanilla && cp ./dist/vanilla/index.html ./dist/vanilla/404.html",
"build:client-for-ssg": "rm -rf ../../dist/vanilla && vite build --outDir ../../dist/vanilla",
"dev:ssr": "cross-env PORT=5174 nodemon server.js",
"build:client": "rimraf ./dist/vanilla && vite build --outDir ./dist/vanilla && cpy ./dist/vanilla/index.html ./dist/vanilla --rename=404.html",
"build:client-for-ssg": "rimraf ../../dist/vanilla && vite build --outDir ../../dist/vanilla",
"build:server": "vite build --outDir ./dist/vanilla-ssr --ssr src/main-server.js",
"build:ssg": "pnpm run build:client-for-ssg && node static-site-generate.js",
"build:without-ssg": "pnpm run build:client && pnpm run build:server",
Expand All @@ -16,7 +16,7 @@
"prettier:write": "prettier --write ./src",
"preview:csr": "vite preview --outDir ./dist/vanilla",
"preview:csr-with-build": "pnpm run build:client && pnpm run preview:csr",
"preview:ssr": "PORT=4174 NODE_ENV=production node server.js",
"preview:ssr": "cross-env PORT=4174 NODE_ENV=production node server.js",
"preview:ssr-with-build": "pnpm run build:without-ssg && pnpm run preview:ssr",
"preview:ssg": "vite preview --outDir ../../dist/vanilla --port 4178",
"preview:ssg-with-build": "pnpm run build && pnpm run preview:ssg",
Expand All @@ -38,6 +38,9 @@
"@testing-library/user-event": "^14.6.1",
"@vitest/coverage-v8": "latest",
"@vitest/ui": "^2.1.8",
"concurrently": "latest",
"cpy-cli": "^6.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
Expand All @@ -47,10 +50,11 @@
"jsdom": "^25.0.1",
"lint-staged": "^15.2.11",
"msw": "^2.10.2",
"nodemon": "^3.1.11",
"prettier": "^3.4.2",
"rimraf": "^6.1.2",
"vite": "npm:rolldown-vite@latest",
"vitest": "latest",
"concurrently": "latest"
"vitest": "latest"
},
"msw": {
"workerDirectory": [
Expand Down
70 changes: 50 additions & 20 deletions packages/vanilla/server.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,61 @@
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_7th_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(),
);
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("/src", express.static("./src/components"));

// Serve HTML
app.use("*all", async (req, res) => {
try {
const url = req.originalUrl.replace(base, "");
let template;
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 = fs.readFileSync("./dist/vanilla/index.html", "utf-8");
render = (await import("./dist/vanilla-ssr/main-server.js")).render;
}

const rendered = await render(url);

const html = template
.replace(
`<!--app-head-->`,
`${rendered.head ?? ""} ${rendered.data ? `<script>window.__INITIAL_DATA__=${JSON.stringify(rendered.data ?? {})}</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
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 @@
/**
* 간단한 SPA 라우터
*/
import { createObserver } from "./createObserver.js";

// Router.js -> Window 의존하는 것 제거
export class ServerRouter {
#routes;
#route;
#observer = createObserver();
#baseUrl;
#queryString;

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

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

get query() {
return ServerRouter.parseQuery(this.#queryString);
}

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:3000") {
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 {
// baseUrl이 없으면 자동으로 붙여줌
let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url);
this.#route = this.#findRoute(fullUrl);
this.#observer.notify();
} catch (error) {
console.error("라우터 네비게이션 오류:", error);
}
}

/**
* 라우터 시작
*/
start() {
this.#route = this.#findRoute();
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;
};
}
Loading
Loading