Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
aea0bf0
feat: 개발 및 프로덕션 환경에 따른 서버 설정 추가
Sep 1, 2025
f2c8535
feat: 초기 데이터 스크립트 생성 방식 개선
Sep 1, 2025
dae4379
feat: 서버 라우터 및 데이터 프리페칭 기능 추가
Sep 1, 2025
8e36ae2
feat: 메모리 스토리지 생성 기능 추가
Sep 1, 2025
2cdc38a
feat: 클라이언트 및 서버 환경 구분 상수 추가 및 장바구니 스토리지 설정 개선
Sep 1, 2025
3e3eaea
feat: 라우터에 다중 경로 등록 기능 추가 및 서버 환경에 따른 라우터 인스턴스 생성 개선
Sep 1, 2025
557e010
fix: 상품 상세 route 수정
Sep 2, 2025
7c099b6
feat: 데이터 안전 직렬화 기능 추가
Sep 2, 2025
70cd8f9
feat: MSW 서버 설정 추가 및 데이터 직렬화 방식 개선
Sep 2, 2025
ba88a96
feat: 서버 라우터 개선 및 렌더링 로직 최적화
Sep 2, 2025
7208f23
feat: 서버 환경에 따른 API 기본 URL 설정 기능 추가
Sep 2, 2025
e16b5cd
feat: ServerRouter에 다중 경로 등록 기능 추가 및 404 핸들러 지원
Sep 2, 2025
8fbef4e
feat: API 경로 패턴 수정 및 JSON 데이터 임포트 방식 개선
Sep 2, 2025
aff9735
fix: ServerRouter 인스턴스 생성 시 경로 인자 수정
Sep 2, 2025
1b73c96
feat: withLifecycle에 SSR 지원 및 메타데이터 처리 기능 추가
Sep 2, 2025
289a5b4
feat: HomePage에 SSR 및 메타데이터 처리 기능 추가
Sep 2, 2025
242f895
feat: ProductDetailPage에 SSR 및 메타데이터 처리 기능 추가
Sep 2, 2025
e643beb
feat: 정적 사이트 생성 기능 추가 및 상품 상세 페이지 렌더링 로직 개선
Sep 3, 2025
5061b88
fix: 서버별 독립적인 전역상태를 위해 컨텍스트 구현
Sep 3, 2025
019c953
feat: 홈 페이지 및 상품 상세 페이지에 SSR 데이터 복원 및 유효성 검사 기능 추가
Sep 3, 2025
43adfc2
chore: pnpm-lock.yaml 및 package.json 업데이트 - esbuild 및 tsx 버전 추가, SSR …
Sep 3, 2025
685cf90
feat: 안전한 직렬화를 위한 safeSerialize 함수 추가
Sep 3, 2025
204d5e5
feat: MSW를 사용한 모의 서버 설정 추가
Sep 3, 2025
2999e7f
feat: 기존 서버.js 파일 삭제 및 TypeScript로 작성된 server.ts 파일 추가
Sep 3, 2025
ef21eab
fix: 요청 스코프 컨텍스트 생성 로직 간소화 및 주석 제거
Sep 3, 2025
daa27f1
feat: useSyncExternalStore 호출에 getServerSnapshot 옵션 추가
Sep 3, 2025
338556f
feat: isServer 분기 구현
Sep 3, 2025
471fe5d
feat: log 유틸에 서버인경우의 분기 추가
Sep 3, 2025
f79e039
chore: pnpm-lock.yaml 및 package.json 업데이트 - nodemon 및 기타 패키지 버전 추가
Sep 4, 2025
119c1d4
feat: 서버 사이드 프로퍼티를 처리하는 withServerSideProps 유틸리티 추가
Sep 4, 2025
ec8dad5
feat: API 핸들러의 경로를 와일드카드로 수정하여 유연성 향상
Sep 4, 2025
9f5a6d4
feat: log 유틸리티에서 클라이언트 및 서버 환경을 확인하는 상수 추가
Sep 4, 2025
c9b41b5
feat: 클라이언트 및 서버 환경을 확인하는 유틸리티 함수 추가
Sep 4, 2025
9e4bdbe
fix: HTML 템플릿에서 주석 처리된 app-data 섹션의 공백 제거 및 서버 코드에서 주석 형식 통일
Sep 4, 2025
d738f30
feat: API 호출 시 서버 환경에 따라 기본 URL 설정 기능 추가
Sep 4, 2025
e6bcae2
refactor: 클라이언트 및 서버 환경 확인 상수 제거 및 기본 URL 설정 유지
Sep 4, 2025
b12378e
feat: 서버 라우터 클래스 추가 및 클라이언트/서버 환경에 따른 라우팅 처리 로직 구현
Sep 4, 2025
50d0785
refactor: productUseCase.ts 파일 제거
Sep 4, 2025
cc7f41a
feat: 메모리 스토리지 구현 및 createStorage 함수의 스토리지 처리 로직 개선
Sep 4, 2025
d61f8ac
feat: 제품 관련 상태 관리 및 API 호출을 위한 ProductProvider 및 useProductStoreConte…
Sep 4, 2025
6384f80
refactor: productUseCase 내보내기 제거
Sep 4, 2025
f0a1874
feat: 초기 상태를 기반으로 하는 상품 스토어 생성 함수 추가
Sep 4, 2025
1e5634e
feat: SSR 하이드레이션 기능 추가 및 초기 데이터 처리 로직 개선
Sep 4, 2025
ba7d784
feat: RouterContext 및 useRouterContext 훅 추가로 라우터 상태 관리 기능 구현
Sep 4, 2025
9eccc73
refactor: 라우터 관련 코드 정리 및 홈 페이지 라우팅 제거
Sep 4, 2025
bf0e574
refactor: 라우터 구조 변경 및 SSR 렌더링 로직 개선
Sep 4, 2025
1316467
refactor: useRouterContext 훅을 사용하여 라우터 관련 코드 통합 및 상품 상태 관리 개선
Sep 4, 2025
5aaa3bc
refactor: RouterContext를 RouterProvider로 변경하고 ProductStoreContext.Pro…
Sep 4, 2025
949b607
feat: SSR 렌더링 로직에 ProductProvider 및 RouterProvider 추가로 상품 상태 관리 통합
Sep 4, 2025
2fa030a
refactor: withServerSideProps의 serverOptions 타입을 ServerConfig로 변경하여 S…
Sep 4, 2025
d04f378
feat: ProductDetailPage에 withServerSideProps를 적용하여 SSR을 통한 상품 상세 정보 및…
Sep 4, 2025
a279b95
feat: HomePage에 withServerSideProps를 적용하여 SSR을 통한 상품 및 카테고리 로딩 기능 추가
Sep 4, 2025
072fceb
feat: 정적 사이트 생성을 위한 SSR 페이지 렌더링 로직 추가 및 파일 시스템 관리 기능 개선
Sep 4, 2025
16d213c
fix: 초기 데이터 처리 로직 수정으로 SSR 렌더링 안정성 향상
Sep 4, 2025
12fc702
refactor: PageWithServer 인터페이스를 ServerConfig를 상속하도록 변경하여 코드 간결성 향상
Sep 4, 2025
55ed979
refactor: SSR 렌더링 로직 개선 및 초기 데이터 처리 조건 추가로 안정성 향상
Sep 4, 2025
5622019
fix: SSR 렌더링 시 파일 확장자를 .js로 변경하여 모듈 로딩 오류 수정
Sep 4, 2025
03243ff
feat: SSR 렌더링 시 쿼리 파라미터 설정 기능 추가
Sep 4, 2025
44face1
fix: React 서버 시작 시 로그 메시지에 base 경로 추가
Sep 5, 2025
1635f1e
refactor: 초기 데이터 처리 로직 제거 및 상품 로딩 함수 호출 방식 개선
Sep 5, 2025
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
112 changes: 78 additions & 34 deletions packages/lib/src/Router.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,88 @@
import type { FC } from "react";
import { createObserver } from "./createObserver";
import type { AnyFunction, StringRecord } from "./types";

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

