@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.
Files changed (91) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +291 -113
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/change/snapshot.ts +4 -23
  11. package/src/change/types.ts +2 -3
  12. package/src/client/Form.tsx +105 -0
  13. package/src/client/__tests__/use-sse.test.ts +153 -0
  14. package/src/client/hooks.ts +105 -6
  15. package/src/client/index.ts +35 -6
  16. package/src/client/router.ts +670 -433
  17. package/src/client/rpc.ts +140 -0
  18. package/src/client/runtime.ts +24 -21
  19. package/src/client/use-fetch.ts +239 -0
  20. package/src/client/use-head.ts +197 -0
  21. package/src/client/use-sse.ts +378 -0
  22. package/src/components/Image.tsx +162 -0
  23. package/src/config/mandu.ts +5 -0
  24. package/src/config/validate.ts +34 -0
  25. package/src/content/index.ts +5 -1
  26. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  27. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  28. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  29. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  30. package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
  31. package/src/devtools/client/components/panel/index.ts +45 -32
  32. package/src/devtools/client/components/panel/panel-container.tsx +332 -312
  33. package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
  34. package/src/devtools/client/state-manager.ts +535 -478
  35. package/src/devtools/design-tokens.ts +265 -264
  36. package/src/devtools/types.ts +345 -319
  37. package/src/filling/context.ts +65 -0
  38. package/src/filling/filling.ts +336 -14
  39. package/src/filling/index.ts +5 -1
  40. package/src/filling/session.ts +216 -0
  41. package/src/filling/ws.ts +78 -0
  42. package/src/generator/generate.ts +2 -2
  43. package/src/guard/auto-correct.ts +0 -29
  44. package/src/guard/check.ts +14 -31
  45. package/src/guard/presets/index.ts +296 -294
  46. package/src/guard/rules.ts +15 -19
  47. package/src/guard/validator.ts +834 -834
  48. package/src/index.ts +5 -1
  49. package/src/island/index.ts +373 -304
  50. package/src/kitchen/api/contract-api.ts +225 -0
  51. package/src/kitchen/api/diff-parser.ts +108 -0
  52. package/src/kitchen/api/file-api.ts +273 -0
  53. package/src/kitchen/api/guard-api.ts +83 -0
  54. package/src/kitchen/api/guard-decisions.ts +100 -0
  55. package/src/kitchen/api/routes-api.ts +50 -0
  56. package/src/kitchen/index.ts +21 -0
  57. package/src/kitchen/kitchen-handler.ts +256 -0
  58. package/src/kitchen/kitchen-ui.ts +1732 -0
  59. package/src/kitchen/stream/activity-sse.ts +145 -0
  60. package/src/kitchen/stream/file-tailer.ts +99 -0
  61. package/src/middleware/compress.ts +62 -0
  62. package/src/middleware/cors.ts +47 -0
  63. package/src/middleware/index.ts +10 -0
  64. package/src/middleware/jwt.ts +134 -0
  65. package/src/middleware/logger.ts +58 -0
  66. package/src/middleware/timeout.ts +55 -0
  67. package/src/paths.ts +0 -4
  68. package/src/plugins/hooks.ts +64 -0
  69. package/src/plugins/index.ts +3 -0
  70. package/src/plugins/types.ts +5 -0
  71. package/src/report/build.ts +0 -6
  72. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  73. package/src/router/fs-patterns.ts +11 -1
  74. package/src/router/fs-routes.ts +78 -14
  75. package/src/router/fs-scanner.ts +2 -2
  76. package/src/router/fs-types.ts +2 -1
  77. package/src/runtime/adapter-bun.ts +62 -0
  78. package/src/runtime/adapter.ts +47 -0
  79. package/src/runtime/cache.ts +310 -0
  80. package/src/runtime/handler.ts +65 -0
  81. package/src/runtime/image-handler.ts +195 -0
  82. package/src/runtime/index.ts +12 -0
  83. package/src/runtime/middleware.ts +263 -0
  84. package/src/runtime/server.ts +686 -92
  85. package/src/runtime/ssr.ts +55 -29
  86. package/src/runtime/streaming-ssr.ts +106 -82
  87. package/src/spec/index.ts +0 -1
  88. package/src/spec/schema.ts +1 -0
  89. package/src/testing/index.ts +144 -0
  90. package/src/watcher/watcher.ts +27 -1
  91. package/src/spec/lock.ts +0 -56
