Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
78c65f4
과제시작
xxziiko Aug 31, 2025
34cd63e
feat: Express SSR 서버 구현
xxziiko Sep 3, 2025
a84cede
feat: SSR 구현
xxziiko Sep 4, 2025
8dc7483
feat: ssg 구현 및 ssr 개선
xxziiko Sep 5, 2025
39f9feb
chore: import 경로 개선
xxziiko Sep 5, 2025
c64ab7e
ci 테스트를 위한 커밋
xxziiko Sep 5, 2025
bdb7664
fix: 잘못된 import 경로 수정
xxziiko Sep 5, 2025
65b7bc4
fix: 스토어 초기화 개선
xxziiko Sep 6, 2025
978251d
feat: React SSR 구현
xxziiko Sep 7, 2025
791e534
feat: Universal React Router 구현
xxziiko Sep 7, 2025
297e2e9
feat: React Hydration 구현
xxziiko Sep 7, 2025
de012bd
feat: SSR 서버 설정 및 Express 서버 구현
xxziiko Sep 7, 2025
a62772e
feat: Static Site Generation (SSG) 구현
xxziiko Sep 7, 2025
233e1eb
feat: 라우터 개선 및 설정 업데이트
xxziiko Sep 7, 2025
db6ee44
fix: 플레이스홀더 추가
xxziiko Sep 7, 2025
b269856
refactor: vanilla static-site-generate를 참고하여 개선
xxziiko Sep 7, 2025
06eb464
refactor: 구조개선
xxziiko Sep 7, 2025
ab18b2c
refactor: 목데이터 사용
xxziiko Sep 7, 2025
6406d45
fix: React SSR 안정성 개선 및 E2E 테스트 통과
xxziiko Sep 7, 2025
afd8e79
ci 테스트를 위한 커밋
xxziiko Sep 7, 2025
f56886d
fix: SSR 검색 필터 초기값 설정 문제 해결
xxziiko Sep 7, 2025
266711b
fix: SSR 검색 필터 초기값 설정 문제 해결
xxziiko Sep 8, 2025
7068262
fix: SSR 개선
xxziiko Sep 8, 2025
a10356f
fix: 라우팅 오류 개선
xxziiko Sep 8, 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
131 changes: 131 additions & 0 deletions packages/lib/src/BaseRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { createObserver } from "./createObserver";
import type { AnyFunction, StringRecord } from "./types";

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

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

export abstract class BaseRouter<Handler extends AnyFunction> {
readonly #routes: Map<string, Route<Handler>>;
readonly #observer = createObserver();
readonly #baseUrl: string;

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

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

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

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));
return "([^/]+)";
})
.replace(/\//g, "\\/");

// normalizedPath와 매칭하므로 baseUrl을 포함하지 않음
const regex = new RegExp(`^${regexPath}$`);

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

findRoute(url: string) {
try {
const { pathname } = new URL(url || "/", this.getOrigin());
const normalizedPath = this.#baseUrl ? pathname.replace(this.#baseUrl, "") || "/" : pathname;

for (const [routePath, route] of this.#routes) {
const match = normalizedPath.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;
} catch (error) {
console.error("❌ findRoute 에러:", error);
return null;
}
}

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

// 추상 메서드들 - 하위 클래스에서 구현
abstract get query(): StringRecord;
abstract set query(newQuery: QueryPayload);
abstract getCurrentUrl(): string;
abstract getOrigin(): string;
abstract start(): void;

static parseQuery = (search: string) => {
const params = new URLSearchParams(search);
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 = "", pathname = "", search = "") => {
const currentQuery = BaseRouter.parseQuery(search);
const updatedQuery = { ...currentQuery, ...newQuery };

Object.keys(updatedQuery).forEach((key) => {
if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") {
delete updatedQuery[key];
}
});

const queryString = BaseRouter.stringifyQuery(updatedQuery);
return `${baseUrl}${pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
};
}
171 changes: 40 additions & 131 deletions packages/lib/src/Router.ts
Original file line number Diff line number Diff line change
@@ -1,166 +1,75 @@
import { createObserver } from "./createObserver";
// packages/lib/src/Router.ts
import { BaseRouter } from "./BaseRouter";
import type { AnyFunction, StringRecord } from "./types";

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

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> {
readonly #routes: Map<string, Route<Handler>>;
readonly #observer = createObserver();
readonly #baseUrl;

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

export class Router<Handler extends AnyFunction> extends BaseRouter<Handler> {
constructor(baseUrl = "") {
this.#routes = new Map();
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);
}
});
super(baseUrl);

// 클라이언트 전용 이벤트 리스너
if (typeof window !== "undefined") {
window.addEventListener("popstate", () => {
this.updateRoute(this.getCurrentUrl());
});

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 {};
}
return BaseRouter.parseQuery(window.location.search);
}

set query(newQuery: QueryPayload) {
const newUrl = Router.getUrl(newQuery, this.#baseUrl);
const newUrl = BaseRouter.getUrl(newQuery, this.baseUrl, window.location.pathname, window.location.search);
this.push(newUrl);
}

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,
});
getCurrentUrl(): string {
if (typeof window === "undefined") {
return "/";
}
return `${window.location.pathname}${window.location.search}`;
}

#findRoute(url = window.location.pathname) {
const { pathname } = new URL(url, window.location.origin);
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,
};
}
getOrigin(): string {
if (typeof window === "undefined") {
return "http://localhost";
}
return null;
return window.location.origin;
}

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

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);
}

this.#route = this.#findRoute(fullUrl);
this.#observer.notify();
this.updateRoute(fullUrl);
} catch (error) {
console.error("라우터 네비게이션 오류:", error);
}
}

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

static parseQuery = (search = window.location.search) => {
const params = new URLSearchParams(search);
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 = Router.parseQuery();
const updatedQuery = { ...currentQuery, ...newQuery };

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

const queryString = Router.stringifyQuery(updatedQuery);
return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`;
};
}
42 changes: 42 additions & 0 deletions packages/lib/src/ServerRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { BaseRouter } from "./BaseRouter";
import type { AnyFunction, StringRecord } from "./types";

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

export class ServerRouter<Handler extends AnyFunction> extends BaseRouter<Handler> {
#currentUrl = "/";
#origin = "http://localhost";
#query: StringRecord = {};

constructor(baseUrl = "") {
super(baseUrl);
}

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

set query(newQuery: QueryPayload) {
this.#query = newQuery
? Object.fromEntries(Object.entries(newQuery).map(([key, value]) => [key, String(value ?? "")]))
: {};
}

getCurrentUrl(): string {
return this.#currentUrl;
}

getOrigin(): string {
return this.#origin;
}

setUrl(url: string, origin = "http://localhost") {
this.#currentUrl = url;
this.#origin = origin;
this.updateRoute(this.getCurrentUrl());
}

start(): void {
this.updateRoute(this.getCurrentUrl());
}
}
Loading