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
155 changes: 155 additions & 0 deletions packages/lib/src/MemoryRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { createObserver } from "./createObserver";
import type { AnyFunction, StringRecord } from "./types";

interface MemoryRoute<Handler extends AnyFunction> {
regex: RegExp;
paramNames: string[];
handler: Handler;
params?: StringRecord;
}

type QueryPayload = Record<string, string | number | undefined>;

export type MemoryRouterInstance<
T extends AnyFunction & {
loader?: (router: MemoryRouterInstance<T>) => Promise<{ data: unknown; title: string } | undefined>;
},
> = InstanceType<typeof MemoryRouter<T>>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class MemoryRouter<Handler extends (...args: any[]) => any> {
readonly #routes: Map<string, MemoryRoute<Handler>>;
readonly #observer = createObserver();
readonly #baseUrl;
#query;
#currentUrl;

#route: null | (MemoryRoute<Handler> & { params: StringRecord; path: string });

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

get query(): StringRecord {
return this.#query;
}

set query(newQuery: QueryPayload) {
this.#query = newQuery;
}

/**
* SSR용: 현재 URL 설정 및 쿼리 파싱
* @param {string} url - 요청 URL
*/
setUrl(url: string) {
this.#currentUrl = url;

// URL에서 쿼리 파라미터 추출
try {
const urlObj = new URL(url, "http://localhost");
this.#query = MemoryRouter.parseQuery(urlObj.search);
} catch {
this.#query = {};
}
}

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

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

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

readonly subscribe = this.#observer.subscribe;

addRoute(path: string, handler: Handler) {
// 경로 패턴을 정규식으로 변환
const paramNames: string[] = [];
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: string | null = null) {
const targetUrl = url ?? this.#currentUrl;
const { pathname } = new URL(targetUrl || "", "http://localhost");
for (const [routePath, route] of this.#routes) {
const match = pathname.match(route.regex);
if (match) {
// 매치된 파라미터들을 객체로 변환
const params: StringRecord = {};
route.paramNames.forEach((name, index) => {
params[name] = match[index + 1];
});

return {
...route,
params,
path: routePath,
};
}
}
return null;
}

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

static parseQuery = (search: string | null = null) => {
const searchString = search ?? "";
const params = new URLSearchParams(searchString);
const query: StringRecord = {};
for (const [key, value] of params) {
query[key] = value;
}
return query;
};

static stringifyQuery = (query: QueryPayload) => {
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: QueryPayload, baseUrl = "") => {
const currentQuery = MemoryRouter.parseQuery();
const updatedQuery = { ...currentQuery, ...newQuery };

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

const queryString = MemoryRouter.stringifyQuery(updatedQuery);
return `${baseUrl}${queryString ? `?${queryString}` : ""}` || "/";
};
}
16 changes: 12 additions & 4 deletions packages/lib/src/createStorage.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
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 dummyStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};