@@ -1,367 +1,390 @@
1
- /**
2
- * Mandu Kitchen DevTools - Network Proxy
3
- * @version 1.0.3
4
- *
5
- * Fetch/XHR 요청을 인터셉트하여 DevTools로 전달
6
- */
7
-
8
- import type { NetworkRequest, NetworkBodyPolicy } from '../../types';
9
- import { getOrCreateHook } from '../../hook';
10
- import { createNetworkRequestEvent, createNetworkResponseEvent, ALLOWED_HEADERS, BLOCKED_HEADERS } from '../../protocol';
11
-
12
- // ============================================================================
13
- // Types
14
- // ============================================================================
15
-
16
- interface NetworkProxyOptions {
17
- /** Network body 수집 정책 */
18
- bodyPolicy?: NetworkBodyPolicy;
19
- /** 무시할 URL 패턴 */
20
- ignorePatterns?: (string | RegExp)[];
21
- /** 최대 추적 요청 */
22
- maxTrackedRequests?: number;
23
- }
24
-
25
- // ============================================================================
26
- // Constants
27
- // ============================================================================
28
-
29
- const DEFAULT_OPTIONS: Required<NetworkProxyOptions> = {
30
- bodyPolicy: {
31
- collectBody: false,
32
- optInPolicy: {
33
- maxBytes: 10_000,
34
- applyPIIFilter: true,
35
- applySecretFilter: true,
36
- allowedContentTypes: ['application/json', 'text/plain', 'text/event-stream'],
37
- },
38
- },
39
- ignorePatterns: [
40
- // DevTools 자체 요청
41
- /__mandu/,
42
- // HMR
43
- /__vite/,
44
- /\.hot-update\./,
45
- // Source maps
46
- /\.map$/,
47
- // Chrome extensions
48
- /^chrome-extension:/,
49
- ],
50
- maxTrackedRequests: 200,
51
- };
52
-
53
- // ============================================================================
54
- // Helper Functions
55
- // ============================================================================
56
-
57
- let requestIdCounter = 0;
58
-
59
- function generateRequestId(): string {
60
- return `req-${Date.now()}-${++requestIdCounter}`;
61
- }
62
-
63
- function shouldIgnore(url: string, patterns: (string | RegExp)[]): boolean {
64
- for (const pattern of patterns) {
65
- if (typeof pattern === 'string') {
66
- if (url.includes(pattern)) return true;
67
- } else {
68
- if (pattern.test(url)) return true;
69
- }
70
- }
71
- return false;
72
- }
73
-
74
- function extractSafeHeaders(headers: Headers | Record<string, string>): {
75
- safeHeaders: Record<string, string>;
76
- redactedHeaders: string[];
77
- } {
78
- const safeHeaders: Record<string, string> = {};
79
- const redactedHeaders: string[] = [];
80
-
81
- const entries = headers instanceof Headers
82
- ? Array.from(headers.entries())
83
- : Object.entries(headers);
84
-
85
- for (const [key, value] of entries) {
86
- const lowerKey = key.toLowerCase();
87
-
88
- if (BLOCKED_HEADERS.has(lowerKey)) {
89
- redactedHeaders.push(key);
90
- } else if (ALLOWED_HEADERS.has(lowerKey)) {
91
- safeHeaders[key] = value;
92
- } else {
93
- // 커스텀 헤더: 키만 표시
94
- safeHeaders[key] = '[...]';
95
- }
96
- }
97
-
98
- return { safeHeaders, redactedHeaders };
99
- }
100
-
101
- function isStreamingResponse(contentType: string | null): boolean {
102
- if (!contentType) return false;
103
- return (
104
- contentType.includes('text/event-stream') ||
105
- contentType.includes('application/x-ndjson')
106
- );
107
- }
108
-
109
- // ============================================================================
110
- // Network Proxy Class
111
- // ============================================================================
112
-
113
- export class NetworkProxy {
114
- private options: Required<NetworkProxyOptions>;
115
- private isAttached = false;
116
- private originalFetch?: typeof fetch;
117
- private trackedRequests = new Map<string, NetworkRequest>();
118
-
119
- constructor(options?: NetworkProxyOptions) {
120
- this.options = {
121
- ...DEFAULT_OPTIONS,
122
- ...options,
123
- bodyPolicy: {
124
- ...DEFAULT_OPTIONS.bodyPolicy,
125
- ...options?.bodyPolicy,
126
- },
127
- };
128
- }
129
-
130
- // --------------------------------------------------------------------------
131
- // Lifecycle
132
- // --------------------------------------------------------------------------
133
-
134
- attach(): void {
135
- if (this.isAttached || typeof window === 'undefined') return;
136
-
137
- this.attachFetch();
138
- this.isAttached = true;
139
- }
140
-
141
- detach(): void {
142
- if (!this.isAttached || typeof window === 'undefined') return;
143
-
144
- if (this.originalFetch) {
145
- window.fetch = this.originalFetch;
146
- }
147
-
148
- this.trackedRequests.clear();
149
- this.isAttached = false;
150
- }
151
-
152
- // --------------------------------------------------------------------------
153
- // Fetch Interceptor
154
- // --------------------------------------------------------------------------
155
-
156
- private attachFetch(): void {
157
- this.originalFetch = window.fetch.bind(window);
158
- const self = this;
159
- const originalFetch = this.originalFetch;
160
-
161
- const interceptedFetch = async function(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
162
- const url = typeof input === 'string'
163
- ? input
164
- : input instanceof URL
165
- ? input.href
166
- : input.url;
167
-
168
- // 무시 패턴 체크
169
- if (shouldIgnore(url, self.options.ignorePatterns)) {
170
- return originalFetch(input, init);
171
- }
172
-
173
- const requestId = generateRequestId();
174
- const method = init?.method ?? 'GET';
175
- const headers = new Headers(init?.headers);
176
- const { safeHeaders, redactedHeaders } = extractSafeHeaders(headers);
177
-
178
- // 요청 시작 이벤트
179
- const request: Omit<NetworkRequest, 'id' | 'startTime'> = {
180
- method: method.toUpperCase(),
181
- url,
182
- safeHeaders,
183
- redactedHeaders,
184
- isStreaming: false,
185
- };
186
-
187
- const event = createNetworkRequestEvent(request);
188
- const trackedRequest = event.data as NetworkRequest;
189
- self.trackedRequests.set(requestId, trackedRequest);
190
-
191
- // 최대 추적 제한
192
- if (self.trackedRequests.size > self.options.maxTrackedRequests) {
193
- const firstKey = self.trackedRequests.keys().next().value;
194
- if (firstKey) self.trackedRequests.delete(firstKey);
195
- }
196
-
197
- getOrCreateHook().emit(event);
198
-
199
- try {
200
- const response = await originalFetch(input, init);
201
-
202
- // 스트리밍 여부 확인
203
- const contentType = response.headers.get('content-type');
204
- const isStreaming = isStreamingResponse(contentType);
205
-
206
- if (isStreaming) {
207
- // SSE/스트리밍 응답 처리
208
- return self.handleStreamingResponse(requestId, response);
209
- }
210
-
211
- // 일반 응답 완료 이벤트
212
- const responseEvent = createNetworkResponseEvent(requestId, response.status);
213
- getOrCreateHook().emit(responseEvent);
214
-
215
- return response;
216
- } catch (error) {
217
- // 네트워크 에러
218
- getOrCreateHook().emit({
219
- type: 'network:error',
220
- timestamp: Date.now(),
221
- data: {
222
- id: requestId,
223
- error: error instanceof Error ? error.message : String(error),
224
- },
225
- });
226
-
227
- throw error;
228
- }
229
- };
230
-
231
- // Bun's fetch type includes `preconnect`, use Object.assign to preserve it
232
- Object.assign(interceptedFetch, { preconnect: window.fetch.preconnect });
233
- window.fetch = interceptedFetch as typeof fetch;
234
- }
235
-
236
- // --------------------------------------------------------------------------
237
- // Streaming Response Handler
238
- // --------------------------------------------------------------------------
239
-
240
- private handleStreamingResponse(requestId: string, response: Response): Response {
241
- const trackedRequest = this.trackedRequests.get(requestId);
242
- if (trackedRequest) {
243
- trackedRequest.isStreaming = true;
244
- trackedRequest.chunkCount = 0;
245
- }
246
-
247
- // 응답 복제 (body를 두 번 읽기 위해)
248
- const clonedResponse = response.clone();
249
- const reader = clonedResponse.body?.getReader();
250
-
251
- if (reader) {
252
- this.trackStreamChunks(requestId, reader);
253
- }
254
-
255
- return response;
256
- }
257
-
258
- private async trackStreamChunks(
259
- requestId: string,
260
- reader: ReadableStreamDefaultReader<Uint8Array>
261
- ): Promise<void> {
262
- const trackedRequest = this.trackedRequests.get(requestId);
263
- let chunkIndex = 0;
264
-
265
- try {
266
- while (true) {
267
- const { done, value } = await reader.read();
268
-
269
- if (done) {
270
- // 스트림 완료
271
- const responseEvent = createNetworkResponseEvent(requestId, 200);
272
- getOrCreateHook().emit(responseEvent);
273
- break;
274
- }
275
-
276
- // Chunk 이벤트
277
- if (trackedRequest) {
278
- trackedRequest.chunkCount = (trackedRequest.chunkCount ?? 0) + 1;
279
- }
280
-
281
- getOrCreateHook().emit({
282
- type: 'network:chunk',
283
- timestamp: Date.now(),
284
- data: {
285
- id: requestId,
286
- chunkIndex: chunkIndex++,
287
- size: value?.length ?? 0,
288
- },
289
- });
290
- }
291
- } catch (error) {
292
- getOrCreateHook().emit({
293
- type: 'network:error',
294
- timestamp: Date.now(),
295
- data: {
296
- id: requestId,
297
- error: error instanceof Error ? error.message : String(error),
298
- },
299
- });
300
- }
301
- }
302
-
303
- // --------------------------------------------------------------------------
304
- // Manual Tracking
305
- // --------------------------------------------------------------------------
306
-
307
- /**
308
- * 수동으로 요청 추적 (외부 라이브러리 등)
309
- */
310
- trackRequest(
311
- method: string,
312
- url: string,
313
- headers?: Record<string, string>
314
- ): string {
315
- const requestId = generateRequestId();
316
- const { safeHeaders, redactedHeaders } = extractSafeHeaders(headers ?? {});
317
-
318
- const event = createNetworkRequestEvent({
319
- method: method.toUpperCase(),
320
- url,
321
- safeHeaders,
322
- redactedHeaders,
323
- isStreaming: false,
324
- });
325
-
326
- const trackedRequest = event.data as NetworkRequest;
327
- this.trackedRequests.set(requestId, trackedRequest);
328
- getOrCreateHook().emit(event);
329
-
330
- return requestId;
331
- }
332
-
333
- /**
334
- * 수동으로 응답 완료 알림
335
- */
336
- completeRequest(requestId: string, status: number): void {
337
- const event = createNetworkResponseEvent(requestId, status);
338
- getOrCreateHook().emit(event);
339
- this.trackedRequests.delete(requestId);
340
- }
341
- }
342
-
343
- // ============================================================================
344
- // Singleton Instance
345
- // ============================================================================
346
-
347
- let globalNetworkProxy: NetworkProxy | null = null;
348
-
349
- export function getNetworkProxy(options?: NetworkProxyOptions): NetworkProxy {
350
- if (!globalNetworkProxy) {
351
- globalNetworkProxy = new NetworkProxy(options);
352
- }
353
- return globalNetworkProxy;
354
- }
355
-
356
- export function initializeNetworkProxy(options?: NetworkProxyOptions): NetworkProxy {
357
- const proxy = getNetworkProxy(options);
358
- proxy.attach();
359
- return proxy;
360
- }
361
-
362
- export function destroyNetworkProxy(): void {
363
- if (globalNetworkProxy) {
364
- globalNetworkProxy.detach();
365
- globalNetworkProxy = null;
366
- }
367
- }
1
+ /**
2
+ * Mandu Kitchen DevTools - Network Proxy
3
+ * @version 1.0.4
4
+ *
5
+ * Fetch/XHR 요청을 인터셉트하여 DevTools로 전달
6
+ *
7
+ * FIXED (v1.0.4): Streaming response handling no longer clones the response.
8
+ * The previous approach used response.clone() + a parallel reader loop, which
9
+ * caused two critical issues:
10
+ * 1. Tee'd ReadableStream backpressure: clone() creates a tee — if one
11
+ * branch is read faster than the other, the browser buffers data
12
+ * internally, eventually stalling both streams.
13
+ * 2. Microtask starvation: the tight `while(true) { await reader.read() }`
14
+ * loop resolved as microtasks. With fast SSE token streams, the loop
15
+ * never yielded to the macrotask queue, blocking page.evaluate(),
16
+ * user interactions, and all macrotask-scheduled work — effectively
17
+ * freezing the main thread.
18
+ *
19
+ * New approach: wrap the original response body with a TransformStream that
20
+ * passthrough-observes each chunk without consuming or buffering it separately.
21
+ * The consumer (island component) drives the read pace; the proxy merely
22
+ * piggybacks on each chunk as it flows through.
23
+ */
24
+
25
+ import type { NetworkRequest, NetworkBodyPolicy } from '../../types';
26
+ import { getOrCreateHook } from '../../hook';
27
+ import { createNetworkRequestEvent, createNetworkResponseEvent, ALLOWED_HEADERS, BLOCKED_HEADERS } from '../../protocol';
28
+
29
+ // ============================================================================
30
+ // Types
31
+ // ============================================================================
32
+
33
+ interface NetworkProxyOptions {
34
+ /** Network body 수집 정책 */
35
+ bodyPolicy?: NetworkBodyPolicy;
36
+ /** 무시할 URL 패턴 */
37
+ ignorePatterns?: (string | RegExp)[];
38
+ /** 최대 추적 요청 수 */
39
+ maxTrackedRequests?: number;
40
+ }
41
+
42
+ // ============================================================================
43
+ // Constants
44
+ // ============================================================================
45
+
46
+ const DEFAULT_OPTIONS: Required<NetworkProxyOptions> = {
47
+ bodyPolicy: {
48
+ collectBody: false,
49
+ optInPolicy: {
50
+ maxBytes: 10_000,
51
+ applyPIIFilter: true,
52
+ applySecretFilter: true,
53
+ allowedContentTypes: ['application/json', 'text/plain', 'text/event-stream'],
54
+ },
55
+ },
56
+ ignorePatterns: [
57
+ // DevTools 자체 요청
58
+ /__mandu/,
59
+ // HMR
60
+ /__vite/,
61
+ /\.hot-update\./,
62
+ // Source maps
63
+ /\.map$/,
64
+ // Chrome extensions
65
+ /^chrome-extension:/,
66
+ ],
67
+ maxTrackedRequests: 200,
68
+ };
69
+
70
+ // ============================================================================
71
+ // Helper Functions
72
+ // ============================================================================
73
+
74
+ let requestIdCounter = 0;
75
+
76
+ function generateRequestId(): string {
77
+ return `req-${Date.now()}-${++requestIdCounter}`;
78
+ }
79
+
80
+ function shouldIgnore(url: string, patterns: (string | RegExp)[]): boolean {
81
+ for (const pattern of patterns) {
82
+ if (typeof pattern === 'string') {
83
+ if (url.includes(pattern)) return true;
84
+ } else {
85
+ if (pattern.test(url)) return true;
86
+ }
87
+ }
88
+ return false;
89
+ }
90
+
91
+ function extractSafeHeaders(headers: Headers | Record<string, string>): {
92
+ safeHeaders: Record<string, string>;
93
+ redactedHeaders: string[];
94
+ } {
95
+ const safeHeaders: Record<string, string> = {};
96
+ const redactedHeaders: string[] = [];
97
+
98
+ const entries = headers instanceof Headers
99
+ ? Array.from(headers.entries())
100
+ : Object.entries(headers);
101
+
102
+ for (const [key, value] of entries) {
103
+ const lowerKey = key.toLowerCase();
104
+
105
+ if (BLOCKED_HEADERS.has(lowerKey)) {
106
+ redactedHeaders.push(key);
107
+ } else if (ALLOWED_HEADERS.has(lowerKey)) {
108
+ safeHeaders[key] = value;
109
+ } else {
110
+ // 커스텀 헤더: 키만 표시
111
+ safeHeaders[key] = '[...]';
112
+ }
113
+ }
114
+
115
+ return { safeHeaders, redactedHeaders };
116
+ }
117
+
118
+ function isStreamingResponse(contentType: string | null): boolean {
119
+ if (!contentType) return false;
120
+ return (
121
+ contentType.includes('text/event-stream') ||
122
+ contentType.includes('application/x-ndjson')
123
+ );
124
+ }
125
+
126
+ // ============================================================================
127
+ // Network Proxy Class
128
+ // ============================================================================
129
+
130
+ export class NetworkProxy {
131
+ private options: Required<NetworkProxyOptions>;
132
+ private isAttached = false;
133
+ private originalFetch?: typeof fetch;
134
+ private trackedRequests = new Map<string, NetworkRequest>();
135
+
136
+ constructor(options?: NetworkProxyOptions) {
137
+ this.options = {
138
+ ...DEFAULT_OPTIONS,
139
+ ...options,
140
+ bodyPolicy: {
141
+ ...DEFAULT_OPTIONS.bodyPolicy,
142
+ ...options?.bodyPolicy,
143
+ },
144
+ };
145
+ }
146
+
147
+ // --------------------------------------------------------------------------
148
+ // Lifecycle
149
+ // --------------------------------------------------------------------------
150
+
151
+ attach(): void {
152
+ if (this.isAttached || typeof window === 'undefined') return;
153
+
154
+ this.attachFetch();
155
+ this.isAttached = true;
156
+ }
157
+
158
+ detach(): void {
159
+ if (!this.isAttached || typeof window === 'undefined') return;
160
+
161
+ if (this.originalFetch) {
162
+ window.fetch = this.originalFetch;
163
+ }
164
+
165
+ this.trackedRequests.clear();
166
+ this.isAttached = false;
167
+ }
168
+
169
+ // --------------------------------------------------------------------------
170
+ // Fetch Interceptor
171
+ // --------------------------------------------------------------------------
172
+
173
+ private attachFetch(): void {
174
+ this.originalFetch = window.fetch.bind(window);
175
+ const self = this;
176
+ const originalFetch = this.originalFetch;
177
+
178
+ const interceptedFetch = async function(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
179
+ const url = typeof input === 'string'
180
+ ? input
181
+ : input instanceof URL
182
+ ? input.href
183
+ : input.url;
184
+
185
+ // 무시 패턴 체크
186
+ if (shouldIgnore(url, self.options.ignorePatterns)) {
187
+ return originalFetch(input, init);
188
+ }
189
+
190
+ const requestId = generateRequestId();
191
+ const method = init?.method ?? 'GET';
192
+ const headers = new Headers(init?.headers);
193
+ const { safeHeaders, redactedHeaders } = extractSafeHeaders(headers);
194
+
195
+ // 요청 시작 이벤트
196
+ const request: Omit<NetworkRequest, 'id' | 'startTime'> = {
197
+ method: method.toUpperCase(),
198
+ url,
199
+ safeHeaders,
200
+ redactedHeaders,
201
+ isStreaming: false,
202
+ };
203
+
204
+ const event = createNetworkRequestEvent(request);
205
+ const trackedRequest = event.data as NetworkRequest;
206
+ self.trackedRequests.set(requestId, trackedRequest);
207
+
208
+ // 최대 추적 수 제한
209
+ if (self.trackedRequests.size > self.options.maxTrackedRequests) {
210
+ const firstKey = self.trackedRequests.keys().next().value;
211
+ if (firstKey) self.trackedRequests.delete(firstKey);
212
+ }
213
+
214
+ getOrCreateHook().emit(event);
215
+
216
+ try {
217
+ const response = await originalFetch(input, init);
218
+
219
+ // 스트리밍 여부 확인
220
+ const contentType = response.headers.get('content-type');
221
+ const isStreaming = isStreamingResponse(contentType);
222
+
223
+ if (isStreaming) {
224
+ // SSE/스트리밍 응답 처리
225
+ return self.handleStreamingResponse(requestId, response);
226
+ }
227
+
228
+ // 일반 응답 완료 이벤트
229
+ const responseEvent = createNetworkResponseEvent(requestId, response.status);
230
+ getOrCreateHook().emit(responseEvent);
231
+
232
+ return response;
233
+ } catch (error) {
234
+ // 네트워크 에러
235
+ getOrCreateHook().emit({
236
+ type: 'network:error',
237
+ timestamp: Date.now(),
238
+ data: {
239
+ id: requestId,
240
+ error: error instanceof Error ? error.message : String(error),
241
+ },
242
+ });
243
+
244
+ throw error;
245
+ }
246
+ };
247
+
248
+ // Bun's fetch type includes `preconnect`, use Object.assign to preserve it
249
+ Object.assign(interceptedFetch, { preconnect: window.fetch.preconnect });
250
+ window.fetch = interceptedFetch as typeof fetch;
251
+ }
252
+
253
+ // --------------------------------------------------------------------------
254
+ // Streaming Response Handler
255
+ // --------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Handle streaming (SSE/NDJSON) responses by wrapping the body with a
259
+ * passthrough TransformStream that observes chunks without cloning.
260
+ *
261
+ * This avoids:
262
+ * - response.clone() tee backpressure that stalls both streams
263
+ * - Separate reader loop that causes microtask starvation
264
+ *
265
+ * The consumer (island component) remains in full control of read pacing.
266
+ */
267
+ private handleStreamingResponse(requestId: string, response: Response): Response {
268
+ const trackedRequest = this.trackedRequests.get(requestId);
269
+ if (trackedRequest) {
270
+ trackedRequest.isStreaming = true;
271
+ trackedRequest.chunkCount = 0;
272
+ }
273
+
274
+ // If there is no body (e.g., 204), just emit the response event and return
275
+ if (!response.body) {
276
+ const responseEvent = createNetworkResponseEvent(requestId, response.status);
277
+ getOrCreateHook().emit(responseEvent);
278
+ return response;
279
+ }
280
+
281
+ let chunkIndex = 0;
282
+ const self = this;
283
+
284
+ // Create a passthrough TransformStream that observes each chunk as it
285
+ // flows from the network to the consumer. No cloning, no buffering.
286
+ const observerTransform = new TransformStream<Uint8Array, Uint8Array>({
287
+ transform(chunk, controller) {
288
+ // Pass the chunk through immediately — zero copy
289
+ controller.enqueue(chunk);
290
+
291
+ // Emit tracking event (lightweight, non-blocking)
292
+ if (trackedRequest) {
293
+ trackedRequest.chunkCount = (trackedRequest.chunkCount ?? 0) + 1;
294
+ }
295
+
296
+ getOrCreateHook().emit({
297
+ type: 'network:chunk',
298
+ timestamp: Date.now(),
299
+ data: {
300
+ id: requestId,
301
+ chunkIndex: chunkIndex++,
302
+ size: chunk?.length ?? 0,
303
+ },
304
+ });
305
+ },
306
+
307
+ flush() {
308
+ // Stream completed emit response event
309
+ const responseEvent = createNetworkResponseEvent(requestId, response.status);
310
+ getOrCreateHook().emit(responseEvent);
311
+ self.trackedRequests.delete(requestId);
312
+ },
313
+ });
314
+
315
+ // Pipe the original body through the observer
316
+ const observedBody = response.body.pipeThrough(observerTransform);
317
+
318
+ // Construct a new Response with the observed body but identical headers/status
319
+ return new Response(observedBody, {
320
+ status: response.status,
321
+ statusText: response.statusText,
322
+ headers: response.headers,
323
+ });
324
+ }
325
+
326
+ // --------------------------------------------------------------------------
327
+ // Manual Tracking
328
+ // --------------------------------------------------------------------------
329
+
330
+ /**
331
+ * 수동으로 요청 추적 (외부 라이브러리 등)
332
+ */
333
+ trackRequest(
334
+ method: string,
335
+ url: string,
336
+ headers?: Record<string, string>
337
+ ): string {
338
+ const requestId = generateRequestId();
339
+ const { safeHeaders, redactedHeaders } = extractSafeHeaders(headers ?? {});
340
+
341
+ const event = createNetworkRequestEvent({
342
+ method: method.toUpperCase(),
343
+ url,
344
+ safeHeaders,
345
+ redactedHeaders,
346
+ isStreaming: false,
347
+ });
348
+
349
+ const trackedRequest = event.data as NetworkRequest;
350
+ this.trackedRequests.set(requestId, trackedRequest);
351
+ getOrCreateHook().emit(event);
352
+
353
+ return requestId;
354
+ }
355
+
356
+ /**
357
+ * 수동으로 응답 완료 알림
358
+ */
359
+ completeRequest(requestId: string, status: number): void {
360
+ const event = createNetworkResponseEvent(requestId, status);
361
+ getOrCreateHook().emit(event);
362
+ this.trackedRequests.delete(requestId);
363
+ }
364
+ }
365
+
366
+ // ============================================================================
367
+ // Singleton Instance
368
+ // ============================================================================
369
+
370
+ let globalNetworkProxy: NetworkProxy | null = null;
371
+
372
+ export function getNetworkProxy(options?: NetworkProxyOptions): NetworkProxy {
373
+ if (!globalNetworkProxy) {
374
+ globalNetworkProxy = new NetworkProxy(options);
375
+ }
376
+ return globalNetworkProxy;
377
+ }
378
+
379
+ export function initializeNetworkProxy(options?: NetworkProxyOptions): NetworkProxy {
380
+ const proxy = getNetworkProxy(options);
381
+ proxy.attach();
382
+ return proxy;
383
+ }
384
+
385
+ export function destroyNetworkProxy(): void {
386
+ if (globalNetworkProxy) {
387
+ globalNetworkProxy.detach();
388
+ globalNetworkProxy = null;
389
+ }
390
+ }