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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,19 @@
"@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",
"gh-pages": "^6.3.0",
"globals": "^15.13.0",
"husky": "^9.1.7",
"lint-staged": "^15.2.11",
"nodemon": "^3.1.11",
"prettier": "^3.4.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.36.0",
"vite": "npm:rolldown-vite@latest",
"vitest": "latest",
"concurrently": "latest"
"vitest": "latest"
}
}
65 changes: 45 additions & 20 deletions packages/lib/src/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ type QueryPayload = Record<string, string | number | undefined>;

export type RouterInstance<T extends AnyFunction> = InstanceType<typeof Router<T>>;

const isServer = typeof window === "undefined";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class Router<Handler extends (...args: any[]) => any> {
readonly #routes: Map<string, Route<Handler>>;
Expand All @@ -25,29 +27,33 @@ export class Router<Handler extends (...args: any[]) => any> {
this.#route = null;
this.#baseUrl = baseUrl.replace(/\/$/, "");

window.addEventListener("popstate", () => {
this.#route = this.#findRoute();
this.#observer.notify();
});

document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target?.closest("[data-link]")) {
return;
}
e.preventDefault();
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
if (url) {
this.push(url);
}
});
if (!isServer) {
window.addEventListener("popstate", () => {
this.#route = this.#findRoute();
this.#observer.notify();
});

document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target?.closest("[data-link]")) {
return;
}
e.preventDefault();
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
if (url) {
this.push(url);
}
});
}
}

get query(): StringRecord {
if (isServer) return {};
return Router.parseQuery(window.location.search);
}

set query(newQuery: QueryPayload) {
if (isServer) return;
const newUrl = Router.getUrl(newQuery, this.#baseUrl);
this.push(newUrl);
}
Expand Down Expand Up @@ -85,8 +91,23 @@ export class Router<Handler extends (...args: any[]) => any> {
});
}

#findRoute(url = window.location.pathname) {
const { pathname } = new URL(url, window.location.origin);
#findRoute(url?: string) {
if (isServer && !url) return null;

let pathname: string;
if (url) {
// URL에서 pathname만 추출 (쿼리 문자열 제거)
if (url.includes("://")) {
pathname = new URL(url).pathname;
} else if (url.includes("?")) {
pathname = url.split("?")[0];
} else {
pathname = url;
}
} else {
pathname = window.location.pathname;
}