export const createStorage = <T>(key: string, storage = null) => {
const storageInstance = storage || (typeof window !== "undefined" ? window.localStorage : dummyStorage);

let data: T | null = JSON.parse(storageInstance.getItem(key) ?? "null");
const { subscribe, notify } = createObserver();

const get = () => data;

const set = (value: T) => {
try {
data = value;
storage.setItem(key, JSON.stringify(data));
storageInstance.setItem(key, JSON.stringify(data));
notify();
} catch (error) {
console.error(`Error setting storage item for key "${key}":`, error);
Expand All @@ -19,7 +27,7 @@ export const createStorage = <T>(key: string, storage = window.localStorage) =>
const reset = () => {
try {
data = null;
storage.removeItem(key);
storageInstance.removeItem(key);
notify();
} catch (error) {
console.error(`Error removing storage item for key "${key}":`, error);
Expand Down
6 changes: 5 additions & 1 deletion packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@ 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));
return useSyncExternalStore(
router.subscribe,
() => shallowSelector(router), // 클라이언트
() => shallowSelector(router), // 서버 (동일한 초기 상태)
);
};
6 changes: 5 additions & 1 deletion packages/lib/src/hooks/useStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@ 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, // 서버 (동일한 초기 상태)
);
};
6 changes: 5 additions & 1 deletion packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ 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()));
return useSyncExternalStore(
store.subscribe,
() => shallowSelector(store.getState()), // 클라이언트
() => shallowSelector(store.getState()), // 서버 (동일한 초기 상태)
);
};
1 change: 1 addition & 0 deletions packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./createObserver";
export * from "./createStorage";
export * from "./createStore";
export * from "./Router";
export * from "./MemoryRouter";
export { useStore, useStorage, useRouter, useAutoCallback } from "./hooks";
export * from "./equals";
export * from "./types";
23 changes: 12 additions & 11 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"build:client-for-ssg": "rm -rf ../../dist/react && vite build --outDir ../../dist/react",
"build:server": "vite build --outDir ./dist/react-ssr --ssr src/main-server.tsx",
"build:without-ssg": "pnpm run build:client && npm run build:server",
"build:ssg": "pnpm run build:client-for-ssg && node static-site-generate.js",
"build:ssg": "pnpm run build:client-for-ssg && tsx static-site-generate.ts",
"build": "npm run build:client && npm run build:server && npm run build:ssg",
"preview:csr": "vite preview --outDir ./dist/react --port 4175",
"preview:csr-with-build": "pnpm run build:client && pnpm run preview:csr",
Expand All @@ -28,31 +28,32 @@
"prettier:write": "prettier --write ./src"
},
"dependencies": {
"@hanghae-plus/lib": "workspace:*",
"react": "latest",
"react-dom": "latest",
"@hanghae-plus/lib": "workspace:*"
"react-dom": "latest"
},
"devDependencies": {
"@babel/core": "latest",
"@babel/plugin-transform-react-jsx": "latest",
"@eslint/js": "^9.16.0",
"@types/node": "^24.0.13",
"@types/react": "latest",
"@types/react-dom": "latest",
"@types/use-sync-external-store": "latest",
"@vitejs/plugin-react": "latest",
"@types/node": "^24.0.13",
"compression": "^1.7.5",
"concurrently": "latest",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"express": "^5.1.0",
"msw": "^2.10.2",
"prettier": "^3.4.2",
"typescript": "^5.8.3",
"vite": "npm:rolldown-vite@latest",
"express": "^5.1.0",
"compression": "^1.7.5",
"sirv": "^3.0.0",
"concurrently": "latest"
"tsx": "^4.21.0",
"typescript": "^5.8.3",
"vite": "npm:rolldown-vite@latest"
}
}
73 changes: 55 additions & 18 deletions packages/react/server.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,66 @@
import express from "express";
import { renderToString } from "react-dom/server";
import { createElement } from "react";
import fs from "fs";

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

// Cached production assets
const templateHtml = prod ? fs.readFileSync("./dist/react/index.html", "utf-8") : "";

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(),
);
// Add Vite or respective production middlewares
/** @type {import('vite').ViteDevServer | undefined} */
let vite;
if (!prod) {
const { createServer } = await import("vite");
vite = await createServer({
server: { middlewareMode: true },
appType: "custom",
});
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/react", { extensions: [] }));
}

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

/** @type {string} */
let template;
/** @type {import('./src/entry-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);

//entry point
render = (await vite.ssrLoadModule("./src/main-server.tsx")).render;
} else {
template = templateHtml;
render = (await import("./dist/react-ssr/main-server.js")).render;
}

const { head, html, initialDataScript } = await render(url);

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

res.status(200).set({ "Content-Type": "text/html" }).send(finalHtml);
} catch (e) {
vite?.ssrFixStacktrace(e);
console.log(e.stack);
res.status(500).end(e.stack);
}
});

// Start http server
Expand Down
11 changes: 11 additions & 0 deletions packages/react/src/api/getBaseUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const getBaseUrl = () => {
if (typeof window !== "undefined") {
return ""; // 브라우저: 상대 경로
}

const port = process.env.PORT || 5176;

return `http://localhost:${port}`; // SSR: 절대 경로 (MSW가 매칭)
};

export default getBaseUrl;
Loading