@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.
@@ -0,0 +1,665 @@
1
+ import { test, describe, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert";
3
+ import ivm from "isolated-vm";
4
+ import { setupFetch, clearAllInstanceState, type FetchHandle } from "./index.ts";
5
+
6
+ describe("Request Body Consumption", () => {
7
+ let isolate: ivm.Isolate;
8
+ let context: ivm.Context;
9
+ let fetchHandle: FetchHandle;
10
+
11
+ beforeEach(async () => {
12
+ isolate = new ivm.Isolate();
13
+ context = await isolate.createContext();
14
+ clearAllInstanceState();
15
+ fetchHandle = await setupFetch(context);
16
+ });
17
+
18
+ afterEach(() => {
19
+ fetchHandle.dispose();
20
+ context.release();
21
+ isolate.dispose();
22
+ });
23
+
24
+ test("accessing request.body before request.json() should not lose body data", async () => {
25
+ context.evalSync(`
26
+ serve({
27
+ async fetch(request) {
28
+ // This pattern is used by frameworks like Better Auth:
29
+ // First access request.body (e.g., to check if body exists)
30
+ const bodyStream = request.body; // Getter is called
31
+
32
+ // Then try to parse the JSON body
33
+ try {
34
+ const data = await request.json();
35
+ return Response.json({ success: true, received: data });
36
+ } catch (error) {
37
+ return Response.json({
38
+ success: false,
39
+ error: error.message,
40
+ bodyWasNull: bodyStream === null
41
+ }, { status: 500 });
42
+ }
43
+ }
44
+ });
45
+ `);
46
+
47
+ const response = await fetchHandle.dispatchRequest(
48
+ new Request("http://localhost/api/test", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({ email: "test@example.com" }),
52
+ })
53
+ );
54
+
55
+ const data = await response.json();
56
+ assert.strictEqual(data.success, true);
57
+ assert.deepStrictEqual(data.received, { email: "test@example.com" });
58
+ });
59
+
60
+ test("accessing request.body getter multiple times should return consistent stream", async () => {
61
+ context.evalSync(`
62
+ serve({
63
+ async fetch(request) {
64
+ // Access body getter multiple times
65
+ const body1 = request.body;
66
+ const body2 = request.body;
67
+
68
+ // Both should reference the same stream
69
+ const areSame = body1 === body2;
70
+
71
+ // Should still be able to read the body
72
+ try {
73
+ const text = await request.text();
74
+ return Response.json({
75
+ success: true,
76
+ bodiesAreSame: areSame,
77
+ bodyText: text
78
+ });
79
+ } catch (error) {
80
+ return Response.json({
81
+ success: false,
82
+ error: error.message,
83
+ bodiesAreSame: areSame
84
+ }, { status: 500 });
85
+ }
86
+ }
87
+ });
88
+ `);
89
+
90
+ const response = await fetchHandle.dispatchRequest(
91
+ new Request("http://localhost/api/test", {
92
+ method: "POST",
93
+ headers: { "Content-Type": "text/plain" },
94
+ body: "Hello World",
95
+ })
96
+ );
97
+
98
+ const data = await response.json();
99
+ assert.strictEqual(data.success, true);
100
+ assert.strictEqual(data.bodiesAreSame, true);
101
+ assert.strictEqual(data.bodyText, "Hello World");
102
+ });
103
+ });
104
+
105
+ describe("HTTP Roundtrip", () => {
106
+ let isolate: ivm.Isolate;
107
+ let context: ivm.Context;
108
+ let fetchHandle: FetchHandle;
109
+
110
+ beforeEach(async () => {
111
+ isolate = new ivm.Isolate();
112
+ context = await isolate.createContext();
113
+ clearAllInstanceState();
114
+ fetchHandle = await setupFetch(context);
115
+ });
116
+
117
+ afterEach(() => {
118
+ fetchHandle.dispose();
119
+ context.release();
120
+ isolate.dispose();
121
+ });
122
+
123
+ test("GET request returns correct response body", async () => {
124
+ context.evalSync(`
125
+ serve({
126
+ fetch(request) {
127
+ return new Response("Hello from isolate!");
128
+ }
129
+ });
130
+ `);
131
+
132
+ const response = await fetchHandle.dispatchRequest(
133
+ new Request("http://localhost/test")
134
+ );
135
+ assert.strictEqual(response.status, 200);
136
+ assert.strictEqual(await response.text(), "Hello from isolate!");
137
+ });
138
+
139
+ test("POST request with JSON body is received correctly", async () => {
140
+ context.evalSync(`
141
+ serve({
142
+ async fetch(request) {
143
+ const body = await request.json();
144
+ return Response.json({ received: body });
145
+ }
146
+ });
147
+ `);
148
+
149
+ const response = await fetchHandle.dispatchRequest(
150
+ new Request("http://localhost/api/data", {
151
+ method: "POST",
152
+ headers: { "Content-Type": "application/json" },
153
+ body: JSON.stringify({ name: "test", value: 42 }),
154
+ })
155
+ );
156
+
157
+ assert.strictEqual(response.status, 200);
158
+ const data = await response.json();
159
+ assert.deepStrictEqual(data.received, { name: "test", value: 42 });
160
+ });
161
+
162
+ test("Response headers are preserved", async () => {
163
+ context.evalSync(`
164
+ serve({
165
+ fetch(request) {
166
+ return new Response("OK", {
167
+ headers: {
168
+ "X-Custom-Header": "custom-value",
169
+ "X-Another-Header": "another-value"
170
+ }
171
+ });
172
+ }
173
+ });
174
+ `);
175
+
176
+ const response = await fetchHandle.dispatchRequest(
177
+ new Request("http://localhost/")
178
+ );
179
+ assert.strictEqual(response.headers.get("X-Custom-Header"), "custom-value");
180
+ assert.strictEqual(response.headers.get("X-Another-Header"), "another-value");
181
+ });
182
+
183
+ test("Response status codes work correctly", async () => {
184
+ context.evalSync(`
185
+ serve({
186
+ fetch(request) {
187
+ const url = new URL(request.url);
188
+ const status = parseInt(url.searchParams.get("status") || "200", 10);
189
+ return new Response("Status test", { status });
190
+ }
191
+ });
192
+ `);
193
+
194
+ const ok = await fetchHandle.dispatchRequest(
195
+ new Request("http://localhost/?status=200")
196
+ );
197
+ assert.strictEqual(ok.status, 200);
198
+
199
+ const notFound = await fetchHandle.dispatchRequest(
200
+ new Request("http://localhost/?status=404")
201
+ );
202
+ assert.strictEqual(notFound.status, 404);
203
+
204
+ const serverError = await fetchHandle.dispatchRequest(
205
+ new Request("http://localhost/?status=500")
206
+ );
207
+ assert.strictEqual(serverError.status, 500);
208
+ });
209
+
210
+ test("JSON response via Response.json()", async () => {
211
+ context.evalSync(`
212
+ serve({
213
+ fetch(request) {
214
+ return Response.json({
215
+ message: "Hello",
216
+ items: [1, 2, 3],
217
+ nested: { foo: "bar" }
218
+ });
219
+ }
220
+ });
221
+ `);
222
+
223
+ const response = await fetchHandle.dispatchRequest(
224
+ new Request("http://localhost/")
225
+ );
226
+ assert.ok(response.headers.get("Content-Type")?.includes("application/json"));
227
+ const data = await response.json();
228
+ assert.deepStrictEqual(data, {
229
+ message: "Hello",
230
+ items: [1, 2, 3],
231
+ nested: { foo: "bar" },
232
+ });
233
+ });
234
+
235
+ test("Request URL and method are accessible", async () => {
236
+ context.evalSync(`
237
+ serve({
238
+ fetch(request) {
239
+ return Response.json({
240
+ method: request.method,
241
+ url: request.url
242
+ });
243
+ }
244
+ });
245
+ `);
246
+
247
+ const response = await fetchHandle.dispatchRequest(
248
+ new Request("http://localhost/api/test?foo=bar", { method: "PUT" })
249
+ );
250
+ const data = await response.json();
251
+ assert.strictEqual(data.method, "PUT");
252
+ assert.ok(data.url.includes("/api/test?foo=bar"));
253
+ });
254
+
255
+ test("Request headers are accessible", async () => {
256
+ context.evalSync(`
257
+ serve({
258
+ fetch(request) {
259
+ return Response.json({
260
+ auth: request.headers.get("Authorization"),
261
+ custom: request.headers.get("X-Custom")
262
+ });
263
+ }
264
+ });
265
+ `);
266
+
267
+ const response = await fetchHandle.dispatchRequest(
268
+ new Request("http://localhost/", {
269
+ headers: {
270
+ Authorization: "Bearer token123",
271
+ "X-Custom": "custom-value",
272
+ },
273
+ })
274
+ );
275
+ const data = await response.json();
276
+ assert.strictEqual(data.auth, "Bearer token123");
277
+ assert.strictEqual(data.custom, "custom-value");
278
+ });
279
+
280
+ test("Large response body can be read", async () => {
281
+ context.evalSync(`
282
+ serve({
283
+ fetch(request) {
284
+ // Generate a ~10KB response
285
+ const chunk = "0123456789".repeat(100);
286
+ const body = chunk.repeat(10);
287
+ return new Response(body);
288
+ }
289
+ });
290
+ `);
291
+
292
+ const response = await fetchHandle.dispatchRequest(
293
+ new Request("http://localhost/")
294
+ );
295
+ const text = await response.text();
296
+ assert.strictEqual(text.length, 10000);
297
+ });
298
+
299
+ test("Request with text body is forwarded to handler", async () => {
300
+ context.evalSync(`
301
+ serve({
302
+ async fetch(request) {
303
+ const body = await request.text();
304
+ return new Response("Received: " + body.length + " chars");
305
+ }
306
+ });
307
+ `);
308
+
309
+ const largeBody = "x".repeat(5000);
310
+ const response = await fetchHandle.dispatchRequest(
311
+ new Request("http://localhost/", {
312
+ method: "POST",
313
+ body: largeBody,
314
+ })
315
+ );
316
+ const text = await response.text();
317
+ assert.strictEqual(text, "Received: 5000 chars");
318
+ });
319
+ });
320
+
321
+ describe("Response Clone", () => {
322
+ let isolate: ivm.Isolate;
323
+ let context: ivm.Context;
324
+ let fetchHandle: FetchHandle;
325
+
326
+ beforeEach(async () => {
327
+ isolate = new ivm.Isolate();
328
+ context = await isolate.createContext();
329
+ clearAllInstanceState();
330
+ fetchHandle = await setupFetch(context);
331
+ });
332
+
333
+ afterEach(() => {
334
+ fetchHandle.dispose();
335
+ context.release();
336
+ isolate.dispose();
337
+ });
338
+
339
+ test("response.clone() preserves headers including cookies", async () => {
340
+ context.evalSync(`
341
+ serve({
342
+ async fetch(request) {
343
+ // Simulate auth handler that adds a cookie
344
+ function authHandler(req) {
345
+ const response = new Response(JSON.stringify({ authenticated: true }), {
346
+ status: 200,
347
+ headers: {
348
+ "Content-Type": "application/json",
349
+ "Set-Cookie": "session=abc123; HttpOnly; Secure"
350
+ }
351
+ });
352
+ return response;
353
+ }
354
+
355
+ const response = await authHandler(request);
356
+ const clone = response.clone();
357
+
358
+ // Read the clone body
359
+ const cloneBody = await clone.text();
360
+
361
+ // Return original response
362
+ return response;
363
+ }
364
+ });
365
+ `);
366
+
367
+ const response = await fetchHandle.dispatchRequest(
368
+ new Request("http://localhost/test")
369
+ );
370
+ assert.strictEqual(response.status, 200);
371
+ assert.strictEqual(response.headers.get("Set-Cookie"), "session=abc123; HttpOnly; Secure");
372
+ });
373
+ });
374
+
375
+ describe("Headers instanceof and constructor behavior (better-auth/better-call compatibility)", () => {
376
+ let isolate: ivm.Isolate;
377
+ let context: ivm.Context;
378
+ let fetchHandle: FetchHandle;
379
+
380
+ beforeEach(async () => {
381
+ isolate = new ivm.Isolate();
382
+ context = await isolate.createContext();
383
+ clearAllInstanceState();
384
+ fetchHandle = await setupFetch(context);
385
+ });
386
+
387
+ afterEach(() => {
388
+ fetchHandle.dispose();
389
+ context.release();
390
+ isolate.dispose();
391
+ });
392
+
393
+ test("request.headers should work with instanceof check", async () => {
394
+ context.evalSync(`
395
+ serve({
396
+ async fetch(request) {
397
+ const cookie = request.headers.get("cookie");
398
+ const instanceofHeaders = request.headers instanceof Headers;
399
+ const constructorName = request.headers.constructor.name;
400
+
401
+ return Response.json({
402
+ cookie,
403
+ instanceofHeaders,
404
+ constructorName
405
+ });
406
+ }
407
+ });
408
+ `);
409
+
410
+ const response = await fetchHandle.dispatchRequest(
411
+ new Request("http://localhost/test", {
412
+ headers: {
413
+ cookie: "session=abc123; other=value",
414
+ },
415
+ })
416
+ );
417
+
418
+ const data = await response.json();
419
+ assert.strictEqual(data.cookie, "session=abc123; other=value");
420
+ assert.strictEqual(data.instanceofHeaders, true);
421
+ assert.strictEqual(data.constructorName, "Headers");
422
+ });
423
+
424
+ test("new Headers(request.headers) should preserve cookies", async () => {
425
+ context.evalSync(`
426
+ serve({
427
+ async fetch(request) {
428
+ const originalCookie = request.headers.get("cookie");
429
+
430
+ // This is what better-call does internally
431
+ const copiedHeaders = new Headers(request.headers);
432
+ const copiedCookie = copiedHeaders.get("cookie");
433
+
434
+ return Response.json({
435
+ originalCookie,
436
+ copiedCookie,
437
+ cookiesMatch: originalCookie === copiedCookie
438
+ });
439
+ }
440
+ });
441
+ `);
442
+
443
+ const response = await fetchHandle.dispatchRequest(
444
+ new Request("http://localhost/test", {
445
+ headers: {
446
+ cookie: "session=abc123; token=xyz",
447
+ },
448
+ })
449
+ );
450
+
451
+ const data = await response.json();
452
+ assert.strictEqual(data.originalCookie, "session=abc123; token=xyz");
453
+ assert.strictEqual(data.copiedCookie, "session=abc123; token=xyz");
454
+ assert.strictEqual(data.cookiesMatch, true);
455
+ });
456
+
457
+ test("headers passed to nested function should preserve instanceof behavior", async () => {
458
+ context.evalSync(`
459
+ serve({
460
+ async fetch(request) {
461
+ // Simulate what better-auth does: pass headers to a nested function
462
+ function processContext(context) {
463
+ const headers = context.headers;
464
+ return {
465
+ hasCookie: headers.has("cookie"),
466
+ getCookie: headers.get("cookie"),
467
+ instanceofHeaders: headers instanceof Headers,
468
+ constructorName: headers.constructor.name,
469
+ };
470
+ }
471
+
472
+ const result = processContext({ headers: request.headers });
473
+
474
+ return Response.json(result);
475
+ }
476
+ });
477
+ `);
478
+
479
+ const response = await fetchHandle.dispatchRequest(
480
+ new Request("http://localhost/test", {
481
+ headers: {
482
+ cookie: "better-auth.session_token=abc123",
483
+ },
484
+ })
485
+ );
486
+
487
+ const data = await response.json();
488
+ assert.strictEqual(data.hasCookie, true);
489
+ assert.strictEqual(data.getCookie, "better-auth.session_token=abc123");
490
+ assert.strictEqual(data.instanceofHeaders, true);
491
+ assert.strictEqual(data.constructorName, "Headers");
492
+ });
493
+
494
+ test("better-call createInternalContext pattern should preserve cookies", async () => {
495
+ context.evalSync(`
496
+ serve({
497
+ async fetch(request) {
498
+ // Simulate the createInternalContext pattern from better-call
499
+ function createInternalContext(context) {
500
+ const isHeadersLike = (obj) => obj && typeof obj.get === "function" && typeof obj.has === "function";
501
+
502
+ let requestHeaders = null;
503
+
504
+ if ("headers" in context && context.headers) {
505
+ if (isHeadersLike(context.headers)) {
506
+ requestHeaders = context.headers;
507
+ } else if (context.headers instanceof Headers) {
508
+ requestHeaders = context.headers;
509
+ } else {
510
+ try {
511
+ requestHeaders = new Headers(context.headers);
512
+ } catch (e) {
513
+ // Ignore errors
514
+ }
515
+ }
516
+ }
517
+
518
+ return {
519
+ requestHeadersType: requestHeaders?.constructor?.name,
520
+ hasCookie: requestHeaders?.has?.("cookie"),
521
+ getCookie: requestHeaders?.get?.("cookie"),
522
+ };
523
+ }
524
+
525
+ // Call like better-auth does: auth.api.getSession({ headers: request.headers })
526
+ const result = createInternalContext({ headers: request.headers });
527
+
528
+ return Response.json(result);
529
+ }
530
+ });
531
+ `);
532
+
533
+ const response = await fetchHandle.dispatchRequest(
534
+ new Request("http://localhost/test", {
535
+ headers: {
536
+ cookie: "better-auth.session_token=abc123.signature",
537
+ },
538
+ })
539
+ );
540
+
541
+ const data = await response.json();
542
+ assert.strictEqual(data.requestHeadersType, "Headers");
543
+ assert.strictEqual(data.hasCookie, true);
544
+ assert.strictEqual(data.getCookie, "better-auth.session_token=abc123.signature");
545
+ });
546
+
547
+ test("headers.entries() should iterate all headers including cookies", async () => {
548
+ context.evalSync(`
549
+ serve({
550
+ async fetch(request) {
551
+ const entries = [];
552
+ for (const [key, value] of request.headers.entries()) {
553
+ entries.push({ key, value });
554
+ }
555
+
556
+ const cookieEntry = entries.find(e => e.key === "cookie");
557
+
558
+ return Response.json({
559
+ entriesCount: entries.length,
560
+ hasCookieEntry: !!cookieEntry,
561
+ cookieValue: cookieEntry?.value,
562
+ });
563
+ }
564
+ });
565
+ `);
566
+
567
+ const response = await fetchHandle.dispatchRequest(
568
+ new Request("http://localhost/test", {
569
+ headers: {
570
+ cookie: "session=test123",
571
+ "content-type": "application/json",
572
+ },
573
+ })
574
+ );
575
+
576
+ const data = await response.json();
577
+ assert.strictEqual(data.hasCookieEntry, true);
578
+ assert.strictEqual(data.cookieValue, "session=test123");
579
+ });
580
+
581
+ test("async function receiving headers should preserve cookie access", async () => {
582
+ context.evalSync(`
583
+ serve({
584
+ async fetch(request) {
585
+ async function getSession(options) {
586
+ // Simulate async processing like better-auth does
587
+ await Promise.resolve();
588
+
589
+ const headers = options.headers;
590
+ return {
591
+ hasCookie: headers.has("cookie"),
592
+ cookieValue: headers.get("cookie"),
593
+ };
594
+ }
595
+
596
+ const session = await getSession({ headers: request.headers });
597
+
598
+ return Response.json(session);
599
+ }
600
+ });
601
+ `);
602
+
603
+ const response = await fetchHandle.dispatchRequest(
604
+ new Request("http://localhost/test", {
605
+ headers: {
606
+ cookie: "auth_token=secret123",
607
+ },
608
+ })
609
+ );
610
+
611
+ const data = await response.json();
612
+ assert.strictEqual(data.hasCookie, true);
613
+ assert.strictEqual(data.cookieValue, "auth_token=secret123");
614
+ });
615
+
616
+ test("multiple async hops should preserve headers", async () => {
617
+ context.evalSync(`
618
+ serve({
619
+ async fetch(request) {
620
+ // Level 1: Router context
621
+ async function routerContext(req) {
622
+ return await authApi({ headers: req.headers });
623
+ }
624
+
625
+ // Level 2: Auth API
626
+ async function authApi(options) {
627
+ await Promise.resolve();
628
+ return await createInternalContext(options);
629
+ }
630
+
631
+ // Level 3: Internal context (like better-call)
632
+ async function createInternalContext(context) {
633
+ await Promise.resolve();
634
+ const headers = context.headers;
635
+
636
+ return {
637
+ level: "createInternalContext",
638
+ hasCookie: headers?.has?.("cookie") ?? false,
639
+ getCookie: headers?.get?.("cookie") ?? null,
640
+ constructorName: headers?.constructor?.name,
641
+ };
642
+ }
643
+
644
+ const result = await routerContext(request);
645
+
646
+ return Response.json(result);
647
+ }
648
+ });
649
+ `);
650
+
651
+ const response = await fetchHandle.dispatchRequest(
652
+ new Request("http://localhost/test", {
653
+ headers: {
654
+ cookie: "session_token=deep_test_123",
655
+ },
656
+ })
657
+ );
658
+
659
+ const data = await response.json();
660
+ assert.strictEqual(data.level, "createInternalContext");
661
+ assert.strictEqual(data.hasCookie, true);
662
+ assert.strictEqual(data.getCookie, "session_token=deep_test_123");
663
+ assert.strictEqual(data.constructorName, "Headers");
664
+ });
665
+ });