const isClient = typeof window !== "undefined";

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

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

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class Router<Handler extends (...args: any[]) => any> {
export class Router<Handler extends FC> {
readonly #routes: Map<string, Route<Handler>>;
readonly #observer = createObserver();
readonly #baseUrl;
#serverQuery: StringRecord = {};

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

constructor(baseUrl = "") {
constructor(initRoutes: Record<string, Handler>, baseUrl = "") {
this.#routes = new Map();
this.#route = null;
this.#baseUrl = baseUrl.replace(/\/$/, "");

window.addEventListener("popstate", () => {
this.#route = this.#findRoute();
this.#observer.notify();
Object.entries(initRoutes).forEach(([path, page]) => {
this.addRoute(path, page);
});

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 (isClient) {
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 {
return Router.parseQuery(window.location.search);
if (typeof window !== "undefined") {
return Router.parseQuery(window.location.search);
}
return this.#serverQuery;
}

set query(newQuery: QueryPayload) {
const newUrl = Router.getUrl(newQuery, this.#baseUrl);
this.push(newUrl);
if (isClient) {
const newUrl = Router.getUrl(newQuery, this.#baseUrl);
this.push(newUrl);
} else {
this.#serverQuery = Object.entries(newQuery).reduce((acc, [key, value]) => {
if (value !== null && value !== undefined && value !== "") {
acc[key] = String(value);
return acc;
}
return acc;
}, {} as StringRecord);
}
}

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

set params(newParams: StringRecord) {
this.#route ??= {} as Route<Handler> & { params: StringRecord; path: string };
this.#route.params = newParams;
}

get route() {
return this.#route;
}
Expand All @@ -66,7 +93,18 @@ export class Router<Handler extends (...args: any[]) => any> {

readonly subscribe = this.#observer.subscribe;

addRoute(path: string, handler: Handler) {
addRoute<T>(path: string, handler: FC<T>) {
// * 경로 처리 (와일드카드)
if (path === "*") {
const regex = new RegExp(".*");
this.#routes.set(path, {
regex,
paramNames: [],
handler: handler as Handler,
});
return;
}

// 경로 패턴을 정규식으로 변환
const paramNames: string[] = [];
const regexPath = path
Expand All @@ -76,21 +114,25 @@ export class Router<Handler extends (...args: any[]) => any> {
})
.replace(/\//g, "\\/");

const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`);
const regex = isClient ? new RegExp(`^${this.#baseUrl}${regexPath}$`) : new RegExp(`^${regexPath}$`);

this.#routes.set(path, {
regex,
paramNames,
handler,
handler: handler as Handler,
});
}

#findRoute(url = window.location.pathname) {
const { pathname } = new URL(url, window.location.origin);
#findRoute(url?: string) {
const pathname = url
? new URL(url, "http://localhost").pathname
: typeof window !== "undefined"
? window.location.pathname
: "/";

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];
Expand All @@ -107,13 +149,13 @@ export class Router<Handler extends (...args: any[]) => any> {
}

push(url: string) {
if (!isClient) return;

try {
// baseUrl이 없으면 자동으로 붙여줌
const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url);

const prevFullUrl = `${window.location.pathname}${window.location.search}`;

// 히스토리 업데이트
if (prevFullUrl !== fullUrl) {
window.history.pushState(null, "", fullUrl);
}
Expand All @@ -125,13 +167,14 @@ export class Router<Handler extends (...args: any[]) => any> {
}
}

start() {
this.#route = this.#findRoute();
start(url?: string) {
this.#route = this.#findRoute(url);
this.#observer.notify();
}

static parseQuery = (search = window.location.search) => {
const params = new URLSearchParams(search);
static parseQuery = (search?: string) => {
const searchString = search || (isClient ? window.location.search : "");
const params = new URLSearchParams(searchString);
const query: StringRecord = {};
for (const [key, value] of params) {
query[key] = value;
Expand Down Expand Up @@ -161,6 +204,7 @@ export class Router<Handler extends (...args: any[]) => any> {
});

const queryString = Router.stringifyQuery(updatedQuery);
return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
const pathname = isClient ? window.location.pathname : "/";
return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
};
}
19 changes: 15 additions & 4 deletions packages/lib/src/createStorage.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
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 memoryStorage = (() => {
const map = new Map<string, string>();
return {
getItem: (k: string) => (map.has(k) ? map.get(k) : null),
setItem: (k: string, v: string) => map.set(k, v),
removeItem: (k: string) => map.delete(k),
};
})();

export const createStorage = <T>(key: string, storage?: Storage) => {
const storageImpl =
storage ?? (typeof window !== "undefined" && window?.localStorage ? window.localStorage : memoryStorage);
let data: T | null = JSON.parse(storageImpl.getItem(key) ?? "null");
const { subscribe, notify } = createObserver();

const get = () => data;

const set = (value: T) => {
try {
data = value;
storage.setItem(key, JSON.stringify(data));
storageImpl.setItem(key, JSON.stringify(data));
notify();
} catch (error) {
console.error(`Error setting storage item for key "${key}":`, error);
Expand All @@ -19,7 +30,7 @@ export const createStorage = <T>(key: string, storage = window.localStorage) =>
const reset = () => {
try {
data = null;
storage.removeItem(key);
storageImpl.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),
);
};
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);
};
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()),
);
};
47 changes: 24 additions & 23 deletions packages/react/index.html
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--app-head-->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/src/styles.css">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: "#3b82f6",
secondary: "#6b7280"
}
}
}
};
</script>
</head>
<body class="bg-gray-50">
<div id="root"><!--app-html--></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--app-head-->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/src/styles.css" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: "#3b82f6",
secondary: "#6b7280",
},
},
},
};
</script>
</head>
<body class="bg-gray-50">
<div id="root"><!--app-html--></div>
<!--app-data-->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
26 changes: 15 additions & 11 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"type": "module",
"scripts": {
"dev": "vite --port 5175",
"dev:ssr": "PORT=5176 node server.js",
"dev:ssr": "PORT=5176 nodemon --watch src --ext ts,tsx,js,json --exec \"tsx server.ts\"",
"build:client": "rm -rf ./dist/react && vite build --outDir ./dist/react && cp ./dist/react/index.html ./dist/react/404.html",
"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",
Expand All @@ -17,7 +17,7 @@
"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",
"preview:ssr": "PORT=4176 NODE_ENV=production node server.js",
"preview:ssr": "PORT=4176 NODE_ENV=production tsx server.ts",
"preview:ssr-with-build": "pnpm run build:without-ssg && pnpm run preview:ssr",
"preview:ssg": "NODE_ENV=production vite preview --outDir ../../dist/react --port 4179",
"preview:ssg-with-build": "pnpm run build:ssg && pnpm run preview:ssg",
Expand All @@ -27,30 +27,34 @@
"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/compression": "^1.8.1",
"@types/express": "^5.0.3",
"@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",
"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",
"nodemon": "^3.1.7",
"sirv": "^3.0.0",
"tsx": "^4.20.5",
"typescript": "^5.8.3",
"vite": "npm:rolldown-vite@latest",
"express": "^5.1.0",
"compression": "^1.7.5",
"sirv": "^3.0.0"
"vite": "npm:rolldown-vite@latest"
}
}
Loading