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 @@ -5,18 +5,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<!--app-head-->
<link rel="stylesheet" href="/src/styles.css">
<!--app-data-->
<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
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 fs from "fs/promises";
import express from "express";
import { mswServer } from "./src/mocks/serverBrowser.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/" : "/");

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(),
);
mswServer.listen({ onUnhandledRequest: "bypass" });

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: [] }));
}

// Serve HTML
app.use("*all", async (req, res) => {
try {
let url = req.originalUrl;
if (base !== "/" && url.startsWith(base)) {
url = url.replace(base, "");
}
if (!url.startsWith("/")) {
url = "/" + url;
}

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

const html = template
.replace(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-data-->`, `<script>window.__INITIAL_DATA__ = ${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
8 changes: 5 additions & 3 deletions packages/vanilla/src/api/productApi.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const BASE_URL = () => (typeof window === "undefined" ? "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 +13,17 @@ export async function getProducts(params = {}) {
sort,
});

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

return await response.json();
}

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

export async function getCategories() {
const response = await fetch("/api/categories");
const response = await fetch(`${BASE_URL()}/api/categories`);
return await response.json();
}
2 changes: 1 addition & 1 deletion packages/vanilla/src/components/CartModal.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CartItem } from "./CartItem";
import { CartItem } from "./CartItem.js";

export function CartModal({ items = [], selectedAll = false, isOpen = false }) {
if (!isOpen) {
Expand Down
2 changes: 1 addition & 1 deletion packages/vanilla/src/components/ProductList.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ProductCard, ProductCardSkeleton } from "./ProductCard";
import { ProductCard, ProductCardSkeleton } from "./ProductCard.js";

const loadingSkeleton = Array(6).fill(0).map(ProductCardSkeleton).join("");

Expand Down
16 changes: 8 additions & 8 deletions packages/vanilla/src/components/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export * from "./ProductCard";
export * from "./SearchBar";
export * from "./ProductList";
export * from "./CartItem";
export * from "./CartModal";
export * from "./Toast";
export * from "./Logo";
export * from "./Footer";
export * from "./ProductCard.js";
export * from "./SearchBar.js";
export * from "./ProductList.js";
export * from "./CartItem.js";
export * from "./CartModal.js";
export * from "./Toast.js";
export * from "./Logo.js";
export * from "./Footer.js";
3 changes: 2 additions & 1 deletion packages/vanilla/src/constants.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const BASE_URL = import.meta.env.PROD ? "/front_6th_chapter4-1/vanilla/" : "/";
export const BASE_URL =
typeof window === "undefined" ? "/" : import.meta.env?.PROD ? "/front_6th_chapter4-1/vanilla/" : "/";
169 changes: 169 additions & 0 deletions packages/vanilla/src/lib/ServerRouter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* Server 라우터
*/
import { createObserver } from "./createObserver.js";

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

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

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

get query() {
// 서버 사이드에서는 #serverQuery를 사용, 클라이언트 사이드에서는 URL 파라미터 사용
if (typeof window === "undefined") {
return this.#serverQuery;
}
return ServerRouter.parseQuery(window.location.search);
}

set query(newQuery) {
// 서버 사이드에서는 #serverQuery를 설정, 클라이언트 사이드에서는 URL 업데이트
if (typeof window === "undefined") {
this.#serverQuery = { ...newQuery };
} else {
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 {
// 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;
};

/**
* 객체를 쿼리 문자열로 변환
* @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, pathname = "/", 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);
return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
};
}
12 changes: 11 additions & 1 deletion packages/vanilla/src/lib/createStorage.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
export const memoryStorage = () => {
const storage = new Map();

return {
getItem: (key) => storage.get(key),
setItem: (key, value) => storage.set(key, value),
removeItem: (key) => storage.delete(key),
};
};

/**
* 로컬스토리지 추상화 함수
* @param {string} key - 스토리지 키
* @param {Storage} storage - 기본값은 localStorage
* @returns {Object} { get, set, reset }
*/
export const createStorage = (key, storage = window.localStorage) => {
export const createStorage = (key, storage = typeof window === "undefined" ? memoryStorage() : window.localStorage) => {
const get = () => {
try {
const item = storage.getItem(key);
Expand Down
2 changes: 1 addition & 1 deletion packages/vanilla/src/lib/createStore.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createObserver } from "./createObserver";
import { createObserver } from "./createObserver.js";

/**
* Redux-style Store 생성 함수
Expand Down
9 changes: 5 additions & 4 deletions packages/vanilla/src/lib/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./createObserver";
export * from "./createStore";
export * from "./createStorage";
export * from "./Router";
export * from "./createObserver.js";
export * from "./createStore.js";
export * from "./createStorage.js";
export * from "./Router.js";
export * from "./ServerRouter.js";
Loading
Loading