@mandujs/core 0.18.22 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +0 -14
- package/package.json +4 -1
- package/src/brain/architecture/analyzer.ts +4 -4
- package/src/brain/doctor/analyzer.ts +18 -14
- package/src/bundler/build.test.ts +127 -0
- package/src/bundler/build.ts +291 -113
- package/src/bundler/css.ts +20 -5
- package/src/bundler/dev.ts +55 -2
- package/src/bundler/prerender.ts +195 -0
- package/src/change/snapshot.ts +4 -23
- package/src/change/types.ts +2 -3
- package/src/client/Form.tsx +105 -0
- package/src/client/__tests__/use-sse.test.ts +153 -0
- package/src/client/hooks.ts +105 -6
- package/src/client/index.ts +35 -6
- package/src/client/router.ts +670 -433
- package/src/client/rpc.ts +140 -0
- package/src/client/runtime.ts +24 -21
- package/src/client/use-fetch.ts +239 -0
- package/src/client/use-head.ts +197 -0
- package/src/client/use-sse.ts +378 -0
- package/src/components/Image.tsx +162 -0
- package/src/config/mandu.ts +5 -0
- package/src/config/validate.ts +34 -0
- package/src/content/index.ts +5 -1
- package/src/devtools/client/catchers/error-catcher.ts +17 -0
- package/src/devtools/client/catchers/network-proxy.ts +390 -367
- package/src/devtools/client/components/kitchen-root.tsx +479 -467
- package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
- package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
- package/src/devtools/client/components/panel/index.ts +45 -32
- package/src/devtools/client/components/panel/panel-container.tsx +332 -312
- package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
- package/src/devtools/client/state-manager.ts +535 -478
- package/src/devtools/design-tokens.ts +265 -264
- package/src/devtools/types.ts +345 -319
- package/src/filling/context.ts +65 -0
- package/src/filling/filling.ts +336 -14
- package/src/filling/index.ts +5 -1
- package/src/filling/session.ts +216 -0
- package/src/filling/ws.ts +78 -0
- package/src/generator/generate.ts +2 -2
- package/src/guard/auto-correct.ts +0 -29
- package/src/guard/check.ts +14 -31
- package/src/guard/presets/index.ts +296 -294
- package/src/guard/rules.ts +15 -19
- package/src/guard/validator.ts +834 -834
- package/src/index.ts +5 -1
- package/src/island/index.ts +373 -304
- package/src/kitchen/api/contract-api.ts +225 -0
- package/src/kitchen/api/diff-parser.ts +108 -0
- package/src/kitchen/api/file-api.ts +273 -0
- package/src/kitchen/api/guard-api.ts +83 -0
- package/src/kitchen/api/guard-decisions.ts +100 -0
- package/src/kitchen/api/routes-api.ts +50 -0
- package/src/kitchen/index.ts +21 -0
- package/src/kitchen/kitchen-handler.ts +256 -0
- package/src/kitchen/kitchen-ui.ts +1732 -0
- package/src/kitchen/stream/activity-sse.ts +145 -0
- package/src/kitchen/stream/file-tailer.ts +99 -0
- package/src/middleware/compress.ts +62 -0
- package/src/middleware/cors.ts +47 -0
- package/src/middleware/index.ts +10 -0
- package/src/middleware/jwt.ts +134 -0
- package/src/middleware/logger.ts +58 -0
- package/src/middleware/timeout.ts +55 -0
- package/src/paths.ts +0 -4
- package/src/plugins/hooks.ts +64 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/types.ts +5 -0
- package/src/report/build.ts +0 -6
- package/src/resource/__tests__/backward-compat.test.ts +0 -1
- package/src/router/fs-patterns.ts +11 -1
- package/src/router/fs-routes.ts +78 -14
- package/src/router/fs-scanner.ts +2 -2
- package/src/router/fs-types.ts +2 -1
- package/src/runtime/adapter-bun.ts +62 -0
- package/src/runtime/adapter.ts +47 -0
- package/src/runtime/cache.ts +310 -0
- package/src/runtime/handler.ts +65 -0
- package/src/runtime/image-handler.ts +195 -0
- package/src/runtime/index.ts +12 -0
- package/src/runtime/middleware.ts +263 -0
- package/src/runtime/server.ts +686 -92
- package/src/runtime/ssr.ts +55 -29
- package/src/runtime/streaming-ssr.ts +106 -82
- package/src/spec/index.ts +0 -1
- package/src/spec/schema.ts +1 -0
- package/src/testing/index.ts +144 -0
- package/src/watcher/watcher.ts +27 -1
- package/src/spec/lock.ts +0 -56
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu RPC Client
|
|
3
|
+
* Contract 정의에서 타입 안전한 API 클라이언트 생성
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ========== Types ==========
|
|
7
|
+
|
|
8
|
+
/** RPC 클라이언트 에러 */
|
|
9
|
+
export class RpcError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
public readonly status: number,
|
|
12
|
+
public readonly body: unknown
|
|
13
|
+
) {
|
|
14
|
+
super(`API Error ${status}`);
|
|
15
|
+
this.name = "RpcError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RpcRequestOptions {
|
|
20
|
+
query?: Record<string, unknown>;
|
|
21
|
+
body?: unknown;
|
|
22
|
+
params?: Record<string, string>;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RpcClientOptions {
|
|
28
|
+
/** API base URL (기본: 현재 origin) */
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
/** 공통 헤더 */
|
|
31
|
+
headers?: Record<string, string>;
|
|
32
|
+
/** 커스텀 fetch (테스트용) */
|
|
33
|
+
fetch?: typeof globalThis.fetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ========== Implementation ==========
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Contract 기반 타입 안전 RPC 클라이언트 생성
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* import { createClient } from "@mandujs/core/client";
|
|
44
|
+
* import type todoContract from "../spec/contracts/api-todos.contract";
|
|
45
|
+
*
|
|
46
|
+
* const api = createClient<typeof todoContract>("/api/todos");
|
|
47
|
+
*
|
|
48
|
+
* // 타입 추론 동작
|
|
49
|
+
* const { todos } = await api.get({ query: { page: 2 } });
|
|
50
|
+
* const { id } = await api.post({ body: { title: "New" } });
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function createClient<TContract = unknown>(
|
|
54
|
+
path: string,
|
|
55
|
+
options?: RpcClientOptions
|
|
56
|
+
): RpcMethods {
|
|
57
|
+
const baseFetch = options?.fetch ?? globalThis.fetch;
|
|
58
|
+
const baseUrl = options?.baseUrl ?? "";
|
|
59
|
+
const baseHeaders = options?.headers ?? {};
|
|
60
|
+
|
|
61
|
+
function makeRequest(method: string) {
|
|
62
|
+
return async (input?: RpcRequestOptions): Promise<unknown> => {
|
|
63
|
+
const url = new URL(`${baseUrl}${path}`, typeof window !== "undefined" ? window.location.origin : "http://localhost");
|
|
64
|
+
|
|
65
|
+
// URL 파라미터 치환
|
|
66
|
+
if (input?.params) {
|
|
67
|
+
let resolvedPath = url.pathname;
|
|
68
|
+
for (const [key, value] of Object.entries(input.params)) {
|
|
69
|
+
resolvedPath = resolvedPath.replace(`:${key}`, encodeURIComponent(value));
|
|
70
|
+
}
|
|
71
|
+
// 미해결 파라미터 검출
|
|
72
|
+
if (resolvedPath.includes(":")) {
|
|
73
|
+
throw new RpcError(0, `Unresolved path params in "${resolvedPath}". Check your params object.`);
|
|
74
|
+
}
|
|
75
|
+
url.pathname = resolvedPath;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Query 파라미터
|
|
79
|
+
if (input?.query) {
|
|
80
|
+
for (const [key, value] of Object.entries(input.query)) {
|
|
81
|
+
if (value !== undefined && value !== null) {
|
|
82
|
+
url.searchParams.set(key, String(value));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const headers: Record<string, string> = {
|
|
88
|
+
...baseHeaders,
|
|
89
|
+
...input?.headers,
|
|
90
|
+
"Accept": "application/json",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const fetchOptions: RequestInit = {
|
|
94
|
+
method: method.toUpperCase(),
|
|
95
|
+
headers,
|
|
96
|
+
signal: input?.signal,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Body (GET/HEAD 제외)
|
|
100
|
+
if (input?.body && method !== "GET" && method !== "HEAD") {
|
|
101
|
+
fetchOptions.body = JSON.stringify(input.body);
|
|
102
|
+
headers["Content-Type"] = "application/json";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const response = await baseFetch(url.toString(), fetchOptions);
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
let body: unknown;
|
|
109
|
+
try {
|
|
110
|
+
body = await response.json();
|
|
111
|
+
} catch {
|
|
112
|
+
body = await response.text().catch(() => null);
|
|
113
|
+
}
|
|
114
|
+
throw new RpcError(response.status, body);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
118
|
+
if (contentType.includes("application/json")) {
|
|
119
|
+
return response.json();
|
|
120
|
+
}
|
|
121
|
+
return response.text();
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
get: makeRequest("GET"),
|
|
127
|
+
post: makeRequest("POST"),
|
|
128
|
+
put: makeRequest("PUT"),
|
|
129
|
+
patch: makeRequest("PATCH"),
|
|
130
|
+
delete: makeRequest("DELETE"),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface RpcMethods {
|
|
135
|
+
get: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
136
|
+
post: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
137
|
+
put: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
138
|
+
patch: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
139
|
+
delete: (input?: RpcRequestOptions) => Promise<unknown>;
|
|
140
|
+
}
|
package/src/client/runtime.ts
CHANGED
|
@@ -11,12 +11,13 @@ import { getHydratedRoots, getServerData as getGlobalServerData } from "./window
|
|
|
11
11
|
/**
|
|
12
12
|
* Hydration 상태 추적
|
|
13
13
|
*/
|
|
14
|
-
export interface HydrationState {
|
|
15
|
-
total: number;
|
|
16
|
-
hydrated: number;
|
|
17
|
-
failed: number;
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
export interface HydrationState {
|
|
15
|
+
total: number;
|
|
16
|
+
hydrated: number;
|
|
17
|
+
failed: number;
|
|
18
|
+
recoverableErrors: number;
|
|
19
|
+
pending: Set<string>;
|
|
20
|
+
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Hydration 우선순위
|
|
@@ -34,14 +35,15 @@ export function getServerData<T = unknown>(islandId: string): T | undefined {
|
|
|
34
35
|
/**
|
|
35
36
|
* Hydration 상태 조회 (DOM 기반)
|
|
36
37
|
*/
|
|
37
|
-
export function getHydrationState(): Readonly<HydrationState> {
|
|
38
|
-
if (typeof document === "undefined") {
|
|
39
|
-
return { total: 0, hydrated: 0, failed: 0, pending: new Set() };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const islands = document.querySelectorAll<HTMLElement>("[data-mandu-island]");
|
|
43
|
-
const hydrated = document.querySelectorAll<HTMLElement>("[data-mandu-hydrated]");
|
|
44
|
-
const failed = document.querySelectorAll<HTMLElement>("[data-mandu-error]");
|
|
38
|
+
export function getHydrationState(): Readonly<HydrationState> {
|
|
39
|
+
if (typeof document === "undefined") {
|
|
40
|
+
return { total: 0, hydrated: 0, failed: 0, recoverableErrors: 0, pending: new Set() };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const islands = document.querySelectorAll<HTMLElement>("[data-mandu-island]");
|
|
44
|
+
const hydrated = document.querySelectorAll<HTMLElement>("[data-mandu-hydrated]");
|
|
45
|
+
const failed = document.querySelectorAll<HTMLElement>("[data-mandu-error]");
|
|
46
|
+
const recoverableErrors = document.querySelectorAll<HTMLElement>("[data-mandu-recoverable-error]");
|
|
45
47
|
|
|
46
48
|
const pending = new Set<string>();
|
|
47
49
|
islands.forEach((el) => {
|
|
@@ -51,13 +53,14 @@ export function getHydrationState(): Readonly<HydrationState> {
|
|
|
51
53
|
}
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
return {
|
|
55
|
-
total: islands.length,
|
|
56
|
-
hydrated: hydrated.length,
|
|
57
|
-
failed: failed.length,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
56
|
+
return {
|
|
57
|
+
total: islands.length,
|
|
58
|
+
hydrated: hydrated.length,
|
|
59
|
+
failed: failed.length,
|
|
60
|
+
recoverableErrors: recoverableErrors.length,
|
|
61
|
+
pending,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
61
64
|
|
|
62
65
|
/**
|
|
63
66
|
* 특정 Island unmount
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu useFetch Composable
|
|
3
|
+
* SSR 데이터 중복 방지 + pending/error 상태 + 클라이언트 캐시
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
7
|
+
|
|
8
|
+
// ========== Types ==========
|
|
9
|
+
|
|
10
|
+
export interface UseFetchOptions<T = unknown> {
|
|
11
|
+
query?: Record<string, string | number>;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
method?: string;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
/** SSR 데이터 있으면 클라이언트 fetch 생략 (기본: true) */
|
|
16
|
+
dedupe?: boolean;
|
|
17
|
+
/** SSR에서 전달된 초기 데이터 */
|
|
18
|
+
initialData?: T;
|
|
19
|
+
/** 캐시 유지 시간 (ms, 0이면 캐시 안 함) */
|
|
20
|
+
cacheTime?: number;
|
|
21
|
+
/** 자동 실행 여부 (기본: true) */
|
|
22
|
+
immediate?: boolean;
|
|
23
|
+
/** 응답 변환 함수 */
|
|
24
|
+
transform?: (data: unknown) => T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseFetchReturn<T> {
|
|
28
|
+
data: T | null;
|
|
29
|
+
error: Error | null;
|
|
30
|
+
loading: boolean;
|
|
31
|
+
refresh: () => Promise<void>;
|
|
32
|
+
mutate: (updater: T | ((prev: T | null) => T)) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ========== Cache (LRU, 최대 200 엔트리) ==========
|
|
36
|
+
|
|
37
|
+
const MAX_CACHE_SIZE = 200;
|
|
38
|
+
|
|
39
|
+
interface CacheEntry { data: unknown; timestamp: number; }
|
|
40
|
+
const fetchCache = new Map<string, CacheEntry>();
|
|
41
|
+
|
|
42
|
+
function getCached(key: string, maxAge: number): unknown | undefined {
|
|
43
|
+
const entry = fetchCache.get(key);
|
|
44
|
+
if (!entry) return undefined;
|
|
45
|
+
if (Date.now() - entry.timestamp > maxAge) {
|
|
46
|
+
fetchCache.delete(key);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
return entry.data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setCache(key: string, data: unknown): void {
|
|
53
|
+
// LRU: 오래된 것부터 제거
|
|
54
|
+
if (fetchCache.size >= MAX_CACHE_SIZE) {
|
|
55
|
+
const oldest = fetchCache.keys().next().value;
|
|
56
|
+
if (oldest !== undefined) fetchCache.delete(oldest);
|
|
57
|
+
}
|
|
58
|
+
fetchCache.set(key, { data, timestamp: Date.now() });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stableStringify(value: unknown): string {
|
|
62
|
+
if (value === undefined || value === null) return "";
|
|
63
|
+
if (typeof value !== "object") return String(value);
|
|
64
|
+
// 키를 정렬하여 삽입 순서에 무관한 안정적 직렬화
|
|
65
|
+
const obj = value as Record<string, unknown>;
|
|
66
|
+
const sorted: Record<string, unknown> = {};
|
|
67
|
+
for (const key of Object.keys(obj).sort()) {
|
|
68
|
+
sorted[key] = obj[key];
|
|
69
|
+
}
|
|
70
|
+
return JSON.stringify(sorted);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildFetchCacheKey(
|
|
74
|
+
url: string,
|
|
75
|
+
options: {
|
|
76
|
+
queryKey?: string;
|
|
77
|
+
method?: string;
|
|
78
|
+
headersKey?: string;
|
|
79
|
+
bodyKey?: string;
|
|
80
|
+
} = {}
|
|
81
|
+
): string {
|
|
82
|
+
return [
|
|
83
|
+
(options.method ?? "GET").toUpperCase(),
|
|
84
|
+
url,
|
|
85
|
+
options.queryKey ?? "",
|
|
86
|
+
options.headersKey ?? "",
|
|
87
|
+
options.bodyKey ?? "",
|
|
88
|
+
].join("::");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildUrl(url: string, query?: Record<string, string | number>): string {
|
|
92
|
+
if (!query || Object.keys(query).length === 0) return url;
|
|
93
|
+
const params = new URLSearchParams();
|
|
94
|
+
for (const [key, value] of Object.entries(query)) {
|
|
95
|
+
if (value !== undefined && value !== null) {
|
|
96
|
+
params.set(key, String(value));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return `${url}${url.includes("?") ? "&" : "?"}${params.toString()}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ========== Hook ==========
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 데이터 페칭 훅 — SSR 중복 방지, 캐싱, pending/error 상태 관리
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```tsx
|
|
109
|
+
* const { data, loading, error, refresh } = useFetch<Post[]>("/api/posts", {
|
|
110
|
+
* query: { page: 1 },
|
|
111
|
+
* cacheTime: 300_000,
|
|
112
|
+
* });
|
|
113
|
+
*
|
|
114
|
+
* const { data, mutate } = useFetch<Todo[]>("/api/todos");
|
|
115
|
+
* const addTodo = (todo: Todo) => mutate(prev => [...(prev ?? []), todo]);
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export function useFetch<T = unknown>(
|
|
119
|
+
url: string,
|
|
120
|
+
options?: UseFetchOptions<T>
|
|
121
|
+
): UseFetchReturn<T> {
|
|
122
|
+
const {
|
|
123
|
+
query,
|
|
124
|
+
headers,
|
|
125
|
+
method = "GET",
|
|
126
|
+
body,
|
|
127
|
+
dedupe = true,
|
|
128
|
+
initialData,
|
|
129
|
+
cacheTime = 0,
|
|
130
|
+
immediate = true,
|
|
131
|
+
transform,
|
|
132
|
+
} = options ?? {};
|
|
133
|
+
|
|
134
|
+
// 안정적 직렬화 — useRef로 이전 값과 비교하여 변경 시에만 갱신
|
|
135
|
+
const queryStr = stableStringify(query);
|
|
136
|
+
const headersStr = stableStringify(headers);
|
|
137
|
+
const bodyStr = stableStringify(body);
|
|
138
|
+
|
|
139
|
+
const prevQueryRef = useRef(queryStr);
|
|
140
|
+
const prevHeadersRef = useRef(headersStr);
|
|
141
|
+
const prevBodyRef = useRef(bodyStr);
|
|
142
|
+
|
|
143
|
+
const stableQuery = prevQueryRef.current === queryStr ? prevQueryRef.current : (prevQueryRef.current = queryStr);
|
|
144
|
+
const stableHeaders = prevHeadersRef.current === headersStr ? prevHeadersRef.current : (prevHeadersRef.current = headersStr);
|
|
145
|
+
const stableBody = prevBodyRef.current === bodyStr ? prevBodyRef.current : (prevBodyRef.current = bodyStr);
|
|
146
|
+
|
|
147
|
+
const cacheKey = buildFetchCacheKey(url, {
|
|
148
|
+
queryKey: stableQuery,
|
|
149
|
+
method,
|
|
150
|
+
headersKey: stableHeaders,
|
|
151
|
+
bodyKey: stableBody,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// URL/query 변경 감지
|
|
155
|
+
const prevCacheKeyRef = useRef(cacheKey);
|
|
156
|
+
|
|
157
|
+
const [data, setData] = useState<T | null>(() => {
|
|
158
|
+
if (initialData !== undefined) return initialData;
|
|
159
|
+
if (cacheTime > 0) {
|
|
160
|
+
const cached = getCached(cacheKey, cacheTime);
|
|
161
|
+
if (cached !== undefined) return (transform ? transform(cached) : cached) as T;
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
});
|
|
165
|
+
const [error, setError] = useState<Error | null>(null);
|
|
166
|
+
const [loading, setLoading] = useState(!data && immediate);
|
|
167
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
168
|
+
|
|
169
|
+
// URL/query 변경 시 data 초기화 (useEffect로 React 규칙 준수)
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (prevCacheKeyRef.current === cacheKey) return;
|
|
172
|
+
prevCacheKeyRef.current = cacheKey;
|
|
173
|
+
|
|
174
|
+
if (cacheTime > 0) {
|
|
175
|
+
const cached = getCached(cacheKey, cacheTime);
|
|
176
|
+
if (cached !== undefined) {
|
|
177
|
+
setData((transform ? transform(cached) : cached) as T);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
setData(null);
|
|
182
|
+
}, [cacheKey, cacheTime, transform]);
|
|
183
|
+
|
|
184
|
+
const fetchData = useCallback(async () => {
|
|
185
|
+
abortRef.current?.abort();
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
abortRef.current = controller;
|
|
188
|
+
|
|
189
|
+
setLoading(true);
|
|
190
|
+
setError(null);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const fetchUrl = buildUrl(url, query);
|
|
194
|
+
const response = await fetch(fetchUrl, {
|
|
195
|
+
method,
|
|
196
|
+
headers: { "Accept": "application/json", ...headers },
|
|
197
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
198
|
+
signal: controller.signal,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (controller.signal.aborted) return;
|
|
202
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
203
|
+
|
|
204
|
+
// Content-Type 확인 후 파싱
|
|
205
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
206
|
+
let result: unknown;
|
|
207
|
+
if (contentType.includes("application/json")) {
|
|
208
|
+
result = await response.json();
|
|
209
|
+
} else if (response.status === 204) {
|
|
210
|
+
result = null;
|
|
211
|
+
} else {
|
|
212
|
+
result = await response.text();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (transform) result = transform(result);
|
|
216
|
+
setData(result as T);
|
|
217
|
+
|
|
218
|
+
if (cacheTime > 0) setCache(cacheKey, result);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
if (e instanceof DOMException && e.name === "AbortError") return;
|
|
221
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
222
|
+
} finally {
|
|
223
|
+
if (!controller.signal.aborted) setLoading(false);
|
|
224
|
+
}
|
|
225
|
+
}, [url, method, stableQuery, stableHeaders, stableBody, cacheTime, cacheKey, transform]);
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (!immediate) return;
|
|
229
|
+
if (data && dedupe) return;
|
|
230
|
+
fetchData();
|
|
231
|
+
return () => { abortRef.current?.abort(); };
|
|
232
|
+
}, [fetchData, immediate, dedupe]);
|
|
233
|
+
|
|
234
|
+
const mutate = useCallback((updater: T | ((prev: T | null) => T)) => {
|
|
235
|
+
setData(prev => typeof updater === "function" ? (updater as (prev: T | null) => T)(prev) : updater);
|
|
236
|
+
}, []);
|
|
237
|
+
|
|
238
|
+
return { data, error, loading, refresh: fetchData, mutate };
|
|
239
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu useHead / useSeoMeta
|
|
3
|
+
* SSR + 클라이언트에서 <head> 태그를 선언적으로 관리
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useRef } from "react";
|
|
7
|
+
|
|
8
|
+
// ========== Types ==========
|
|
9
|
+
|
|
10
|
+
export interface HeadTag {
|
|
11
|
+
tag: "title" | "meta" | "link" | "script" | "style";
|
|
12
|
+
attrs?: Record<string, string>;
|
|
13
|
+
children?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface HeadConfig {
|
|
17
|
+
title?: string;
|
|
18
|
+
meta?: Array<{ name?: string; property?: string; content: string; httpEquiv?: string }>;
|
|
19
|
+
link?: Array<{ rel: string; href: string; [key: string]: string }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SeoMetaConfig {
|
|
23
|
+
title?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
ogTitle?: string;
|
|
26
|
+
ogDescription?: string;
|
|
27
|
+
ogImage?: string;
|
|
28
|
+
ogUrl?: string;
|
|
29
|
+
ogType?: string;
|
|
30
|
+
twitterCard?: "summary" | "summary_large_image" | "app" | "player";
|
|
31
|
+
twitterTitle?: string;
|
|
32
|
+
twitterDescription?: string;
|
|
33
|
+
twitterImage?: string;
|
|
34
|
+
canonical?: string;
|
|
35
|
+
robots?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ========== SSR Head Collection ==========
|
|
39
|
+
|
|
40
|
+
/** SSR 렌더링 중 수집된 head 태그 */
|
|
41
|
+
let ssrHeadTags: string[] = [];
|
|
42
|
+
|
|
43
|
+
/** SSR 렌더링 전 초기화 */
|
|
44
|
+
export function resetSSRHead(): void {
|
|
45
|
+
ssrHeadTags = [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** SSR 렌더링 후 수집된 head 태그 반환 */
|
|
49
|
+
export function getSSRHeadTags(): string {
|
|
50
|
+
return ssrHeadTags.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pushSSRTag(html: string): void {
|
|
54
|
+
if (typeof window === "undefined") {
|
|
55
|
+
ssrHeadTags.push(html);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ========== Client-side DOM Management ==========
|
|
60
|
+
|
|
61
|
+
const managedElements = new Set<Element>();
|
|
62
|
+
|
|
63
|
+
function updateClientHead(tags: HeadTag[]): void {
|
|
64
|
+
if (typeof document === "undefined") return;
|
|
65
|
+
|
|
66
|
+
// 이전 managed 태그 제거
|
|
67
|
+
for (const el of managedElements) {
|
|
68
|
+
el.remove();
|
|
69
|
+
}
|
|
70
|
+
managedElements.clear();
|
|
71
|
+
|
|
72
|
+
// 새 태그 삽입
|
|
73
|
+
for (const tag of tags) {
|
|
74
|
+
if (tag.tag === "title") {
|
|
75
|
+
document.title = tag.children ?? "";
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const el = document.createElement(tag.tag);
|
|
80
|
+
if (tag.attrs) {
|
|
81
|
+
for (const [key, value] of Object.entries(tag.attrs)) {
|
|
82
|
+
el.setAttribute(key, value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (tag.children) {
|
|
86
|
+
el.textContent = tag.children;
|
|
87
|
+
}
|
|
88
|
+
el.setAttribute("data-mandu-head", "");
|
|
89
|
+
document.head.appendChild(el);
|
|
90
|
+
managedElements.add(el);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ========== Hooks ==========
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 선언적 <head> 태그 관리
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```tsx
|
|
101
|
+
* useHead({
|
|
102
|
+
* title: "My Page",
|
|
103
|
+
* meta: [{ name: "description", content: "Page description" }],
|
|
104
|
+
* link: [{ rel: "canonical", href: "https://example.com/page" }],
|
|
105
|
+
* });
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function useHead(config: HeadConfig): void {
|
|
109
|
+
const tags: HeadTag[] = [];
|
|
110
|
+
|
|
111
|
+
if (config.title) {
|
|
112
|
+
tags.push({ tag: "title", children: config.title });
|
|
113
|
+
// SSR: title은 별도 처리 (renderToHTML의 title 옵션 대신)
|
|
114
|
+
pushSSRTag(`<title>${escapeHtml(config.title)}</title>`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (config.meta) {
|
|
118
|
+
for (const meta of config.meta) {
|
|
119
|
+
const attrs: Record<string, string> = { content: meta.content };
|
|
120
|
+
if (meta.name) attrs.name = meta.name;
|
|
121
|
+
if (meta.property) attrs.property = meta.property;
|
|
122
|
+
if (meta.httpEquiv) attrs["http-equiv"] = meta.httpEquiv;
|
|
123
|
+
tags.push({ tag: "meta", attrs });
|
|
124
|
+
pushSSRTag(`<meta ${Object.entries(attrs).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (config.link) {
|
|
129
|
+
for (const link of config.link) {
|
|
130
|
+
tags.push({ tag: "link", attrs: link });
|
|
131
|
+
pushSSRTag(`<link ${Object.entries(link).map(([k, v]) => `${k}="${escapeHtml(v)}"`).join(" ")}>`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 클라이언트: DOM 업데이트
|
|
136
|
+
const prevTagsRef = useRef<HeadTag[]>([]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
prevTagsRef.current = tags;
|
|
140
|
+
updateClientHead(tags);
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
// unmount 시 정리
|
|
144
|
+
for (const el of managedElements) {
|
|
145
|
+
el.remove();
|
|
146
|
+
}
|
|
147
|
+
managedElements.clear();
|
|
148
|
+
};
|
|
149
|
+
}, [JSON.stringify(config)]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* SEO 메타 태그 전용 — 간편 API
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```tsx
|
|
157
|
+
* useSeoMeta({
|
|
158
|
+
* title: "Blog Post Title",
|
|
159
|
+
* description: "Post excerpt...",
|
|
160
|
+
* ogTitle: "Blog Post Title",
|
|
161
|
+
* ogImage: "/images/cover.jpg",
|
|
162
|
+
* twitterCard: "summary_large_image",
|
|
163
|
+
* });
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
export function useSeoMeta(config: SeoMetaConfig): void {
|
|
167
|
+
const headConfig: HeadConfig = {
|
|
168
|
+
meta: [],
|
|
169
|
+
link: [],
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (config.title) headConfig.title = config.title;
|
|
173
|
+
if (config.description) headConfig.meta!.push({ name: "description", content: config.description });
|
|
174
|
+
if (config.robots) headConfig.meta!.push({ name: "robots", content: config.robots });
|
|
175
|
+
|
|
176
|
+
// Open Graph
|
|
177
|
+
if (config.ogTitle) headConfig.meta!.push({ property: "og:title", content: config.ogTitle });
|
|
178
|
+
if (config.ogDescription) headConfig.meta!.push({ property: "og:description", content: config.ogDescription });
|
|
179
|
+
if (config.ogImage) headConfig.meta!.push({ property: "og:image", content: config.ogImage });
|
|
180
|
+
if (config.ogUrl) headConfig.meta!.push({ property: "og:url", content: config.ogUrl });
|
|
181
|
+
if (config.ogType) headConfig.meta!.push({ property: "og:type", content: config.ogType });
|
|
182
|
+
|
|
183
|
+
// Twitter
|
|
184
|
+
if (config.twitterCard) headConfig.meta!.push({ name: "twitter:card", content: config.twitterCard });
|
|
185
|
+
if (config.twitterTitle) headConfig.meta!.push({ name: "twitter:title", content: config.twitterTitle });
|
|
186
|
+
if (config.twitterDescription) headConfig.meta!.push({ name: "twitter:description", content: config.twitterDescription });
|
|
187
|
+
if (config.twitterImage) headConfig.meta!.push({ name: "twitter:image", content: config.twitterImage });
|
|
188
|
+
|
|
189
|
+
// Canonical
|
|
190
|
+
if (config.canonical) headConfig.link!.push({ rel: "canonical", href: config.canonical });
|
|
191
|
+
|
|
192
|
+
useHead(headConfig);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function escapeHtml(str: string): string {
|
|
196
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
197
|
+
}
|