@mandujs/core 0.23.0 → 0.25.0
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/package.json +3 -1
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -0
- package/src/bundler/build.ts +26 -3
- package/src/bundler/dev.ts +306 -4
- package/src/bundler/reverse-import-graph.ts +339 -0
- package/src/bundler/safe-build.test.ts +54 -0
- package/src/bundler/safe-build.ts +33 -7
- package/src/client/globals.ts +61 -44
- package/src/client/prefetch-helper.ts +55 -0
- package/src/client/router.ts +93 -15
- package/src/client/use-fetch.ts +243 -239
- package/src/config/mandu.ts +280 -172
- package/src/config/validate.ts +411 -357
- package/src/content/collection.ts +506 -0
- package/src/content/frontmatter.ts +189 -0
- package/src/content/generate-types.ts +168 -0
- package/src/content/index.ts +206 -168
- package/src/content/llms-txt.ts +196 -0
- package/src/content/prebuild.test.ts +249 -0
- package/src/content/prebuild.ts +400 -0
- package/src/content/schema.ts +20 -0
- package/src/content/sidebar.ts +212 -0
- package/src/content/slug.ts +110 -0
- package/src/guard/check.ts +5 -2
- package/src/observability/index.ts +37 -18
- package/src/observability/metrics.ts +334 -0
- package/src/runtime/adapter-bun.ts +64 -62
- package/src/runtime/index.ts +11 -0
- package/src/runtime/registry.ts +171 -0
- package/src/runtime/server.ts +214 -8
- package/src/runtime/ssr.ts +264 -8
- package/src/runtime/streaming-ssr.ts +100 -4
- package/src/utils/__tests__/lru-cache.test.ts +186 -0
- package/src/utils/lru-cache.ts +172 -75
package/src/client/router.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./window-state";
|
|
16
16
|
import { LRUCache } from "../utils/lru-cache";
|
|
17
17
|
import { LIMITS } from "../constants";
|
|
18
|
+
import { registerCacheSize } from "../observability/metrics";
|
|
18
19
|
|
|
19
20
|
// ========== Types ==========
|
|
20
21
|
|
|
@@ -158,6 +159,12 @@ interface CompiledPattern {
|
|
|
158
159
|
|
|
159
160
|
const patternCache = new LRUCache<string, CompiledPattern>(LIMITS.ROUTER_PATTERN_CACHE);
|
|
160
161
|
|
|
162
|
+
// Phase 17 — expose the cache size to the /_mandu/heap + /_mandu/metrics
|
|
163
|
+
// endpoints so long-running processes can detect runaway growth.
|
|
164
|
+
// The registration happens at module init — safe to call once per process
|
|
165
|
+
// because `registerCacheSize` replaces any prior reporter under the same key.
|
|
166
|
+
registerCacheSize("patternCache", () => patternCache.size);
|
|
167
|
+
|
|
161
168
|
/**
|
|
162
169
|
* 패턴을 정규식으로 컴파일
|
|
163
170
|
*/
|
|
@@ -435,40 +442,105 @@ export function getNavigationState(): NavigationState {
|
|
|
435
442
|
// ========== Link Click Handler ==========
|
|
436
443
|
|
|
437
444
|
/**
|
|
438
|
-
*
|
|
445
|
+
* Issue #193 — Link click handler (event delegation).
|
|
446
|
+
*
|
|
447
|
+
* Mandu v0.22+ reversed the default from opt-in to opt-out SPA navigation.
|
|
448
|
+
* Every internal same-origin `<a href="/...">` click is intercepted and
|
|
449
|
+
* routed through the client-side router unless one of the explicit escape
|
|
450
|
+
* hatches fires:
|
|
451
|
+
*
|
|
452
|
+
* - `data-no-spa` → per-link opt-out (always skip).
|
|
453
|
+
* - `<a>` without `href` → degenerate anchor, let the browser decide.
|
|
454
|
+
* - `href="#fragment"` → same-page anchor, browser handles scroll.
|
|
455
|
+
* - `href="mailto:"` / `tel:` / → non-http schemes the browser owns.
|
|
456
|
+
* `javascript:` / `data:` / …
|
|
457
|
+
* - `target="_blank" / "_top" / → any target other than `_self` means the
|
|
458
|
+
* "_parent" / "framename" user wants a new browsing context.
|
|
459
|
+
* - `download` attribute present → file download, never a navigation.
|
|
460
|
+
* - Modifier keys (Ctrl / Cmd / → browser shortcut for new tab, bookmark,
|
|
461
|
+
* Shift / Alt) save, or save-as.
|
|
462
|
+
* - Non-left click → middle-click opens a new tab, right-click
|
|
463
|
+
* opens context menu.
|
|
464
|
+
* - `event.defaultPrevented` → another listener already handled it.
|
|
465
|
+
* - Cross-origin href → full document navigation required.
|
|
466
|
+
*
|
|
467
|
+
* The legacy opt-in attribute `data-mandu-link` still works for
|
|
468
|
+
* backward compatibility — it is simply a no-op under the new default
|
|
469
|
+
* because we already intercept by default. Teams that want the old
|
|
470
|
+
* opt-in behavior back can set `spa: false` in `mandu.config.ts`, which
|
|
471
|
+
* surfaces as `window.__MANDU_SPA__ === false` and re-introduces the
|
|
472
|
+
* requirement that `<a>` tags carry `data-mandu-link`.
|
|
439
473
|
*/
|
|
440
474
|
function handleLinkClick(event: MouseEvent): void {
|
|
441
|
-
//
|
|
475
|
+
// Pre-filter: obvious browser-owned events.
|
|
442
476
|
if (
|
|
443
477
|
event.defaultPrevented ||
|
|
444
|
-
event.button !== 0 ||
|
|
445
|
-
event.metaKey ||
|
|
446
|
-
event.altKey ||
|
|
447
|
-
event.ctrlKey ||
|
|
448
|
-
event.shiftKey
|
|
478
|
+
event.button !== 0 || // middle-click / right-click — browser decides.
|
|
479
|
+
event.metaKey || // Cmd (macOS) — new tab.
|
|
480
|
+
event.altKey || // Alt — "save-as" in most browsers.
|
|
481
|
+
event.ctrlKey || // Ctrl (Windows/Linux) — new tab.
|
|
482
|
+
event.shiftKey // Shift — new window / bookmark.
|
|
449
483
|
) {
|
|
450
484
|
return;
|
|
451
485
|
}
|
|
452
486
|
|
|
453
|
-
//
|
|
454
|
-
|
|
487
|
+
// Find the closest anchor ancestor — users commonly nest `<span>` /
|
|
488
|
+
// `<img>` inside `<a>` and the event target is the inner element.
|
|
489
|
+
const anchor = (event.target as HTMLElement | null)?.closest("a");
|
|
455
490
|
if (!anchor) return;
|
|
456
491
|
|
|
457
|
-
//
|
|
458
|
-
if (
|
|
492
|
+
// Escape hatch 1: explicit per-link opt-out always wins.
|
|
493
|
+
if (anchor.hasAttribute("data-no-spa")) return;
|
|
494
|
+
|
|
495
|
+
// Escape hatch 2: global config `spa: false` — reverts to the legacy
|
|
496
|
+
// opt-in behavior (only `data-mandu-link` intercepts). SSR injects
|
|
497
|
+
// `window.__MANDU_SPA__ = false` when the user sets `spa: false`.
|
|
498
|
+
const spaGlobal = (globalThis as { window?: { __MANDU_SPA__?: boolean } }).window?.__MANDU_SPA__;
|
|
499
|
+
if (spaGlobal === false && !anchor.hasAttribute("data-mandu-link")) return;
|
|
459
500
|
|
|
501
|
+
// `<a>` without `href` is a degenerate anchor — the browser will not
|
|
502
|
+
// navigate, but a listener somewhere might. Don't intercept.
|
|
460
503
|
const href = anchor.getAttribute("href");
|
|
461
504
|
if (!href) return;
|
|
462
505
|
|
|
463
|
-
//
|
|
506
|
+
// Same-page fragment link — let the browser handle scroll / focus.
|
|
507
|
+
if (href.startsWith("#")) return;
|
|
508
|
+
|
|
509
|
+
// `target` other than `_self` (or absent) signals the user wants a
|
|
510
|
+
// new browsing context. `target="_blank"` is the common case but we
|
|
511
|
+
// also pass through `_top`, `_parent`, and framed targets.
|
|
512
|
+
const target = anchor.getAttribute("target");
|
|
513
|
+
if (target && target !== "_self") return;
|
|
514
|
+
|
|
515
|
+
// `download` attribute means the user wants to save the resource,
|
|
516
|
+
// never navigate to it.
|
|
517
|
+
if (anchor.hasAttribute("download")) return;
|
|
518
|
+
|
|
519
|
+
// URL parsing — catches both cross-origin and non-http schemes.
|
|
520
|
+
let url: URL;
|
|
464
521
|
try {
|
|
465
|
-
|
|
466
|
-
if (url.origin !== window.location.origin) return;
|
|
522
|
+
url = new URL(href, window.location.origin);
|
|
467
523
|
} catch {
|
|
524
|
+
// Malformed href — let the browser produce its own error.
|
|
468
525
|
return;
|
|
469
526
|
}
|
|
470
527
|
|
|
471
|
-
//
|
|
528
|
+
// Only same-origin http(s) navigations are eligible for SPA handling.
|
|
529
|
+
// `mailto:`, `tel:`, `javascript:`, `data:`, `blob:`, chrome-extension,
|
|
530
|
+
// etc. all fail this check because `new URL("mailto:foo@bar").origin`
|
|
531
|
+
// is the string `"null"` (spec-defined), never equal to
|
|
532
|
+
// `window.location.origin`.
|
|
533
|
+
if (url.origin !== window.location.origin) return;
|
|
534
|
+
|
|
535
|
+
// Only intercept http / https schemes. Defense-in-depth against any
|
|
536
|
+
// exotic same-origin scheme we haven't considered (e.g. a custom
|
|
537
|
+
// protocol handler installed by a browser extension).
|
|
538
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return;
|
|
539
|
+
|
|
540
|
+
// All clear — prevent the default full-page navigation and hand off
|
|
541
|
+
// to the client-side router. `href` preserves the user's original
|
|
542
|
+
// string (relative paths, fragments, query strings) so the router
|
|
543
|
+
// can normalize as needed.
|
|
472
544
|
event.preventDefault();
|
|
473
545
|
navigate(href);
|
|
474
546
|
}
|
|
@@ -828,3 +900,9 @@ if (typeof window !== "undefined") {
|
|
|
828
900
|
// can exercise the schema-check path directly without round-tripping
|
|
829
901
|
// through `window.__MANDU_ROUTER_REVALIDATE__`.
|
|
830
902
|
export { applyHDRUpdate as _testOnly_applyHDRUpdate };
|
|
903
|
+
|
|
904
|
+
// Issue #193: export the link-click handler for unit tests so we can
|
|
905
|
+
// drive every exclusion case without installing a real DOM click
|
|
906
|
+
// listener. Keeping this under a `_testOnly_` prefix to signal it is
|
|
907
|
+
// not part of the public API.
|
|
908
|
+
export { handleLinkClick as _testOnly_handleLinkClick };
|
package/src/client/use-fetch.ts
CHANGED
|
@@ -1,239 +1,243 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu useFetch Composable
|
|
3
|
-
* SSR 데이터 중복 방지 + pending/error 상태 + 클라이언트 캐시
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { useState, useEffect, useCallback, useRef } from "react";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** SSR
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
options.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
result = await response.
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}, []);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Mandu useFetch Composable
|
|
3
|
+
* SSR 데이터 중복 방지 + pending/error 상태 + 클라이언트 캐시
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
7
|
+
import { LRUCache } from "../utils/lru-cache";
|
|
8
|
+
import { registerCacheSize } from "../observability/metrics";
|
|
9
|
+
|
|
10
|
+
// ========== Types ==========
|
|
11
|
+
|
|
12
|
+
export interface UseFetchOptions<T = unknown> {
|
|
13
|
+
query?: Record<string, string | number>;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
method?: string;
|
|
16
|
+
body?: unknown;
|
|
17
|
+
/** SSR 데이터 있으면 클라이언트 fetch 생략 (기본: true) */
|
|
18
|
+
dedupe?: boolean;
|
|
19
|
+
/** SSR에서 전달된 초기 데이터 */
|
|
20
|
+
initialData?: T;
|
|
21
|
+
/** 캐시 유지 시간 (ms, 0이면 캐시 안 함) */
|
|
22
|
+
cacheTime?: number;
|
|
23
|
+
/** 자동 실행 여부 (기본: true) */
|
|
24
|
+
immediate?: boolean;
|
|
25
|
+
/** 응답 변환 함수 */
|
|
26
|
+
transform?: (data: unknown) => T;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseFetchReturn<T> {
|
|
30
|
+
data: T | null;
|
|
31
|
+
error: Error | null;
|
|
32
|
+
loading: boolean;
|
|
33
|
+
refresh: () => Promise<void>;
|
|
34
|
+
mutate: (updater: T | ((prev: T | null) => T)) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ========== Cache (LRU, 최대 200 엔트리) ==========
|
|
38
|
+
//
|
|
39
|
+
// Phase 17 — swapped hand-rolled Map for `LRUCache` so `get()` actually
|
|
40
|
+
// promotes the entry to most-recently-used (was FIFO before). Size is
|
|
41
|
+
// registered with the observability module for `/_mandu/heap` + the
|
|
42
|
+
// Prometheus `mandu_cache_entries{cache="fetchCache"}` series.
|
|
43
|
+
|
|
44
|
+
const MAX_CACHE_SIZE = 200;
|
|
45
|
+
|
|
46
|
+
interface CacheEntry { data: unknown; timestamp: number; }
|
|
47
|
+
const fetchCache = new LRUCache<string, CacheEntry>({ maxSize: MAX_CACHE_SIZE });
|
|
48
|
+
registerCacheSize("fetchCache", () => fetchCache.size);
|
|
49
|
+
|
|
50
|
+
function getCached(key: string, maxAge: number): unknown | undefined {
|
|
51
|
+
const entry = fetchCache.get(key);
|
|
52
|
+
if (!entry) return undefined;
|
|
53
|
+
if (Date.now() - entry.timestamp > maxAge) {
|
|
54
|
+
fetchCache.delete(key);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return entry.data;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setCache(key: string, data: unknown): void {
|
|
61
|
+
// LRUCache handles eviction — we just set.
|
|
62
|
+
fetchCache.set(key, { data, timestamp: Date.now() });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stableStringify(value: unknown): string {
|
|
66
|
+
if (value === undefined || value === null) return "";
|
|
67
|
+
if (typeof value !== "object") return String(value);
|
|
68
|
+
// 키를 정렬하여 삽입 순서에 무관한 안정적 직렬화
|
|
69
|
+
const obj = value as Record<string, unknown>;
|
|
70
|
+
const sorted: Record<string, unknown> = {};
|
|
71
|
+
for (const key of Object.keys(obj).sort()) {
|
|
72
|
+
sorted[key] = obj[key];
|
|
73
|
+
}
|
|
74
|
+
return JSON.stringify(sorted);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildFetchCacheKey(
|
|
78
|
+
url: string,
|
|
79
|
+
options: {
|
|
80
|
+
queryKey?: string;
|
|
81
|
+
method?: string;
|
|
82
|
+
headersKey?: string;
|
|
83
|
+
bodyKey?: string;
|
|
84
|
+
} = {}
|
|
85
|
+
): string {
|
|
86
|
+
return [
|
|
87
|
+
(options.method ?? "GET").toUpperCase(),
|
|
88
|
+
url,
|
|
89
|
+
options.queryKey ?? "",
|
|
90
|
+
options.headersKey ?? "",
|
|
91
|
+
options.bodyKey ?? "",
|
|
92
|
+
].join("::");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildUrl(url: string, query?: Record<string, string | number>): string {
|
|
96
|
+
if (!query || Object.keys(query).length === 0) return url;
|
|
97
|
+
const params = new URLSearchParams();
|
|
98
|
+
for (const [key, value] of Object.entries(query)) {
|
|
99
|
+
if (value !== undefined && value !== null) {
|
|
100
|
+
params.set(key, String(value));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return `${url}${url.includes("?") ? "&" : "?"}${params.toString()}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ========== Hook ==========
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 데이터 페칭 훅 — SSR 중복 방지, 캐싱, pending/error 상태 관리
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```tsx
|
|
113
|
+
* const { data, loading, error, refresh } = useFetch<Post[]>("/api/posts", {
|
|
114
|
+
* query: { page: 1 },
|
|
115
|
+
* cacheTime: 300_000,
|
|
116
|
+
* });
|
|
117
|
+
*
|
|
118
|
+
* const { data, mutate } = useFetch<Todo[]>("/api/todos");
|
|
119
|
+
* const addTodo = (todo: Todo) => mutate(prev => [...(prev ?? []), todo]);
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export function useFetch<T = unknown>(
|
|
123
|
+
url: string,
|
|
124
|
+
options?: UseFetchOptions<T>
|
|
125
|
+
): UseFetchReturn<T> {
|
|
126
|
+
const {
|
|
127
|
+
query,
|
|
128
|
+
headers,
|
|
129
|
+
method = "GET",
|
|
130
|
+
body,
|
|
131
|
+
dedupe = true,
|
|
132
|
+
initialData,
|
|
133
|
+
cacheTime = 0,
|
|
134
|
+
immediate = true,
|
|
135
|
+
transform,
|
|
136
|
+
} = options ?? {};
|
|
137
|
+
|
|
138
|
+
// 안정적 직렬화 — useRef로 이전 값과 비교하여 변경 시에만 갱신
|
|
139
|
+
const queryStr = stableStringify(query);
|
|
140
|
+
const headersStr = stableStringify(headers);
|
|
141
|
+
const bodyStr = stableStringify(body);
|
|
142
|
+
|
|
143
|
+
const prevQueryRef = useRef(queryStr);
|
|
144
|
+
const prevHeadersRef = useRef(headersStr);
|
|
145
|
+
const prevBodyRef = useRef(bodyStr);
|
|
146
|
+
|
|
147
|
+
const stableQuery = prevQueryRef.current === queryStr ? prevQueryRef.current : (prevQueryRef.current = queryStr);
|
|
148
|
+
const stableHeaders = prevHeadersRef.current === headersStr ? prevHeadersRef.current : (prevHeadersRef.current = headersStr);
|
|
149
|
+
const stableBody = prevBodyRef.current === bodyStr ? prevBodyRef.current : (prevBodyRef.current = bodyStr);
|
|
150
|
+
|
|
151
|
+
const cacheKey = buildFetchCacheKey(url, {
|
|
152
|
+
queryKey: stableQuery,
|
|
153
|
+
method,
|
|
154
|
+
headersKey: stableHeaders,
|
|
155
|
+
bodyKey: stableBody,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// URL/query 변경 감지
|
|
159
|
+
const prevCacheKeyRef = useRef(cacheKey);
|
|
160
|
+
|
|
161
|
+
const [data, setData] = useState<T | null>(() => {
|
|
162
|
+
if (initialData !== undefined) return initialData;
|
|
163
|
+
if (cacheTime > 0) {
|
|
164
|
+
const cached = getCached(cacheKey, cacheTime);
|
|
165
|
+
if (cached !== undefined) return (transform ? transform(cached) : cached) as T;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
});
|
|
169
|
+
const [error, setError] = useState<Error | null>(null);
|
|
170
|
+
const [loading, setLoading] = useState(!data && immediate);
|
|
171
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
172
|
+
|
|
173
|
+
// URL/query 변경 시 data 초기화 (useEffect로 React 규칙 준수)
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (prevCacheKeyRef.current === cacheKey) return;
|
|
176
|
+
prevCacheKeyRef.current = cacheKey;
|
|
177
|
+
|
|
178
|
+
if (cacheTime > 0) {
|
|
179
|
+
const cached = getCached(cacheKey, cacheTime);
|
|
180
|
+
if (cached !== undefined) {
|
|
181
|
+
setData((transform ? transform(cached) : cached) as T);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
setData(null);
|
|
186
|
+
}, [cacheKey, cacheTime, transform]);
|
|
187
|
+
|
|
188
|
+
const fetchData = useCallback(async () => {
|
|
189
|
+
abortRef.current?.abort();
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
abortRef.current = controller;
|
|
192
|
+
|
|
193
|
+
setLoading(true);
|
|
194
|
+
setError(null);
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const fetchUrl = buildUrl(url, query);
|
|
198
|
+
const response = await fetch(fetchUrl, {
|
|
199
|
+
method,
|
|
200
|
+
headers: { "Accept": "application/json", ...headers },
|
|
201
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
202
|
+
signal: controller.signal,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (controller.signal.aborted) return;
|
|
206
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
207
|
+
|
|
208
|
+
// Content-Type 확인 후 파싱
|
|
209
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
210
|
+
let result: unknown;
|
|
211
|
+
if (contentType.includes("application/json")) {
|
|
212
|
+
result = await response.json();
|
|
213
|
+
} else if (response.status === 204) {
|
|
214
|
+
result = null;
|
|
215
|
+
} else {
|
|
216
|
+
result = await response.text();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (transform) result = transform(result);
|
|
220
|
+
setData(result as T);
|
|
221
|
+
|
|
222
|
+
if (cacheTime > 0) setCache(cacheKey, result);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if (e instanceof DOMException && e.name === "AbortError") return;
|
|
225
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
226
|
+
} finally {
|
|
227
|
+
if (!controller.signal.aborted) setLoading(false);
|
|
228
|
+
}
|
|
229
|
+
}, [url, method, stableQuery, stableHeaders, stableBody, cacheTime, cacheKey, transform]);
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (!immediate) return;
|
|
233
|
+
if (data && dedupe) return;
|
|
234
|
+
fetchData();
|
|
235
|
+
return () => { abortRef.current?.abort(); };
|
|
236
|
+
}, [fetchData, immediate, dedupe]);
|
|
237
|
+
|
|
238
|
+
const mutate = useCallback((updater: T | ((prev: T | null) => T)) => {
|
|
239
|
+
setData(prev => typeof updater === "function" ? (updater as (prev: T | null) => T)(prev) : updater);
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
242
|
+
return { data, error, loading, refresh: fetchData, mutate };
|
|
243
|
+
}
|