for (const [routePath, route] of this.#routes) {
const match = pathname.match(route.regex);
if (match) {
Expand All @@ -107,6 +128,7 @@ export class Router<Handler extends (...args: any[]) => any> {
}

push(url: string) {
if (isServer) return;
try {
// baseUrl이 없으면 자동으로 붙여줌
const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url);
Expand All @@ -126,12 +148,14 @@ export class Router<Handler extends (...args: any[]) => any> {
}

start() {
if (isServer) return;
this.#route = this.#findRoute();
this.#observer.notify();
}

static parseQuery = (search = window.location.search) => {
const params = new URLSearchParams(search);
static parseQuery = (search?: string) => {
if (isServer && !search) return {};
const params = new URLSearchParams(search ?? window.location.search);
const query: StringRecord = {};
for (const [key, value] of params) {
query[key] = value;
Expand All @@ -150,6 +174,7 @@ export class Router<Handler extends (...args: any[]) => any> {
};

static getUrl = (newQuery: QueryPayload, baseUrl = "") => {
if (isServer) return "";
const currentQuery = Router.parseQuery();
const updatedQuery = { ...currentQuery, ...newQuery };

Expand Down
20 changes: 16 additions & 4 deletions packages/lib/src/createStorage.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { createObserver } from "./createObserver.ts";

export const createStorage = <T>(key: string, storage = window.localStorage) => {
let data: T | null = JSON.parse(storage.getItem(key) ?? "null");
const isServer = typeof window === "undefined";

const noopStorage: Storage = {
length: 0,
clear: () => {},
getItem: () => null,
key: () => null,
removeItem: () => {},
setItem: () => {},
};

export const createStorage = <T>(key: string, storage?: Storage) => {
const actualStorage = storage ?? (isServer ? noopStorage : window.localStorage);
let data: T | null = JSON.parse(actualStorage.getItem(key) ?? "null");
const { subscribe, notify } = createObserver();

const get = () => data;

const set = (value: T) => {
try {
data = value;
storage.setItem(key, JSON.stringify(data));
actualStorage.setItem(key, JSON.stringify(data));
notify();
} catch (error) {
console.error(`Error setting storage item for key "${key}":`, error);
Expand All @@ -19,7 +31,7 @@ export const createStorage = <T>(key: string, storage = window.localStorage) =>
const reset = () => {
try {
data = null;
storage.removeItem(key);
actualStorage.removeItem(key);
notify();
} catch (error) {
console.error(`Error removing storage item for key "${key}":`, error);
Expand Down
3 changes: 2 additions & 1 deletion packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useRouter = <T extends RouterInstance<AnyFunction>, S>(router: T, selector = defaultSelector<T, S>) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(router.subscribe, () => shallowSelector(router));
const getSnapshot = () => shallowSelector(router);
return useSyncExternalStore(router.subscribe, getSnapshot, getSnapshot);
};
2 changes: 1 addition & 1 deletion packages/lib/src/hooks/useStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import type { createStorage } from "../createStorage";
type Storage<T> = ReturnType<typeof createStorage<T>>;

export const useStorage = <T>(storage: Storage<T>) => {
return useSyncExternalStore(storage.subscribe, storage.get);
return useSyncExternalStore(storage.subscribe, storage.get, storage.get);
};
3 changes: 2 additions & 1 deletion packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState()));
const getSnapshot = () => shallowSelector(store.getState());
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
};
120 changes: 95 additions & 25 deletions packages/react/server.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,102 @@
import express from "express";
import { renderToString } from "react-dom/server";
import { createElement } from "react";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

const prod = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5174;
const base = process.env.BASE || (prod ? "/front_7th_chapter4-1/react/" : "/");
const base = prod ? "/front_7th_chapter4-1/react/" : "/";

const app = express();

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>React SSR</title>
</head>
<body>
<div id="app">${renderToString(createElement("div", null, "안녕하세요"))}</div>
</body>
</html>
`.trim(),
);
});

// Start http server
app.listen(port, () => {
console.log(`React Server started at http://localhost:${port}`);
});
let template;
let render;

if (prod) {
template = fs.readFileSync(path.resolve(__dirname, "dist/react/index.html"), "utf-8");
const ssrModule = await import("./dist/react-ssr/main-server.js");
render = ssrModule.render;
} else {
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
});

// HTML이 아닌 파일들에 대해서만 Vite 미들웨어 사용
app.use((req, res, next) => {
const url = req.originalUrl;
// HTML 요청이 아닌 경우만 Vite 미들웨어로 전달
if (url.includes(".") && !url.endsWith(".html")) {
return vite.middlewares(req, res, next);
}
next();
});

// 모든 HTML 요청에 대해 SSR 처리
app.use(async (req, res, next) => {
const url = req.originalUrl.replace(base, "/");

try {
template = fs.readFileSync(path.resolve(__dirname, "index.html"), "utf-8");
template = await vite.transformIndexHtml(url, template);

const { render } = await vite.ssrLoadModule("/src/main-server.tsx");

const query = req.query;
const { html, head, initialData } = await render(url, query);

const initialDataScript = initialData
? `<script>window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};</script>`
: "";

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

res.status(200).set({ "Content-Type": "text/html" }).send(finalHtml);
} catch (e) {
vite.ssrFixStacktrace(e);
console.error(e);
next(e);
}
});

app.listen(port, () => {
console.log(`React Dev SSR Server started at http://localhost:${port}`);
});
}

if (prod) {
app.use(base, express.static(path.resolve(__dirname, "dist/react"), { index: false }));

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

try {
const query = req.query;
const { html, head, initialData } = await render(url, query);

const initialDataScript = initialData
? `<script>window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};</script>`
: "";

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

res.status(200).set({ "Content-Type": "text/html" }).send(finalHtml);
} catch (e) {
console.error(e);
res.status(500).send("Internal Server Error");
}
});

app.listen(port, () => {
console.log(`React SSR Server started at http://localhost:${port}`);
});
}
10 changes: 6 additions & 4 deletions packages/react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { router, useCurrentPage } from "./router";
import { HomePage, NotFoundPage, ProductDetailPage } from "./pages";
import { useLoadCartStore } from "./entities";
import { ModalProvider, ToastProvider } from "./components";
import { QueryProvider } from "./contexts/QueryContext";

// 홈 페이지 (상품 목록)
router.addRoute("/", HomePage);
Expand All @@ -16,15 +17,16 @@ const CartInitializer = () => {
/**
* 전체 애플리케이션 렌더링
*/
export const App = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const App = ({ initialData }: { initialData?: any }) => {
const PageComponent = useCurrentPage();

return (
<>
<QueryProvider initialQuery={initialData?.query || {}}>
<ToastProvider>
<ModalProvider>{PageComponent ? <PageComponent /> : null}</ModalProvider>
<ModalProvider>{PageComponent ? <PageComponent initialData={initialData} /> : null}</ModalProvider>
</ToastProvider>
<CartInitializer />
</>
</QueryProvider>
);
};
Loading