@ricsam/isolate-fetch 0.0.1 → 0.1.1

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/src/index.ts ADDED
@@ -0,0 +1,2325 @@
1
+ import ivm from "isolated-vm";
2
+ import { setupCore, clearAllInstanceState } from "@ricsam/isolate-core";
3
+ import {
4
+ getStreamRegistryForContext,
5
+ startNativeStreamReader,
6
+ } from "./stream-state.ts";
7
+ import type { StreamStateRegistry } from "./stream-state.ts";
8
+
9
+ export { clearAllInstanceState };
10
+
11
+ export interface FetchOptions {
12
+ /** Handler for fetch requests from the isolate */
13
+ onFetch?: (request: Request) => Promise<Response>;
14
+ }
15
+
16
+ // ============================================================================
17
+ // Serve Types
18
+ // ============================================================================
19
+
20
+ export interface UpgradeRequest {
21
+ requested: true;
22
+ connectionId: string;
23
+ }
24
+
25
+ export interface WebSocketCommand {
26
+ type: "message" | "close";
27
+ connectionId: string;
28
+ data?: string | ArrayBuffer;
29
+ code?: number;
30
+ reason?: string;
31
+ }
32
+
33
+ interface ServeState {
34
+ pendingUpgrade: UpgradeRequest | null;
35
+ activeConnections: Map<string, { connectionId: string }>;
36
+ }
37
+
38
+ export interface DispatchRequestOptions {
39
+ /** Tick function to pump isolate timers - required for streaming responses */
40
+ tick?: () => Promise<void>;
41
+ }
42
+
43
+ export interface FetchHandle {
44
+ dispose(): void;
45
+ /** Dispatch an HTTP request to the isolate's serve() handler */
46
+ dispatchRequest(request: Request, options?: DispatchRequestOptions): Promise<Response>;
47
+ /** Check if isolate requested WebSocket upgrade */
48
+ getUpgradeRequest(): UpgradeRequest | null;
49
+ /** Dispatch WebSocket open event to isolate */
50
+ dispatchWebSocketOpen(connectionId: string): void;
51
+ /** Dispatch WebSocket message event to isolate */
52
+ dispatchWebSocketMessage(connectionId: string, message: string | ArrayBuffer): void;
53
+ /** Dispatch WebSocket close event to isolate */
54
+ dispatchWebSocketClose(connectionId: string, code: number, reason: string): void;
55
+ /** Dispatch WebSocket error event to isolate */
56
+ dispatchWebSocketError(connectionId: string, error: Error): void;
57
+ /** Register callback for WebSocket commands from isolate */
58
+ onWebSocketCommand(callback: (cmd: WebSocketCommand) => void): () => void;
59
+ /** Check if serve() has been called */
60
+ hasServeHandler(): boolean;
61
+ /** Check if there are active WebSocket connections */
62
+ hasActiveConnections(): boolean;
63
+ }
64
+
65
+ // ============================================================================
66
+ // Instance State Management
67
+ // ============================================================================
68
+
69
+ const instanceStateMap = new WeakMap<ivm.Context, Map<number, unknown>>();
70
+ let nextInstanceId = 1;
71
+
72
+ function getInstanceStateMapForContext(
73
+ context: ivm.Context
74
+ ): Map<number, unknown> {
75
+ let map = instanceStateMap.get(context);
76
+ if (!map) {
77
+ map = new Map();
78
+ instanceStateMap.set(context, map);
79
+ }
80
+ return map;
81
+ }
82
+
83
+ // ============================================================================
84
+ // State Types
85
+ // ============================================================================
86
+
87
+ interface ResponseState {
88
+ status: number;
89
+ statusText: string;
90
+ headers: [string, string][];
91
+ body: Uint8Array | null;
92
+ bodyUsed: boolean;
93
+ type: string;
94
+ url: string;
95
+ redirected: boolean;
96
+ streamId: number | null;
97
+ }
98
+
99
+ interface RequestState {
100
+ method: string;
101
+ url: string;
102
+ headers: [string, string][];
103
+ body: Uint8Array | null;
104
+ bodyUsed: boolean;
105
+ streamId: number | null;
106
+ mode: string;
107
+ credentials: string;
108
+ cache: string;
109
+ redirect: string;
110
+ referrer: string;
111
+ integrity: string;
112
+ }
113
+
114
+ // ============================================================================
115
+ // Headers Implementation (Pure JS)
116
+ // ============================================================================
117
+
118
+ const headersCode = `
119
+ (function() {
120
+ class Headers {
121
+ #headers = new Map(); // lowercase key -> [originalCase, values[]]
122
+
123
+ constructor(init) {
124
+ if (init instanceof Headers) {
125
+ init.forEach((value, key) => this.append(key, value));
126
+ } else if (Array.isArray(init)) {
127
+ for (const pair of init) {
128
+ if (Array.isArray(pair) && pair.length >= 2) {
129
+ this.append(pair[0], pair[1]);
130
+ }
131
+ }
132
+ } else if (init && typeof init === 'object') {
133
+ for (const [key, value] of Object.entries(init)) {
134
+ this.append(key, value);
135
+ }
136
+ }
137
+ }
138
+
139
+ append(name, value) {
140
+ const key = String(name).toLowerCase();
141
+ const valueStr = String(value);
142
+ const existing = this.#headers.get(key);
143
+ if (existing) {
144
+ existing[1].push(valueStr);
145
+ } else {
146
+ this.#headers.set(key, [String(name), [valueStr]]);
147
+ }
148
+ }
149
+
150
+ delete(name) {
151
+ this.#headers.delete(String(name).toLowerCase());
152
+ }
153
+
154
+ get(name) {
155
+ const entry = this.#headers.get(String(name).toLowerCase());
156
+ return entry ? entry[1].join(', ') : null;
157
+ }
158
+
159
+ getSetCookie() {
160
+ const entry = this.#headers.get('set-cookie');
161
+ return entry ? [...entry[1]] : [];
162
+ }
163
+
164
+ has(name) {
165
+ return this.#headers.has(String(name).toLowerCase());
166
+ }
167
+
168
+ set(name, value) {
169
+ const key = String(name).toLowerCase();
170
+ this.#headers.set(key, [String(name), [String(value)]]);
171
+ }
172
+
173
+ forEach(callback, thisArg) {
174
+ for (const [key, [originalName, values]] of this.#headers) {
175
+ callback.call(thisArg, values.join(', '), originalName, this);
176
+ }
177
+ }
178
+
179
+ *entries() {
180
+ for (const [key, [name, values]] of this.#headers) {
181
+ yield [name, values.join(', ')];
182
+ }
183
+ }
184
+
185
+ *keys() {
186
+ for (const [key, [name]] of this.#headers) {
187
+ yield name;
188
+ }
189
+ }
190
+
191
+ *values() {
192
+ for (const [key, [name, values]] of this.#headers) {
193
+ yield values.join(', ');
194
+ }
195
+ }
196
+
197
+ [Symbol.iterator]() {
198
+ return this.entries();
199
+ }
200
+ }
201
+
202
+ globalThis.Headers = Headers;
203
+ })();
204
+ `;
205
+
206
+ // ============================================================================
207
+ // FormData Implementation (Pure JS)
208
+ // ============================================================================
209
+
210
+ const formDataCode = `
211
+ (function() {
212
+ class FormData {
213
+ #entries = []; // Array of [name, value]
214
+
215
+ append(name, value, filename) {
216
+ let finalValue = value;
217
+ if (value instanceof Blob && !(value instanceof File)) {
218
+ if (filename !== undefined) {
219
+ finalValue = new File([value], String(filename), { type: value.type });
220
+ }
221
+ } else if (value instanceof File && filename !== undefined) {
222
+ finalValue = new File([value], String(filename), {
223
+ type: value.type,
224
+ lastModified: value.lastModified
225
+ });
226
+ }
227
+ this.#entries.push([String(name), finalValue]);
228
+ }
229
+
230
+ delete(name) {
231
+ const nameStr = String(name);
232
+ this.#entries = this.#entries.filter(([n]) => n !== nameStr);
233
+ }
234
+
235
+ get(name) {
236
+ const nameStr = String(name);
237
+ const entry = this.#entries.find(([n]) => n === nameStr);
238
+ return entry ? entry[1] : null;
239
+ }
240
+
241
+ getAll(name) {
242
+ const nameStr = String(name);
243
+ return this.#entries.filter(([n]) => n === nameStr).map(([, v]) => v);
244
+ }
245
+
246
+ has(name) {
247
+ return this.#entries.some(([n]) => n === String(name));
248
+ }
249
+
250
+ set(name, value, filename) {
251
+ const nameStr = String(name);
252
+ this.delete(nameStr);
253
+ this.append(nameStr, value, filename);
254
+ }
255
+
256
+ *entries() {
257
+ for (const [name, value] of this.#entries) {
258
+ yield [name, value];
259
+ }
260
+ }
261
+
262
+ *keys() {
263
+ for (const [name] of this.#entries) {
264
+ yield name;
265
+ }
266
+ }
267
+
268
+ *values() {
269
+ for (const [, value] of this.#entries) {
270
+ yield value;
271
+ }
272
+ }
273
+
274
+ forEach(callback, thisArg) {
275
+ for (const [name, value] of this.#entries) {
276
+ callback.call(thisArg, value, name, this);
277
+ }
278
+ }
279
+
280
+ [Symbol.iterator]() {
281
+ return this.entries();
282
+ }
283
+ }
284
+
285
+ globalThis.FormData = FormData;
286
+ })();
287
+ `;
288
+
289
+ // ============================================================================
290
+ // Multipart FormData Parsing/Serialization (Pure JS)
291
+ // ============================================================================
292
+
293
+ const multipartCode = `
294
+ (function() {
295
+ // Find byte sequence in Uint8Array
296
+ function findSequence(haystack, needle, start = 0) {
297
+ outer: for (let i = start; i <= haystack.length - needle.length; i++) {
298
+ for (let j = 0; j < needle.length; j++) {
299
+ if (haystack[i + j] !== needle[j]) continue outer;
300
+ }
301
+ return i;
302
+ }
303
+ return -1;
304
+ }
305
+
306
+ // Parse header lines into object
307
+ function parseHeaders(text) {
308
+ const headers = {};
309
+ for (const line of text.split(/\\r?\\n/)) {
310
+ const colonIdx = line.indexOf(':');
311
+ if (colonIdx > 0) {
312
+ const name = line.slice(0, colonIdx).trim().toLowerCase();
313
+ const value = line.slice(colonIdx + 1).trim();
314
+ headers[name] = value;
315
+ }
316
+ }
317
+ return headers;
318
+ }
319
+
320
+ // Parse multipart/form-data body into FormData
321
+ globalThis.__parseMultipartFormData = function(bodyBytes, contentType) {
322
+ const formData = new FormData();
323
+
324
+ // Extract boundary from Content-Type
325
+ const boundaryMatch = contentType.match(/boundary=([^;]+)/i);
326
+ if (!boundaryMatch) return formData;
327
+
328
+ const boundary = boundaryMatch[1].replace(/^["']|["']$/g, '');
329
+ const encoder = new TextEncoder();
330
+ const decoder = new TextDecoder();
331
+ const boundaryBytes = encoder.encode('--' + boundary);
332
+
333
+ // Find first boundary
334
+ let pos = findSequence(bodyBytes, boundaryBytes, 0);
335
+ if (pos === -1) return formData;
336
+ pos += boundaryBytes.length;
337
+
338
+ while (pos < bodyBytes.length) {
339
+ // Skip CRLF after boundary
340
+ if (bodyBytes[pos] === 0x0d && bodyBytes[pos + 1] === 0x0a) pos += 2;
341
+ else if (bodyBytes[pos] === 0x0a) pos += 1;
342
+
343
+ // Check for closing boundary (--)
344
+ if (bodyBytes[pos] === 0x2d && bodyBytes[pos + 1] === 0x2d) break;
345
+
346
+ // Find header/body separator (CRLFCRLF)
347
+ const crlfcrlf = encoder.encode('\\r\\n\\r\\n');
348
+ const headersEnd = findSequence(bodyBytes, crlfcrlf, pos);
349
+ if (headersEnd === -1) break;
350
+
351
+ // Parse headers
352
+ const headersText = decoder.decode(bodyBytes.slice(pos, headersEnd));
353
+ const headers = parseHeaders(headersText);
354
+ pos = headersEnd + 4;
355
+
356
+ // Find next boundary
357
+ const nextBoundary = findSequence(bodyBytes, boundaryBytes, pos);
358
+ if (nextBoundary === -1) break;
359
+
360
+ // Extract content (minus trailing CRLF)
361
+ let contentEnd = nextBoundary;
362
+ if (contentEnd > 0 && bodyBytes[contentEnd - 1] === 0x0a) contentEnd--;
363
+ if (contentEnd > 0 && bodyBytes[contentEnd - 1] === 0x0d) contentEnd--;
364
+ const content = bodyBytes.slice(pos, contentEnd);
365
+
366
+ // Parse Content-Disposition
367
+ const disposition = headers['content-disposition'] || '';
368
+ const nameMatch = disposition.match(/name="([^"]+)"/);
369
+ const filenameMatch = disposition.match(/filename="([^"]+)"/);
370
+
371
+ if (nameMatch) {
372
+ const name = nameMatch[1];
373
+ if (filenameMatch) {
374
+ const filename = filenameMatch[1];
375
+ const mimeType = headers['content-type'] || 'application/octet-stream';
376
+ const file = new File([content], filename, { type: mimeType });
377
+ formData.append(name, file);
378
+ } else {
379
+ formData.append(name, decoder.decode(content));
380
+ }
381
+ }
382
+
383
+ pos = nextBoundary + boundaryBytes.length;
384
+ }
385
+
386
+ return formData;
387
+ };
388
+
389
+ // Serialize FormData to multipart/form-data format
390
+ globalThis.__serializeFormData = function(formData) {
391
+ const boundary = '----FormDataBoundary' + Math.random().toString(36).slice(2) +
392
+ Math.random().toString(36).slice(2);
393
+ const encoder = new TextEncoder();
394
+ const parts = [];
395
+
396
+ for (const [name, value] of formData.entries()) {
397
+ if (value instanceof File) {
398
+ const header = [
399
+ '--' + boundary,
400
+ 'Content-Disposition: form-data; name="' + name + '"; filename="' + value.name + '"',
401
+ 'Content-Type: ' + (value.type || 'application/octet-stream'),
402
+ '',
403
+ ''
404
+ ].join('\\r\\n');
405
+ parts.push(encoder.encode(header));
406
+ // Use existing __Blob_bytes callback (File extends Blob)
407
+ parts.push(__Blob_bytes(value._getInstanceId()));
408
+ parts.push(encoder.encode('\\r\\n'));
409
+ } else if (value instanceof Blob) {
410
+ const header = [
411
+ '--' + boundary,
412
+ 'Content-Disposition: form-data; name="' + name + '"; filename="blob"',
413
+ 'Content-Type: ' + (value.type || 'application/octet-stream'),
414
+ '',
415
+ ''
416
+ ].join('\\r\\n');
417
+ parts.push(encoder.encode(header));
418
+ parts.push(__Blob_bytes(value._getInstanceId()));
419
+ parts.push(encoder.encode('\\r\\n'));
420
+ } else {
421
+ const header = [
422
+ '--' + boundary,
423
+ 'Content-Disposition: form-data; name="' + name + '"',
424
+ '',
425
+ ''
426
+ ].join('\\r\\n');
427
+ parts.push(encoder.encode(header));
428
+ parts.push(encoder.encode(String(value)));
429
+ parts.push(encoder.encode('\\r\\n'));
430
+ }
431
+ }
432
+
433
+ // Closing boundary
434
+ parts.push(encoder.encode('--' + boundary + '--\\r\\n'));
435
+
436
+ // Concatenate all parts
437
+ const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
438
+ const body = new Uint8Array(totalLength);
439
+ let offset = 0;
440
+ for (const part of parts) {
441
+ body.set(part, offset);
442
+ offset += part.length;
443
+ }
444
+
445
+ return {
446
+ body: body,
447
+ contentType: 'multipart/form-data; boundary=' + boundary
448
+ };
449
+ };
450
+ })();
451
+ `;
452
+
453
+ // ============================================================================
454
+ // Stream Callbacks (Host State)
455
+ // ============================================================================
456
+
457
+ function setupStreamCallbacks(
458
+ context: ivm.Context,
459
+ streamRegistry: StreamStateRegistry
460
+ ): void {
461
+ const global = context.global;
462
+
463
+ // Create stream (returns ID)
464
+ global.setSync(
465
+ "__Stream_create",
466
+ new ivm.Callback(() => {
467
+ return streamRegistry.create();
468
+ })
469
+ );
470
+
471
+ // Push chunk (sync) - receives number[] from isolate
472
+ global.setSync(
473
+ "__Stream_push",
474
+ new ivm.Callback((streamId: number, chunkArray: number[]) => {
475
+ const chunk = new Uint8Array(chunkArray);
476
+ return streamRegistry.push(streamId, chunk);
477
+ })
478
+ );
479
+
480
+ // Close stream (sync)
481
+ global.setSync(
482
+ "__Stream_close",
483
+ new ivm.Callback((streamId: number) => {
484
+ streamRegistry.close(streamId);
485
+ })
486
+ );
487
+
488
+ // Error stream (sync)
489
+ global.setSync(
490
+ "__Stream_error",
491
+ new ivm.Callback((streamId: number, message: string) => {
492
+ streamRegistry.error(streamId, new Error(message));
493
+ })
494
+ );
495
+
496
+ // Check backpressure (sync)
497
+ global.setSync(
498
+ "__Stream_isQueueFull",
499
+ new ivm.Callback((streamId: number) => {
500
+ return streamRegistry.isQueueFull(streamId);
501
+ })
502
+ );
503
+
504
+ // Pull chunk (async with applySyncPromise)
505
+ const pullRef = new ivm.Reference(async (streamId: number) => {
506
+ const result = await streamRegistry.pull(streamId);
507
+ if (result.done) {
508
+ return JSON.stringify({ done: true });
509
+ }
510
+ return JSON.stringify({ done: false, value: Array.from(result.value) });
511
+ });
512
+ global.setSync("__Stream_pull_ref", pullRef);
513
+ }
514
+
515
+ // ============================================================================
516
+ // Host-Backed ReadableStream (Isolate Code)
517
+ // ============================================================================
518
+
519
+ const hostBackedStreamCode = `
520
+ (function() {
521
+ const _streamIds = new WeakMap();
522
+
523
+ class HostBackedReadableStream {
524
+ constructor(streamId) {
525
+ if (streamId === undefined) {
526
+ streamId = __Stream_create();
527
+ }
528
+ _streamIds.set(this, streamId);
529
+ }
530
+
531
+ _getStreamId() {
532
+ return _streamIds.get(this);
533
+ }
534
+
535
+ getReader() {
536
+ const streamId = this._getStreamId();
537
+ let released = false;
538
+
539
+ return {
540
+ read: async () => {
541
+ if (released) {
542
+ throw new TypeError("Reader has been released");
543
+ }
544
+ const resultJson = __Stream_pull_ref.applySyncPromise(undefined, [streamId]);
545
+ const result = JSON.parse(resultJson);
546
+
547
+ if (result.done) {
548
+ return { done: true, value: undefined };
549
+ }
550
+ return { done: false, value: new Uint8Array(result.value) };
551
+ },
552
+
553
+ releaseLock: () => {
554
+ released = true;
555
+ },
556
+
557
+ get closed() {
558
+ return new Promise(() => {});
559
+ },
560
+
561
+ cancel: async (reason) => {
562
+ __Stream_error(streamId, String(reason || "cancelled"));
563
+ }
564
+ };
565
+ }
566
+
567
+ async cancel(reason) {
568
+ __Stream_error(this._getStreamId(), String(reason || "cancelled"));
569
+ }
570
+
571
+ get locked() {
572
+ return false;
573
+ }
574
+
575
+ // Static method to create from existing stream ID
576
+ static _fromStreamId(streamId) {
577
+ return new HostBackedReadableStream(streamId);
578
+ }
579
+ }
580
+
581
+ globalThis.HostBackedReadableStream = HostBackedReadableStream;
582
+ })();
583
+ `;
584
+
585
+ // ============================================================================
586
+ // Response Implementation (Host State + Isolate Class)
587
+ // ============================================================================
588
+
589
+ function setupResponse(
590
+ context: ivm.Context,
591
+ stateMap: Map<number, unknown>
592
+ ): void {
593
+ const global = context.global;
594
+
595
+ // Register host callbacks
596
+ global.setSync(
597
+ "__Response_construct",
598
+ new ivm.Callback(
599
+ (
600
+ bodyBytes: number[] | null,
601
+ status: number,
602
+ statusText: string,
603
+ headers: [string, string][]
604
+ ) => {
605
+ const instanceId = nextInstanceId++;
606
+ const body = bodyBytes ? new Uint8Array(bodyBytes) : null;
607
+ const state: ResponseState = {
608
+ status,
609
+ statusText,
610
+ headers,
611
+ body,
612
+ bodyUsed: false,
613
+ type: "default",
614
+ url: "",
615
+ redirected: false,
616
+ streamId: null,
617
+ };
618
+ stateMap.set(instanceId, state);
619
+ return instanceId;
620
+ }
621
+ )
622
+ );
623
+
624
+ // Streaming Response constructor - creates Response with stream ID but no buffered body
625
+ global.setSync(
626
+ "__Response_constructStreaming",
627
+ new ivm.Callback(
628
+ (
629
+ streamId: number,
630
+ status: number,
631
+ statusText: string,
632
+ headers: [string, string][]
633
+ ) => {
634
+ const instanceId = nextInstanceId++;
635
+ const state: ResponseState = {
636
+ status,
637
+ statusText,
638
+ headers,
639
+ body: null, // No buffered body - using stream
640
+ bodyUsed: false,
641
+ type: "default",
642
+ url: "",
643
+ redirected: false,
644
+ streamId, // Stream ID for body
645
+ };
646
+ stateMap.set(instanceId, state);
647
+ return instanceId;
648
+ }
649
+ )
650
+ );
651
+
652
+ global.setSync(
653
+ "__Response_constructFromFetch",
654
+ new ivm.Callback(
655
+ (
656
+ bodyBytes: number[] | null,
657
+ status: number,
658
+ statusText: string,
659
+ headers: [string, string][],
660
+ url: string,
661
+ redirected: boolean
662
+ ) => {
663
+ const instanceId = nextInstanceId++;
664
+ const body = bodyBytes ? new Uint8Array(bodyBytes) : null;
665
+ const state: ResponseState = {
666
+ status,
667
+ statusText,
668
+ headers,
669
+ body,
670
+ bodyUsed: false,
671
+ type: "default",
672
+ url,
673
+ redirected,
674
+ streamId: null,
675
+ };
676
+ stateMap.set(instanceId, state);
677
+ return instanceId;
678
+ }
679
+ )
680
+ );
681
+
682
+ global.setSync(
683
+ "__Response_get_status",
684
+ new ivm.Callback((instanceId: number) => {
685
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
686
+ return state?.status ?? 200;
687
+ })
688
+ );
689
+
690
+ global.setSync(
691
+ "__Response_get_statusText",
692
+ new ivm.Callback((instanceId: number) => {
693
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
694
+ return state?.statusText ?? "";
695
+ })
696
+ );
697
+
698
+ global.setSync(
699
+ "__Response_get_headers",
700
+ new ivm.Callback((instanceId: number) => {
701
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
702
+ return state?.headers ?? [];
703
+ })
704
+ );
705
+
706
+ global.setSync(
707
+ "__Response_get_bodyUsed",
708
+ new ivm.Callback((instanceId: number) => {
709
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
710
+ return state?.bodyUsed ?? false;
711
+ })
712
+ );
713
+
714
+ global.setSync(
715
+ "__Response_get_url",
716
+ new ivm.Callback((instanceId: number) => {
717
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
718
+ return state?.url ?? "";
719
+ })
720
+ );
721
+
722
+ global.setSync(
723
+ "__Response_get_redirected",
724
+ new ivm.Callback((instanceId: number) => {
725
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
726
+ return state?.redirected ?? false;
727
+ })
728
+ );
729
+
730
+ global.setSync(
731
+ "__Response_get_type",
732
+ new ivm.Callback((instanceId: number) => {
733
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
734
+ return state?.type ?? "default";
735
+ })
736
+ );
737
+
738
+ global.setSync(
739
+ "__Response_setType",
740
+ new ivm.Callback((instanceId: number, type: string) => {
741
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
742
+ if (state) {
743
+ state.type = type;
744
+ }
745
+ })
746
+ );
747
+
748
+ global.setSync(
749
+ "__Response_markBodyUsed",
750
+ new ivm.Callback((instanceId: number) => {
751
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
752
+ if (state) {
753
+ if (state.bodyUsed) {
754
+ throw new Error("[TypeError]Body has already been consumed");
755
+ }
756
+ state.bodyUsed = true;
757
+ }
758
+ })
759
+ );
760
+
761
+ global.setSync(
762
+ "__Response_text",
763
+ new ivm.Callback((instanceId: number) => {
764
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
765
+ if (!state || !state.body) return "";
766
+ return new TextDecoder().decode(state.body);
767
+ })
768
+ );
769
+
770
+ global.setSync(
771
+ "__Response_arrayBuffer",
772
+ new ivm.Callback((instanceId: number) => {
773
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
774
+ if (!state || !state.body) {
775
+ return new ivm.ExternalCopy(new ArrayBuffer(0)).copyInto();
776
+ }
777
+ return new ivm.ExternalCopy(state.body.buffer.slice(
778
+ state.body.byteOffset,
779
+ state.body.byteOffset + state.body.byteLength
780
+ )).copyInto();
781
+ })
782
+ );
783
+
784
+ global.setSync(
785
+ "__Response_clone",
786
+ new ivm.Callback((instanceId: number) => {
787
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
788
+ if (!state) {
789
+ throw new Error("[TypeError]Cannot clone invalid Response");
790
+ }
791
+ const newId = nextInstanceId++;
792
+ const newState: ResponseState = {
793
+ ...state,
794
+ body: state.body ? new Uint8Array(state.body) : null,
795
+ bodyUsed: false,
796
+ };
797
+ stateMap.set(newId, newState);
798
+ return newId;
799
+ })
800
+ );
801
+
802
+ global.setSync(
803
+ "__Response_getStreamId",
804
+ new ivm.Callback((instanceId: number) => {
805
+ const state = stateMap.get(instanceId) as ResponseState | undefined;
806
+ return state?.streamId ?? null;
807
+ })
808
+ );
809
+
810
+ // Inject Response class
811
+ const responseCode = `
812
+ (function() {
813
+ const _responseInstanceIds = new WeakMap();
814
+
815
+ function __decodeError(err) {
816
+ if (!(err instanceof Error)) return err;
817
+ const match = err.message.match(/^\\[(TypeError|RangeError|SyntaxError|ReferenceError|URIError|EvalError|Error)\\](.*)$/);
818
+ if (match) {
819
+ const ErrorType = globalThis[match[1]] || Error;
820
+ return new ErrorType(match[2]);
821
+ }
822
+ return err;
823
+ }
824
+
825
+ function __prepareBody(body) {
826
+ if (body === null || body === undefined) return null;
827
+ if (typeof body === 'string') {
828
+ const encoder = new TextEncoder();
829
+ return Array.from(encoder.encode(body));
830
+ }
831
+ if (body instanceof ArrayBuffer) {
832
+ return Array.from(new Uint8Array(body));
833
+ }
834
+ if (body instanceof Uint8Array) {
835
+ return Array.from(body);
836
+ }
837
+ if (ArrayBuffer.isView(body)) {
838
+ return Array.from(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
839
+ }
840
+ if (body instanceof Blob) {
841
+ // Mark as needing async Blob handling - will be read in constructor
842
+ return { __isBlob: true, blob: body };
843
+ }
844
+ // Handle ReadableStream (both native and host-backed)
845
+ if (body instanceof ReadableStream || body instanceof HostBackedReadableStream) {
846
+ return { __isStream: true, stream: body };
847
+ }
848
+ // Try to convert to string
849
+ return Array.from(new TextEncoder().encode(String(body)));
850
+ }
851
+
852
+ class Response {
853
+ #instanceId;
854
+ #headers;
855
+ #streamId = null;
856
+ #blobInitPromise = null; // For async Blob body initialization
857
+
858
+ constructor(body, init = {}) {
859
+ // Handle internal construction from instance ID
860
+ if (typeof body === 'number' && init === null) {
861
+ this.#instanceId = body;
862
+ this.#headers = new Headers(__Response_get_headers(body));
863
+ this.#streamId = __Response_getStreamId(body);
864
+ return;
865
+ }
866
+
867
+ const preparedBody = __prepareBody(body);
868
+
869
+ // Handle Blob body - create streaming response and push blob data
870
+ if (preparedBody && preparedBody.__isBlob) {
871
+ this.#streamId = __Stream_create();
872
+ const status = init.status ?? 200;
873
+ const statusText = init.statusText ?? '';
874
+ const headers = new Headers(init.headers);
875
+ const headersArray = Array.from(headers.entries());
876
+
877
+ this.#instanceId = __Response_constructStreaming(
878
+ this.#streamId,
879
+ status,
880
+ statusText,
881
+ headersArray
882
+ );
883
+ this.#headers = headers;
884
+
885
+ // Start async blob initialization and stream pumping
886
+ const streamId = this.#streamId;
887
+ const blob = preparedBody.blob;
888
+ this.#blobInitPromise = (async () => {
889
+ try {
890
+ const buffer = await blob.arrayBuffer();
891
+ __Stream_push(streamId, Array.from(new Uint8Array(buffer)));
892
+ __Stream_close(streamId);
893
+ } catch (error) {
894
+ __Stream_error(streamId, String(error));
895
+ }
896
+ })();
897
+ return;
898
+ }
899
+
900
+ // Handle streaming body
901
+ if (preparedBody && preparedBody.__isStream) {
902
+ this.#streamId = __Stream_create();
903
+ const status = init.status ?? 200;
904
+ const statusText = init.statusText ?? '';
905
+ const headers = new Headers(init.headers);
906
+ const headersArray = Array.from(headers.entries());
907
+
908
+ this.#instanceId = __Response_constructStreaming(
909
+ this.#streamId,
910
+ status,
911
+ statusText,
912
+ headersArray
913
+ );
914
+ this.#headers = headers;
915
+
916
+ // Start pumping the source stream to host queue (fire-and-forget)
917
+ this._startStreamPump(preparedBody.stream);
918
+ return;
919
+ }
920
+
921
+ // Existing buffered body handling
922
+ const bodyBytes = preparedBody;
923
+ const status = init.status ?? 200;
924
+ const statusText = init.statusText ?? '';
925
+ const headersInit = init.headers;
926
+ const headers = new Headers(headersInit);
927
+ const headersArray = Array.from(headers.entries());
928
+
929
+ this.#instanceId = __Response_construct(bodyBytes, status, statusText, headersArray);
930
+ this.#headers = headers;
931
+ }
932
+
933
+ async _startStreamPump(sourceStream) {
934
+ const streamId = this.#streamId;
935
+ try {
936
+ const reader = sourceStream.getReader();
937
+ while (true) {
938
+ // Check backpressure - wait if queue is full
939
+ while (__Stream_isQueueFull(streamId)) {
940
+ await new Promise(r => setTimeout(r, 1));
941
+ }
942
+
943
+ const { done, value } = await reader.read();
944
+ if (done) {
945
+ __Stream_close(streamId);
946
+ break;
947
+ }
948
+ if (value) {
949
+ __Stream_push(streamId, Array.from(value));
950
+ }
951
+ }
952
+ } catch (error) {
953
+ __Stream_error(streamId, String(error));
954
+ }
955
+ }
956
+
957
+ _getInstanceId() {
958
+ return this.#instanceId;
959
+ }
960
+
961
+ static _fromInstanceId(instanceId) {
962
+ return new Response(instanceId, null);
963
+ }
964
+
965
+ get status() {
966
+ return __Response_get_status(this.#instanceId);
967
+ }
968
+
969
+ get statusText() {
970
+ return __Response_get_statusText(this.#instanceId);
971
+ }
972
+
973
+ get ok() {
974
+ const status = this.status;
975
+ return status >= 200 && status < 300;
976
+ }
977
+
978
+ get headers() {
979
+ return this.#headers;
980
+ }
981
+
982
+ get bodyUsed() {
983
+ return __Response_get_bodyUsed(this.#instanceId);
984
+ }
985
+
986
+ get url() {
987
+ return __Response_get_url(this.#instanceId);
988
+ }
989
+
990
+ get redirected() {
991
+ return __Response_get_redirected(this.#instanceId);
992
+ }
993
+
994
+ get type() {
995
+ return __Response_get_type(this.#instanceId);
996
+ }
997
+
998
+ get body() {
999
+ const streamId = __Response_getStreamId(this.#instanceId);
1000
+ if (streamId !== null) {
1001
+ return HostBackedReadableStream._fromStreamId(streamId);
1002
+ }
1003
+
1004
+ // Fallback: create host-backed stream from buffered body
1005
+ const instanceId = this.#instanceId;
1006
+ const newStreamId = __Stream_create();
1007
+ const buffer = __Response_arrayBuffer(instanceId);
1008
+
1009
+ if (buffer.byteLength > 0) {
1010
+ __Stream_push(newStreamId, Array.from(new Uint8Array(buffer)));
1011
+ }
1012
+ __Stream_close(newStreamId);
1013
+
1014
+ return HostBackedReadableStream._fromStreamId(newStreamId);
1015
+ }
1016
+
1017
+ async text() {
1018
+ try {
1019
+ __Response_markBodyUsed(this.#instanceId);
1020
+ } catch (err) {
1021
+ throw __decodeError(err);
1022
+ }
1023
+ return __Response_text(this.#instanceId);
1024
+ }
1025
+
1026
+ async json() {
1027
+ const text = await this.text();
1028
+ return JSON.parse(text);
1029
+ }
1030
+
1031
+ async arrayBuffer() {
1032
+ try {
1033
+ __Response_markBodyUsed(this.#instanceId);
1034
+ } catch (err) {
1035
+ throw __decodeError(err);
1036
+ }
1037
+
1038
+ // For streaming responses (including Blob bodies), consume the stream
1039
+ if (this.#streamId !== null) {
1040
+ // Wait for blob init to complete if needed
1041
+ if (this.#blobInitPromise) {
1042
+ await this.#blobInitPromise;
1043
+ this.#blobInitPromise = null;
1044
+ }
1045
+
1046
+ const reader = this.body.getReader();
1047
+ const chunks = [];
1048
+ while (true) {
1049
+ const { done, value } = await reader.read();
1050
+ if (done) break;
1051
+ if (value) chunks.push(value);
1052
+ }
1053
+ // Concatenate all chunks
1054
+ const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
1055
+ const result = new Uint8Array(totalLength);
1056
+ let offset = 0;
1057
+ for (const chunk of chunks) {
1058
+ result.set(chunk, offset);
1059
+ offset += chunk.length;
1060
+ }
1061
+ return result.buffer;
1062
+ }
1063
+
1064
+ return __Response_arrayBuffer(this.#instanceId);
1065
+ }
1066
+
1067
+ async blob() {
1068
+ const buffer = await this.arrayBuffer();
1069
+ const contentType = this.headers.get('content-type') || '';
1070
+ return new Blob([buffer], { type: contentType });
1071
+ }
1072
+
1073
+ async formData() {
1074
+ const contentType = this.headers.get('content-type') || '';
1075
+
1076
+ // Parse multipart/form-data
1077
+ if (contentType.includes('multipart/form-data')) {
1078
+ const buffer = await this.arrayBuffer();
1079
+ return __parseMultipartFormData(new Uint8Array(buffer), contentType);
1080
+ }
1081
+
1082
+ // Parse application/x-www-form-urlencoded
1083
+ if (contentType.includes('application/x-www-form-urlencoded')) {
1084
+ const text = await this.text();
1085
+ const formData = new FormData();
1086
+ const params = new URLSearchParams(text);
1087
+ for (const [key, value] of params) {
1088
+ formData.append(key, value);
1089
+ }
1090
+ return formData;
1091
+ }
1092
+
1093
+ throw new TypeError('Unsupported content type for formData()');
1094
+ }
1095
+
1096
+ clone() {
1097
+ if (this.bodyUsed) {
1098
+ throw new TypeError('Cannot clone a Response that has already been used');
1099
+ }
1100
+ const newId = __Response_clone(this.#instanceId);
1101
+ const cloned = Response._fromInstanceId(newId);
1102
+ return cloned;
1103
+ }
1104
+
1105
+ static json(data, init = {}) {
1106
+ const body = JSON.stringify(data);
1107
+ const headers = new Headers(init.headers);
1108
+ if (!headers.has('content-type')) {
1109
+ headers.set('content-type', 'application/json');
1110
+ }
1111
+ return new Response(body, { ...init, headers });
1112
+ }
1113
+
1114
+ static redirect(url, status = 302) {
1115
+ if (![301, 302, 303, 307, 308].includes(status)) {
1116
+ throw new RangeError('Invalid redirect status code');
1117
+ }
1118
+ const headers = new Headers({ Location: String(url) });
1119
+ return new Response(null, { status, headers });
1120
+ }
1121
+
1122
+ static error() {
1123
+ const response = new Response(null, { status: 0, statusText: '' });
1124
+ __Response_setType(response._getInstanceId(), 'error');
1125
+ return response;
1126
+ }
1127
+ }
1128
+
1129
+ globalThis.Response = Response;
1130
+ })();
1131
+ `;
1132
+
1133
+ context.evalSync(responseCode);
1134
+ }
1135
+
1136
+ // ============================================================================
1137
+ // Request Implementation (Host State + Isolate Class)
1138
+ // ============================================================================
1139
+
1140
+ function setupRequest(
1141
+ context: ivm.Context,
1142
+ stateMap: Map<number, unknown>
1143
+ ): void {
1144
+ const global = context.global;
1145
+
1146
+ // Register host callbacks
1147
+ global.setSync(
1148
+ "__Request_construct",
1149
+ new ivm.Callback(
1150
+ (
1151
+ url: string,
1152
+ method: string,
1153
+ headers: [string, string][],
1154
+ bodyBytes: number[] | null,
1155
+ mode: string,
1156
+ credentials: string,
1157
+ cache: string,
1158
+ redirect: string,
1159
+ referrer: string,
1160
+ integrity: string
1161
+ ) => {
1162
+ const instanceId = nextInstanceId++;
1163
+ const body = bodyBytes ? new Uint8Array(bodyBytes) : null;
1164
+ const state: RequestState = {
1165
+ url,
1166
+ method,
1167
+ headers,
1168
+ body,
1169
+ bodyUsed: false,
1170
+ streamId: null,
1171
+ mode,
1172
+ credentials,
1173
+ cache,
1174
+ redirect,
1175
+ referrer,
1176
+ integrity,
1177
+ };
1178
+ stateMap.set(instanceId, state);
1179
+ return instanceId;
1180
+ }
1181
+ )
1182
+ );
1183
+
1184
+ global.setSync(
1185
+ "__Request_get_method",
1186
+ new ivm.Callback((instanceId: number) => {
1187
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1188
+ return state?.method ?? "GET";
1189
+ })
1190
+ );
1191
+
1192
+ global.setSync(
1193
+ "__Request_get_url",
1194
+ new ivm.Callback((instanceId: number) => {
1195
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1196
+ return state?.url ?? "";
1197
+ })
1198
+ );
1199
+
1200
+ global.setSync(
1201
+ "__Request_get_headers",
1202
+ new ivm.Callback((instanceId: number) => {
1203
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1204
+ return state?.headers ?? [];
1205
+ })
1206
+ );
1207
+
1208
+ global.setSync(
1209
+ "__Request_get_bodyUsed",
1210
+ new ivm.Callback((instanceId: number) => {
1211
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1212
+ return state?.bodyUsed ?? false;
1213
+ })
1214
+ );
1215
+
1216
+ global.setSync(
1217
+ "__Request_get_mode",
1218
+ new ivm.Callback((instanceId: number) => {
1219
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1220
+ return state?.mode ?? "cors";
1221
+ })
1222
+ );
1223
+
1224
+ global.setSync(
1225
+ "__Request_get_credentials",
1226
+ new ivm.Callback((instanceId: number) => {
1227
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1228
+ return state?.credentials ?? "same-origin";
1229
+ })
1230
+ );
1231
+
1232
+ global.setSync(
1233
+ "__Request_get_cache",
1234
+ new ivm.Callback((instanceId: number) => {
1235
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1236
+ return state?.cache ?? "default";
1237
+ })
1238
+ );
1239
+
1240
+ global.setSync(
1241
+ "__Request_get_redirect",
1242
+ new ivm.Callback((instanceId: number) => {
1243
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1244
+ return state?.redirect ?? "follow";
1245
+ })
1246
+ );
1247
+
1248
+ global.setSync(
1249
+ "__Request_get_referrer",
1250
+ new ivm.Callback((instanceId: number) => {
1251
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1252
+ return state?.referrer ?? "about:client";
1253
+ })
1254
+ );
1255
+
1256
+ global.setSync(
1257
+ "__Request_get_integrity",
1258
+ new ivm.Callback((instanceId: number) => {
1259
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1260
+ return state?.integrity ?? "";
1261
+ })
1262
+ );
1263
+
1264
+ global.setSync(
1265
+ "__Request_markBodyUsed",
1266
+ new ivm.Callback((instanceId: number) => {
1267
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1268
+ if (state) {
1269
+ if (state.bodyUsed) {
1270
+ throw new Error("[TypeError]Body has already been consumed");
1271
+ }
1272
+ state.bodyUsed = true;
1273
+ }
1274
+ })
1275
+ );
1276
+
1277
+ global.setSync(
1278
+ "__Request_text",
1279
+ new ivm.Callback((instanceId: number) => {
1280
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1281
+ if (!state || !state.body) return "";
1282
+ return new TextDecoder().decode(state.body);
1283
+ })
1284
+ );
1285
+
1286
+ global.setSync(
1287
+ "__Request_arrayBuffer",
1288
+ new ivm.Callback((instanceId: number) => {
1289
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1290
+ if (!state || !state.body) {
1291
+ return new ivm.ExternalCopy(new ArrayBuffer(0)).copyInto();
1292
+ }
1293
+ return new ivm.ExternalCopy(state.body.buffer.slice(
1294
+ state.body.byteOffset,
1295
+ state.body.byteOffset + state.body.byteLength
1296
+ )).copyInto();
1297
+ })
1298
+ );
1299
+
1300
+ global.setSync(
1301
+ "__Request_getBodyBytes",
1302
+ new ivm.Callback((instanceId: number) => {
1303
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1304
+ if (!state || !state.body) return null;
1305
+ return Array.from(state.body);
1306
+ })
1307
+ );
1308
+
1309
+ global.setSync(
1310
+ "__Request_clone",
1311
+ new ivm.Callback((instanceId: number) => {
1312
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1313
+ if (!state) {
1314
+ throw new Error("[TypeError]Cannot clone invalid Request");
1315
+ }
1316
+ const newId = nextInstanceId++;
1317
+ const newState: RequestState = {
1318
+ ...state,
1319
+ body: state.body ? new Uint8Array(state.body) : null,
1320
+ bodyUsed: false,
1321
+ };
1322
+ stateMap.set(newId, newState);
1323
+ return newId;
1324
+ })
1325
+ );
1326
+
1327
+ global.setSync(
1328
+ "__Request_getStreamId",
1329
+ new ivm.Callback((instanceId: number) => {
1330
+ const state = stateMap.get(instanceId) as RequestState | undefined;
1331
+ return state?.streamId ?? null;
1332
+ })
1333
+ );
1334
+
1335
+ // Inject Request class
1336
+ const requestCode = `
1337
+ (function() {
1338
+ function __decodeError(err) {
1339
+ if (!(err instanceof Error)) return err;
1340
+ const match = err.message.match(/^\\[(TypeError|RangeError|SyntaxError|ReferenceError|URIError|EvalError|Error)\\](.*)$/);
1341
+ if (match) {
1342
+ const ErrorType = globalThis[match[1]] || Error;
1343
+ return new ErrorType(match[2]);
1344
+ }
1345
+ return err;
1346
+ }
1347
+
1348
+ function __prepareBody(body) {
1349
+ if (body === null || body === undefined) return null;
1350
+ if (typeof body === 'string') {
1351
+ const encoder = new TextEncoder();
1352
+ return Array.from(encoder.encode(body));
1353
+ }
1354
+ if (body instanceof ArrayBuffer) {
1355
+ return Array.from(new Uint8Array(body));
1356
+ }
1357
+ if (body instanceof Uint8Array) {
1358
+ return Array.from(body);
1359
+ }
1360
+ if (ArrayBuffer.isView(body)) {
1361
+ return Array.from(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
1362
+ }
1363
+ if (body instanceof URLSearchParams) {
1364
+ return Array.from(new TextEncoder().encode(body.toString()));
1365
+ }
1366
+ if (body instanceof FormData) {
1367
+ // Check if FormData has any File/Blob entries
1368
+ let hasFiles = false;
1369
+ for (const [, value] of body.entries()) {
1370
+ if (value instanceof File || value instanceof Blob) {
1371
+ hasFiles = true;
1372
+ break;
1373
+ }
1374
+ }
1375
+
1376
+ if (hasFiles) {
1377
+ // Serialize as multipart/form-data
1378
+ const { body: bytes, contentType } = __serializeFormData(body);
1379
+ globalThis.__pendingFormDataContentType = contentType;
1380
+ return Array.from(bytes);
1381
+ }
1382
+
1383
+ // URL-encoded for string-only FormData
1384
+ const parts = [];
1385
+ body.forEach((value, key) => {
1386
+ if (typeof value === 'string') {
1387
+ parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value));
1388
+ }
1389
+ });
1390
+ return Array.from(new TextEncoder().encode(parts.join('&')));
1391
+ }
1392
+ // Try to convert to string
1393
+ return Array.from(new TextEncoder().encode(String(body)));
1394
+ }
1395
+
1396
+ // Helper to consume a HostBackedReadableStream and concatenate all chunks
1397
+ async function __consumeStream(stream) {
1398
+ const reader = stream.getReader();
1399
+ const chunks = [];
1400
+ let totalLength = 0;
1401
+
1402
+ while (true) {
1403
+ const { done, value } = await reader.read();
1404
+ if (done) break;
1405
+ chunks.push(value);
1406
+ totalLength += value.length;
1407
+ }
1408
+
1409
+ // Concatenate all chunks
1410
+ const result = new Uint8Array(totalLength);
1411
+ let offset = 0;
1412
+ for (const chunk of chunks) {
1413
+ result.set(chunk, offset);
1414
+ offset += chunk.length;
1415
+ }
1416
+ return result;
1417
+ }
1418
+
1419
+ class Request {
1420
+ #instanceId;
1421
+ #headers;
1422
+ #signal;
1423
+ #streamId;
1424
+ #cachedBody = null;
1425
+
1426
+ constructor(input, init = {}) {
1427
+ // Handle internal construction from instance ID
1428
+ if (typeof input === 'number' && init === null) {
1429
+ this.#instanceId = input;
1430
+ this.#headers = new Headers(__Request_get_headers(input));
1431
+ this.#signal = null;
1432
+ this.#streamId = __Request_getStreamId(input);
1433
+ return;
1434
+ }
1435
+
1436
+ let url;
1437
+ let method = 'GET';
1438
+ let headers;
1439
+ let body = null;
1440
+ let signal = null;
1441
+ let mode = 'cors';
1442
+ let credentials = 'same-origin';
1443
+ let cache = 'default';
1444
+ let redirect = 'follow';
1445
+ let referrer = 'about:client';
1446
+ let integrity = '';
1447
+
1448
+ if (input instanceof Request) {
1449
+ url = input.url;
1450
+ method = input.method;
1451
+ headers = new Headers(input.headers);
1452
+ signal = input.signal;
1453
+ mode = input.mode;
1454
+ credentials = input.credentials;
1455
+ cache = input.cache;
1456
+ redirect = input.redirect;
1457
+ referrer = input.referrer;
1458
+ integrity = input.integrity;
1459
+ // Note: We don't copy the body from the input Request
1460
+ } else {
1461
+ url = String(input);
1462
+ headers = new Headers();
1463
+ }
1464
+
1465
+ // Apply init overrides
1466
+ if (init.method !== undefined) method = String(init.method).toUpperCase();
1467
+ if (init.headers !== undefined) headers = new Headers(init.headers);
1468
+ if (init.body !== undefined) body = init.body;
1469
+ if (init.signal !== undefined) signal = init.signal;
1470
+ if (init.mode !== undefined) mode = init.mode;
1471
+ if (init.credentials !== undefined) credentials = init.credentials;
1472
+ if (init.cache !== undefined) cache = init.cache;
1473
+ if (init.redirect !== undefined) redirect = init.redirect;
1474
+ if (init.referrer !== undefined) referrer = init.referrer;
1475
+ if (init.integrity !== undefined) integrity = init.integrity;
1476
+
1477
+ // Validate: body with GET/HEAD
1478
+ if (body !== null && (method === 'GET' || method === 'HEAD')) {
1479
+ throw new TypeError('Request with GET/HEAD method cannot have body');
1480
+ }
1481
+
1482
+ const bodyBytes = __prepareBody(body);
1483
+
1484
+ // Handle Content-Type for FormData
1485
+ if (globalThis.__pendingFormDataContentType) {
1486
+ headers.set('content-type', globalThis.__pendingFormDataContentType);
1487
+ delete globalThis.__pendingFormDataContentType;
1488
+ } else if (body instanceof FormData && !headers.has('content-type')) {
1489
+ headers.set('content-type', 'application/x-www-form-urlencoded');
1490
+ }
1491
+
1492
+ const headersArray = Array.from(headers.entries());
1493
+
1494
+ this.#instanceId = __Request_construct(
1495
+ url, method, headersArray, bodyBytes,
1496
+ mode, credentials, cache, redirect, referrer, integrity
1497
+ );
1498
+ this.#headers = headers;
1499
+ this.#signal = signal;
1500
+ this.#streamId = null;
1501
+ }
1502
+
1503
+ _getInstanceId() {
1504
+ return this.#instanceId;
1505
+ }
1506
+
1507
+ static _fromInstanceId(instanceId) {
1508
+ return new Request(instanceId, null);
1509
+ }
1510
+
1511
+ get method() {
1512
+ return __Request_get_method(this.#instanceId);
1513
+ }
1514
+
1515
+ get url() {
1516
+ return __Request_get_url(this.#instanceId);
1517
+ }
1518
+
1519
+ get headers() {
1520
+ return this.#headers;
1521
+ }
1522
+
1523
+ get bodyUsed() {
1524
+ return __Request_get_bodyUsed(this.#instanceId);
1525
+ }
1526
+
1527
+ get signal() {
1528
+ return this.#signal;
1529
+ }
1530
+
1531
+ get mode() {
1532
+ return __Request_get_mode(this.#instanceId);
1533
+ }
1534
+
1535
+ get credentials() {
1536
+ return __Request_get_credentials(this.#instanceId);
1537
+ }
1538
+
1539
+ get cache() {
1540
+ return __Request_get_cache(this.#instanceId);
1541
+ }
1542
+
1543
+ get redirect() {
1544
+ return __Request_get_redirect(this.#instanceId);
1545
+ }
1546
+
1547
+ get referrer() {
1548
+ return __Request_get_referrer(this.#instanceId);
1549
+ }
1550
+
1551
+ get integrity() {
1552
+ return __Request_get_integrity(this.#instanceId);
1553
+ }
1554
+
1555
+ get body() {
1556
+ // Return cached body if available
1557
+ if (this.#cachedBody !== null) {
1558
+ return this.#cachedBody;
1559
+ }
1560
+
1561
+ // If we have a stream ID, create and cache the stream
1562
+ if (this.#streamId !== null) {
1563
+ this.#cachedBody = HostBackedReadableStream._fromStreamId(this.#streamId);
1564
+ return this.#cachedBody;
1565
+ }
1566
+
1567
+ // Create stream from buffered body
1568
+ const newStreamId = __Stream_create();
1569
+ const buffer = __Request_arrayBuffer(this.#instanceId);
1570
+ if (buffer.byteLength > 0) {
1571
+ __Stream_push(newStreamId, Array.from(new Uint8Array(buffer)));
1572
+ }
1573
+ __Stream_close(newStreamId);
1574
+
1575
+ this.#cachedBody = HostBackedReadableStream._fromStreamId(newStreamId);
1576
+ return this.#cachedBody;
1577
+ }
1578
+
1579
+ async text() {
1580
+ try {
1581
+ __Request_markBodyUsed(this.#instanceId);
1582
+ } catch (err) {
1583
+ throw __decodeError(err);
1584
+ }
1585
+
1586
+ // If streaming, consume the stream
1587
+ if (this.#streamId !== null) {
1588
+ const bytes = await __consumeStream(this.body);
1589
+ return new TextDecoder().decode(bytes);
1590
+ }
1591
+
1592
+ // Fallback to host callback for buffered body
1593
+ return __Request_text(this.#instanceId);
1594
+ }
1595
+
1596
+ async json() {
1597
+ const text = await this.text();
1598
+ return JSON.parse(text);
1599
+ }
1600
+
1601
+ async arrayBuffer() {
1602
+ try {
1603
+ __Request_markBodyUsed(this.#instanceId);
1604
+ } catch (err) {
1605
+ throw __decodeError(err);
1606
+ }
1607
+
1608
+ // If streaming, consume the stream
1609
+ if (this.#streamId !== null) {
1610
+ const bytes = await __consumeStream(this.body);
1611
+ return bytes.buffer;
1612
+ }
1613
+
1614
+ return __Request_arrayBuffer(this.#instanceId);
1615
+ }
1616
+
1617
+ async blob() {
1618
+ const buffer = await this.arrayBuffer();
1619
+ const contentType = this.headers.get('content-type') || '';
1620
+ return new Blob([buffer], { type: contentType });
1621
+ }
1622
+
1623
+ async formData() {
1624
+ const contentType = this.headers.get('content-type') || '';
1625
+
1626
+ // Parse multipart/form-data
1627
+ if (contentType.includes('multipart/form-data')) {
1628
+ const buffer = await this.arrayBuffer();
1629
+ return __parseMultipartFormData(new Uint8Array(buffer), contentType);
1630
+ }
1631
+
1632
+ // Parse application/x-www-form-urlencoded
1633
+ if (contentType.includes('application/x-www-form-urlencoded')) {
1634
+ const text = await this.text();
1635
+ const formData = new FormData();
1636
+ const params = new URLSearchParams(text);
1637
+ for (const [key, value] of params) {
1638
+ formData.append(key, value);
1639
+ }
1640
+ return formData;
1641
+ }
1642
+
1643
+ throw new TypeError('Unsupported content type for formData()');
1644
+ }
1645
+
1646
+ clone() {
1647
+ if (this.bodyUsed) {
1648
+ throw new TypeError('Cannot clone a Request that has already been used');
1649
+ }
1650
+ const newId = __Request_clone(this.#instanceId);
1651
+ const cloned = Request._fromInstanceId(newId);
1652
+ cloned.#signal = this.#signal;
1653
+ return cloned;
1654
+ }
1655
+
1656
+ _getBodyBytes() {
1657
+ return __Request_getBodyBytes(this.#instanceId);
1658
+ }
1659
+ }
1660
+
1661
+ globalThis.Request = Request;
1662
+ })();
1663
+ `;
1664
+
1665
+ context.evalSync(requestCode);
1666
+ }
1667
+
1668
+ // ============================================================================
1669
+ // fetch Implementation
1670
+ // ============================================================================
1671
+
1672
+ function setupFetchFunction(
1673
+ context: ivm.Context,
1674
+ stateMap: Map<number, unknown>,
1675
+ options?: FetchOptions
1676
+ ): void {
1677
+ const global = context.global;
1678
+
1679
+ // Create async fetch reference
1680
+ // We use JSON serialization for complex data to avoid transfer issues
1681
+ const fetchRef = new ivm.Reference(
1682
+ async (
1683
+ url: string,
1684
+ method: string,
1685
+ headersJson: string,
1686
+ bodyJson: string | null,
1687
+ signalAborted: boolean
1688
+ ) => {
1689
+ // Check if already aborted
1690
+ if (signalAborted) {
1691
+ throw new Error("[AbortError]The operation was aborted.");
1692
+ }
1693
+
1694
+ // Parse headers and body from JSON
1695
+ const headers = JSON.parse(headersJson) as [string, string][];
1696
+ const bodyBytes = bodyJson ? JSON.parse(bodyJson) as number[] : null;
1697
+
1698
+ // Construct native Request
1699
+ const body = bodyBytes ? new Uint8Array(bodyBytes) : null;
1700
+ const nativeRequest = new Request(url, {
1701
+ method,
1702
+ headers,
1703
+ body,
1704
+ });
1705
+
1706
+ // Call user's onFetch handler or default fetch
1707
+ const onFetch = options?.onFetch ?? fetch;
1708
+ const nativeResponse = await onFetch(nativeRequest);
1709
+
1710
+ // Read response body
1711
+ const responseBody = await nativeResponse.arrayBuffer();
1712
+ const responseBodyArray = Array.from(new Uint8Array(responseBody));
1713
+
1714
+ // Store the response in the state map and return just the ID + metadata
1715
+ const instanceId = nextInstanceId++;
1716
+ const state: ResponseState = {
1717
+ status: nativeResponse.status,
1718
+ statusText: nativeResponse.statusText,
1719
+ headers: Array.from(nativeResponse.headers.entries()),
1720
+ body: new Uint8Array(responseBodyArray),
1721
+ bodyUsed: false,
1722
+ type: "default",
1723
+ url: nativeResponse.url,
1724
+ redirected: nativeResponse.redirected,
1725
+ streamId: null,
1726
+ };
1727
+ stateMap.set(instanceId, state);
1728
+
1729
+ // Return only the instance ID - avoid complex object transfer
1730
+ return instanceId;
1731
+ }
1732
+ );
1733
+
1734
+ global.setSync("__fetch_ref", fetchRef);
1735
+
1736
+ // Inject fetch function
1737
+ const fetchCode = `
1738
+ (function() {
1739
+ function __decodeError(err) {
1740
+ if (!(err instanceof Error)) return err;
1741
+ const match = err.message.match(/^\\[(TypeError|RangeError|AbortError|Error)\\](.*)$/);
1742
+ if (match) {
1743
+ if (match[1] === 'AbortError') {
1744
+ return new DOMException(match[2], 'AbortError');
1745
+ }
1746
+ const ErrorType = globalThis[match[1]] || Error;
1747
+ return new ErrorType(match[2]);
1748
+ }
1749
+ return err;
1750
+ }
1751
+
1752
+ globalThis.fetch = function(input, init = {}) {
1753
+ // Create Request from input
1754
+ const request = input instanceof Request ? input : new Request(input, init);
1755
+
1756
+ // Get signal info
1757
+ const signal = init.signal ?? request.signal;
1758
+ const signalAborted = signal?.aborted ?? false;
1759
+
1760
+ // Serialize headers and body to JSON for transfer
1761
+ const headersJson = JSON.stringify(Array.from(request.headers.entries()));
1762
+ const bodyBytes = request._getBodyBytes();
1763
+ const bodyJson = bodyBytes ? JSON.stringify(bodyBytes) : null;
1764
+
1765
+ // Call host - returns just the response instance ID
1766
+ try {
1767
+ const instanceId = __fetch_ref.applySyncPromise(undefined, [
1768
+ request.url,
1769
+ request.method,
1770
+ headersJson,
1771
+ bodyJson,
1772
+ signalAborted
1773
+ ]);
1774
+
1775
+ // Construct Response from the instance ID
1776
+ return Response._fromInstanceId(instanceId);
1777
+ } catch (err) {
1778
+ throw __decodeError(err);
1779
+ }
1780
+ };
1781
+ })();
1782
+ `;
1783
+
1784
+ context.evalSync(fetchCode);
1785
+ }
1786
+
1787
+ // ============================================================================
1788
+ // Server Implementation (for serve())
1789
+ // ============================================================================
1790
+
1791
+ function setupServer(
1792
+ context: ivm.Context,
1793
+ serveState: ServeState
1794
+ ): void {
1795
+ const global = context.global;
1796
+
1797
+ // Setup upgrade registry in isolate (data stays in isolate, never marshalled to host)
1798
+ context.evalSync(`
1799
+ globalThis.__upgradeRegistry__ = new Map();
1800
+ globalThis.__upgradeIdCounter__ = 0;
1801
+ `);
1802
+
1803
+ // Host callback to notify about pending upgrade
1804
+ global.setSync(
1805
+ "__setPendingUpgrade__",
1806
+ new ivm.Callback((connectionId: string) => {
1807
+ serveState.pendingUpgrade = { requested: true, connectionId };
1808
+ })
1809
+ );
1810
+
1811
+ // Pure JS Server class with upgrade method
1812
+ context.evalSync(`
1813
+ (function() {
1814
+ class Server {
1815
+ upgrade(request, options) {
1816
+ const data = options?.data;
1817
+ const connectionId = String(++globalThis.__upgradeIdCounter__);
1818
+ globalThis.__upgradeRegistry__.set(connectionId, data);
1819
+ __setPendingUpgrade__(connectionId);
1820
+ return true;
1821
+ }
1822
+ }
1823
+ globalThis.__Server__ = Server;
1824
+ })();
1825
+ `);
1826
+ }
1827
+
1828
+ // ============================================================================
1829
+ // ServerWebSocket Implementation (for serve())
1830
+ // ============================================================================
1831
+
1832
+ function setupServerWebSocket(
1833
+ context: ivm.Context,
1834
+ wsCommandCallbacks: Set<(cmd: WebSocketCommand) => void>
1835
+ ): void {
1836
+ const global = context.global;
1837
+
1838
+ // Host callback for ws.send()
1839
+ global.setSync(
1840
+ "__ServerWebSocket_send",
1841
+ new ivm.Callback((connectionId: string, data: string) => {
1842
+ const cmd: WebSocketCommand = { type: "message", connectionId, data };
1843
+ for (const cb of wsCommandCallbacks) cb(cmd);
1844
+ })
1845
+ );
1846
+
1847
+ // Host callback for ws.close()
1848
+ global.setSync(
1849
+ "__ServerWebSocket_close",
1850
+ new ivm.Callback((connectionId: string, code?: number, reason?: string) => {
1851
+ const cmd: WebSocketCommand = { type: "close", connectionId, code, reason };
1852
+ for (const cb of wsCommandCallbacks) cb(cmd);
1853
+ })
1854
+ );
1855
+
1856
+ // Pure JS ServerWebSocket class
1857
+ context.evalSync(`
1858
+ (function() {
1859
+ const _wsInstanceData = new WeakMap();
1860
+
1861
+ class ServerWebSocket {
1862
+ constructor(connectionId) {
1863
+ _wsInstanceData.set(this, { connectionId, readyState: 1 });
1864
+ }
1865
+
1866
+ get data() {
1867
+ const state = _wsInstanceData.get(this);
1868
+ return globalThis.__upgradeRegistry__.get(state.connectionId);
1869
+ }
1870
+
1871
+ get readyState() {
1872
+ return _wsInstanceData.get(this).readyState;
1873
+ }
1874
+
1875
+ send(message) {
1876
+ const state = _wsInstanceData.get(this);
1877
+ if (state.readyState !== 1) throw new Error("WebSocket is not open");
1878
+ // Convert ArrayBuffer/Uint8Array to string for transfer
1879
+ let data = message;
1880
+ if (message instanceof ArrayBuffer) {
1881
+ data = new TextDecoder().decode(message);
1882
+ } else if (message instanceof Uint8Array) {
1883
+ data = new TextDecoder().decode(message);
1884
+ }
1885
+ __ServerWebSocket_send(state.connectionId, data);
1886
+ }
1887
+
1888
+ close(code, reason) {
1889
+ const state = _wsInstanceData.get(this);
1890
+ if (state.readyState === 3) return;
1891
+ state.readyState = 2; // CLOSING
1892
+ __ServerWebSocket_close(state.connectionId, code, reason);
1893
+ }
1894
+
1895
+ _setReadyState(readyState) {
1896
+ _wsInstanceData.get(this).readyState = readyState;
1897
+ }
1898
+ }
1899
+
1900
+ globalThis.__ServerWebSocket__ = ServerWebSocket;
1901
+ })();
1902
+ `);
1903
+ }
1904
+
1905
+ // ============================================================================
1906
+ // serve() Function Implementation
1907
+ // ============================================================================
1908
+
1909
+ function setupServe(context: ivm.Context): void {
1910
+ // Pure JS serve() that stores options on __serveOptions__ global
1911
+ context.evalSync(`
1912
+ (function() {
1913
+ globalThis.__serveOptions__ = null;
1914
+
1915
+ function serve(options) {
1916
+ globalThis.__serveOptions__ = options;
1917
+ }
1918
+
1919
+ globalThis.serve = serve;
1920
+ })();
1921
+ `);
1922
+ }
1923
+
1924
+ // ============================================================================
1925
+ // Main Setup Function
1926
+ // ============================================================================
1927
+
1928
+ /**
1929
+ * Setup Fetch API in an isolated-vm context
1930
+ *
1931
+ * Injects fetch, Request, Response, Headers, FormData
1932
+ * Also sets up core APIs (Blob, File, AbortController, etc.) if not already present
1933
+ *
1934
+ * @example
1935
+ * const handle = await setupFetch(context, {
1936
+ * onFetch: async (request) => {
1937
+ * // Proxy fetch requests to the host
1938
+ * return fetch(request);
1939
+ * }
1940
+ * });
1941
+ *
1942
+ * await context.eval(`
1943
+ * const response = await fetch("https://example.com");
1944
+ * const text = await response.text();
1945
+ * `);
1946
+ */
1947
+ export async function setupFetch(
1948
+ context: ivm.Context,
1949
+ options?: FetchOptions
1950
+ ): Promise<FetchHandle> {
1951
+ // Setup core APIs first (Blob, File, AbortController, Streams, etc.)
1952
+ await setupCore(context);
1953
+
1954
+ const stateMap = getInstanceStateMapForContext(context);
1955
+ const streamRegistry = getStreamRegistryForContext(context);
1956
+
1957
+ // Inject Headers (pure JS)
1958
+ context.evalSync(headersCode);
1959
+
1960
+ // Inject FormData (pure JS)
1961
+ context.evalSync(formDataCode);
1962
+
1963
+ // Inject multipart parsing/serialization (pure JS)
1964
+ context.evalSync(multipartCode);
1965
+
1966
+ // Setup stream callbacks and inject HostBackedReadableStream
1967
+ setupStreamCallbacks(context, streamRegistry);
1968
+ context.evalSync(hostBackedStreamCode);
1969
+
1970
+ // Setup Response (host state + isolate class)
1971
+ setupResponse(context, stateMap);
1972
+
1973
+ // Setup Request (host state + isolate class)
1974
+ setupRequest(context, stateMap);
1975
+
1976
+ // Setup fetch function
1977
+ setupFetchFunction(context, stateMap, options);
1978
+
1979
+ // Setup serve state
1980
+ const serveState: ServeState = {
1981
+ pendingUpgrade: null,
1982
+ activeConnections: new Map(),
1983
+ };
1984
+
1985
+ // Setup WebSocket command callbacks
1986
+ const wsCommandCallbacks = new Set<(cmd: WebSocketCommand) => void>();
1987
+
1988
+ // Setup Server class
1989
+ setupServer(context, serveState);
1990
+
1991
+ // Setup ServerWebSocket class
1992
+ setupServerWebSocket(context, wsCommandCallbacks);
1993
+
1994
+ // Setup serve function
1995
+ setupServe(context);
1996
+
1997
+ return {
1998
+ dispose() {
1999
+ // Clear state for this context
2000
+ stateMap.clear();
2001
+ // Clear upgrade registry
2002
+ context.evalSync(`globalThis.__upgradeRegistry__.clear()`);
2003
+ // Clear serve state
2004
+ serveState.activeConnections.clear();
2005
+ serveState.pendingUpgrade = null;
2006
+ },
2007
+
2008
+ async dispatchRequest(
2009
+ request: Request,
2010
+ dispatchOptions?: DispatchRequestOptions
2011
+ ): Promise<Response> {
2012
+ const tick = dispatchOptions?.tick;
2013
+
2014
+ // Clean up previous pending upgrade if not consumed
2015
+ if (serveState.pendingUpgrade) {
2016
+ const oldConnectionId = serveState.pendingUpgrade.connectionId;
2017
+ context.evalSync(`globalThis.__upgradeRegistry__.delete("${oldConnectionId}")`);
2018
+ serveState.pendingUpgrade = null;
2019
+ }
2020
+
2021
+ // Check if serve handler exists
2022
+ const hasHandler = context.evalSync(`!!globalThis.__serveOptions__?.fetch`);
2023
+ if (!hasHandler) {
2024
+ throw new Error("No serve() handler registered");
2025
+ }
2026
+
2027
+ // Setup streaming for request body
2028
+ let requestStreamId: number | null = null;
2029
+ let streamCleanup: (() => Promise<void>) | null = null;
2030
+
2031
+ if (request.body) {
2032
+ // Create a stream in the registry for the request body
2033
+ requestStreamId = streamRegistry.create();
2034
+
2035
+ // Start background reader that pushes from native stream to host queue
2036
+ streamCleanup = startNativeStreamReader(
2037
+ request.body,
2038
+ requestStreamId,
2039
+ streamRegistry
2040
+ );
2041
+ }
2042
+
2043
+ try {
2044
+ const headersArray = Array.from(request.headers.entries());
2045
+
2046
+ // Create Request instance in isolate
2047
+ const requestInstanceId = nextInstanceId++;
2048
+ const requestState: RequestState = {
2049
+ url: request.url,
2050
+ method: request.method,
2051
+ headers: headersArray,
2052
+ body: null, // No buffered body - using stream
2053
+ bodyUsed: false,
2054
+ streamId: requestStreamId,
2055
+ mode: request.mode,
2056
+ credentials: request.credentials,
2057
+ cache: request.cache,
2058
+ redirect: request.redirect,
2059
+ referrer: request.referrer,
2060
+ integrity: request.integrity,
2061
+ };
2062
+ stateMap.set(requestInstanceId, requestState);
2063
+
2064
+ // Call the fetch handler and get response
2065
+ // We use eval with promise: true to handle async handlers
2066
+ const responseInstanceId = await context.eval(`
2067
+ (async function() {
2068
+ const request = Request._fromInstanceId(${requestInstanceId});
2069
+ const server = new __Server__();
2070
+ const response = await Promise.resolve(__serveOptions__.fetch(request, server));
2071
+ return response._getInstanceId();
2072
+ })()
2073
+ `, { promise: true });
2074
+
2075
+ // Get ResponseState from the instance
2076
+ const responseState = stateMap.get(responseInstanceId) as ResponseState | undefined;
2077
+ if (!responseState) {
2078
+ throw new Error("Response state not found");
2079
+ }
2080
+
2081
+ // Check if response has streaming body
2082
+ if (responseState.streamId !== null) {
2083
+ const responseStreamId = responseState.streamId;
2084
+ let streamDone = false;
2085
+
2086
+ // Create native stream that pumps isolate timers while waiting
2087
+ const pumpedStream = new ReadableStream<Uint8Array>({
2088
+ async pull(controller) {
2089
+ if (streamDone) return;
2090
+
2091
+ // Pump isolate timers to allow stream pump to progress
2092
+ while (!streamDone) {
2093
+ if (tick) {
2094
+ await tick();
2095
+ }
2096
+
2097
+ // Check if data is available
2098
+ const state = streamRegistry.get(responseStreamId);
2099
+ if (!state) {
2100
+ controller.close();
2101
+ streamDone = true;
2102
+ return;
2103
+ }
2104
+
2105
+ // If queue has data or stream is done, break and pull
2106
+ if (state.queue.length > 0 || state.closed || state.errored) {
2107
+ break;
2108
+ }
2109
+
2110
+ // Small delay to avoid busy-waiting
2111
+ await new Promise((r) => setTimeout(r, 1));
2112
+ }
2113
+
2114
+ try {
2115
+ const result = await streamRegistry.pull(responseStreamId);
2116
+ if (result.done) {
2117
+ controller.close();
2118
+ streamDone = true;
2119
+ streamRegistry.delete(responseStreamId);
2120
+ return;
2121
+ }
2122
+ controller.enqueue(result.value);
2123
+ } catch (error) {
2124
+ controller.error(error);
2125
+ streamDone = true;
2126
+ streamRegistry.delete(responseStreamId);
2127
+ }
2128
+ },
2129
+ cancel() {
2130
+ streamDone = true;
2131
+ streamRegistry.error(
2132
+ responseStreamId,
2133
+ new Error("Stream cancelled")
2134
+ );
2135
+ streamRegistry.delete(responseStreamId);
2136
+ },
2137
+ });
2138
+
2139
+ const responseHeaders = new Headers(responseState.headers);
2140
+ const status =
2141
+ responseState.status === 101 ? 200 : responseState.status;
2142
+ const response = new Response(pumpedStream, {
2143
+ status,
2144
+ statusText: responseState.statusText,
2145
+ headers: responseHeaders,
2146
+ });
2147
+
2148
+ // @ts-expect-error - adding custom property
2149
+ response._originalStatus = responseState.status;
2150
+
2151
+ return response;
2152
+ }
2153
+
2154
+ // Convert to native Response (non-streaming)
2155
+ const responseHeaders = new Headers(responseState.headers);
2156
+ const responseBody = responseState.body;
2157
+
2158
+ // Note: Status 101 (Switching Protocols) is not valid for Response constructor
2159
+ // We use 200 as the status but preserve the actual status in a custom header
2160
+ // The caller should check getUpgradeRequest() for WebSocket upgrades
2161
+ const status = responseState.status === 101 ? 200 : responseState.status;
2162
+ const response = new Response(responseBody as ConstructorParameters<typeof Response>[0], {
2163
+ status,
2164
+ statusText: responseState.statusText,
2165
+ headers: responseHeaders,
2166
+ });
2167
+
2168
+ // Expose the original status via a property for callers to check
2169
+ // @ts-expect-error - adding custom property
2170
+ response._originalStatus = responseState.status;
2171
+
2172
+ return response;
2173
+ } finally {
2174
+ // Cleanup: cancel stream reader if still running
2175
+ if (streamCleanup) {
2176
+ await streamCleanup();
2177
+ }
2178
+ // Delete stream from registry
2179
+ if (requestStreamId !== null) {
2180
+ streamRegistry.delete(requestStreamId);
2181
+ }
2182
+ }
2183
+ },
2184
+
2185
+ getUpgradeRequest(): UpgradeRequest | null {
2186
+ const result = serveState.pendingUpgrade;
2187
+ // Don't clear yet - it will be cleared on next dispatchRequest or consumed by dispatchWebSocketOpen
2188
+ return result;
2189
+ },
2190
+
2191
+ dispatchWebSocketOpen(connectionId: string): void {
2192
+ // Check if websocket.open handler exists - required for connection tracking
2193
+ const hasOpenHandler = context.evalSync(`!!globalThis.__serveOptions__?.websocket?.open`);
2194
+ if (!hasOpenHandler) {
2195
+ // Delete from registry and return - connection NOT tracked
2196
+ context.evalSync(`globalThis.__upgradeRegistry__.delete("${connectionId}")`);
2197
+ return;
2198
+ }
2199
+
2200
+ // Store connection (data stays in isolate registry)
2201
+ serveState.activeConnections.set(connectionId, { connectionId });
2202
+
2203
+ // Create ServerWebSocket and call open handler
2204
+ context.evalSync(`
2205
+ (function() {
2206
+ const ws = new __ServerWebSocket__("${connectionId}");
2207
+ globalThis.__activeWs_${connectionId}__ = ws;
2208
+ __serveOptions__.websocket.open(ws);
2209
+ })()
2210
+ `);
2211
+
2212
+ // Clear pending upgrade after successful open
2213
+ if (serveState.pendingUpgrade?.connectionId === connectionId) {
2214
+ serveState.pendingUpgrade = null;
2215
+ }
2216
+ },
2217
+
2218
+ dispatchWebSocketMessage(connectionId: string, message: string | ArrayBuffer): void {
2219
+ // Check if connection is tracked
2220
+ if (!serveState.activeConnections.has(connectionId)) {
2221
+ return; // Silently ignore for unknown connections
2222
+ }
2223
+
2224
+ // Check if message handler exists
2225
+ const hasMessageHandler = context.evalSync(`!!globalThis.__serveOptions__?.websocket?.message`);
2226
+ if (!hasMessageHandler) {
2227
+ return;
2228
+ }
2229
+
2230
+ // Marshal message and call handler
2231
+ if (typeof message === "string") {
2232
+ context.evalSync(`
2233
+ (function() {
2234
+ const ws = globalThis.__activeWs_${connectionId}__;
2235
+ if (ws) __serveOptions__.websocket.message(ws, "${message.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}");
2236
+ })()
2237
+ `);
2238
+ } else {
2239
+ // ArrayBuffer - convert to base64 or pass as array
2240
+ const bytes = Array.from(new Uint8Array(message));
2241
+ context.evalSync(`
2242
+ (function() {
2243
+ const ws = globalThis.__activeWs_${connectionId}__;
2244
+ if (ws) {
2245
+ const bytes = new Uint8Array([${bytes.join(",")}]);
2246
+ __serveOptions__.websocket.message(ws, bytes.buffer);
2247
+ }
2248
+ })()
2249
+ `);
2250
+ }
2251
+ },
2252
+
2253
+ dispatchWebSocketClose(connectionId: string, code: number, reason: string): void {
2254
+ // Check if connection is tracked
2255
+ if (!serveState.activeConnections.has(connectionId)) {
2256
+ return;
2257
+ }
2258
+
2259
+ // Update readyState to CLOSED
2260
+ context.evalSync(`
2261
+ (function() {
2262
+ const ws = globalThis.__activeWs_${connectionId}__;
2263
+ if (ws) ws._setReadyState(3);
2264
+ })()
2265
+ `);
2266
+
2267
+ // Check if close handler exists
2268
+ const hasCloseHandler = context.evalSync(`!!globalThis.__serveOptions__?.websocket?.close`);
2269
+ if (hasCloseHandler) {
2270
+ const safeReason = reason.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
2271
+ context.evalSync(`
2272
+ (function() {
2273
+ const ws = globalThis.__activeWs_${connectionId}__;
2274
+ if (ws) __serveOptions__.websocket.close(ws, ${code}, "${safeReason}");
2275
+ })()
2276
+ `);
2277
+ }
2278
+
2279
+ // Cleanup
2280
+ context.evalSync(`
2281
+ delete globalThis.__activeWs_${connectionId}__;
2282
+ globalThis.__upgradeRegistry__.delete("${connectionId}");
2283
+ `);
2284
+ serveState.activeConnections.delete(connectionId);
2285
+ },
2286
+
2287
+ dispatchWebSocketError(connectionId: string, error: Error): void {
2288
+ // Check if connection is tracked
2289
+ if (!serveState.activeConnections.has(connectionId)) {
2290
+ return;
2291
+ }
2292
+
2293
+ // Check if error handler exists
2294
+ const hasErrorHandler = context.evalSync(`!!globalThis.__serveOptions__?.websocket?.error`);
2295
+ if (!hasErrorHandler) {
2296
+ return;
2297
+ }
2298
+
2299
+ const safeName = error.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
2300
+ const safeMessage = error.message.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
2301
+ context.evalSync(`
2302
+ (function() {
2303
+ const ws = globalThis.__activeWs_${connectionId}__;
2304
+ if (ws) {
2305
+ const error = { name: "${safeName}", message: "${safeMessage}" };
2306
+ __serveOptions__.websocket.error(ws, error);
2307
+ }
2308
+ })()
2309
+ `);
2310
+ },
2311
+
2312
+ onWebSocketCommand(callback: (cmd: WebSocketCommand) => void): () => void {
2313
+ wsCommandCallbacks.add(callback);
2314
+ return () => wsCommandCallbacks.delete(callback);
2315
+ },
2316
+
2317
+ hasServeHandler(): boolean {
2318
+ return context.evalSync(`!!globalThis.__serveOptions__?.fetch`) as boolean;
2319
+ },
2320
+
2321
+ hasActiveConnections(): boolean {
2322
+ return serveState.activeConnections.size > 0;
2323
+ },
2324
+ };
2325
+ }