@ricsam/isolate-fetch 0.1.11 → 0.1.13

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.
@@ -410,6 +410,9 @@ function setupStreamCallbacks(context, streamRegistry) {
410
410
  global.setSync("__Stream_isQueueFull", new import_isolated_vm.default.Callback((streamId) => {
411
411
  return streamRegistry.isQueueFull(streamId);
412
412
  }));
413
+ global.setSync("__Stream_cancel", new import_isolated_vm.default.Callback((streamId) => {
414
+ streamRegistry.cancel(streamId);
415
+ }));
413
416
  const pullRef = new import_isolated_vm.default.Reference(async (streamId) => {
414
417
  const result = await streamRegistry.pull(streamId);
415
418
  if (result.done) {
@@ -460,7 +463,7 @@ var hostBackedStreamCode = `
460
463
  async pull(controller) {
461
464
  if (closed) return;
462
465
 
463
- const resultJson = __Stream_pull_ref.applySyncPromise(undefined, [streamId]);
466
+ const resultJson = await __Stream_pull_ref.apply(undefined, [streamId], { result: { promise: true, copy: true } });
464
467
  const result = JSON.parse(resultJson);
465
468
 
466
469
  if (result.done) {
@@ -472,7 +475,7 @@ var hostBackedStreamCode = `
472
475
  },
473
476
  cancel(reason) {
474
477
  closed = true;
475
- __Stream_error(streamId, String(reason || "cancelled"));
478
+ __Stream_cancel(streamId);
476
479
  }
477
480
  });
478
481
 
@@ -499,7 +502,7 @@ var hostBackedStreamCode = `
499
502
  globalThis.HostBackedReadableStream = HostBackedReadableStream;
500
503
  })();
501
504
  `;
502
- function setupResponse(context, stateMap) {
505
+ function setupResponse(context, stateMap, streamRegistry) {
503
506
  const global = context.global;
504
507
  global.setSync("__Response_construct", new import_isolated_vm.default.Callback((bodyBytes, status, statusText, headers) => {
505
508
  const instanceId = nextInstanceId++;
@@ -579,6 +582,10 @@ function setupResponse(context, stateMap) {
579
582
  const state = stateMap.get(instanceId);
580
583
  return state?.type ?? "default";
581
584
  }));
585
+ global.setSync("__Response_get_nullBody", new import_isolated_vm.default.Callback((instanceId) => {
586
+ const state = stateMap.get(instanceId);
587
+ return state?.nullBody ?? false;
588
+ }));
582
589
  global.setSync("__Response_setType", new import_isolated_vm.default.Callback((instanceId, type) => {
583
590
  const state = stateMap.get(instanceId);
584
591
  if (state) {
@@ -612,6 +619,38 @@ function setupResponse(context, stateMap) {
612
619
  if (!state) {
613
620
  throw new Error("[TypeError]Cannot clone invalid Response");
614
621
  }
622
+ if (state.streamId !== null) {
623
+ const streamId1 = streamRegistry.create();
624
+ const streamId2 = streamRegistry.create();
625
+ const origStreamId = state.streamId;
626
+ (async () => {
627
+ try {
628
+ while (true) {
629
+ const result = await streamRegistry.pull(origStreamId);
630
+ if (result.done) {
631
+ streamRegistry.close(streamId1);
632
+ streamRegistry.close(streamId2);
633
+ break;
634
+ }
635
+ streamRegistry.push(streamId1, new Uint8Array(result.value));
636
+ streamRegistry.push(streamId2, new Uint8Array(result.value));
637
+ }
638
+ } catch (err) {
639
+ streamRegistry.error(streamId1, err);
640
+ streamRegistry.error(streamId2, err);
641
+ }
642
+ })();
643
+ state.streamId = streamId1;
644
+ const newId2 = nextInstanceId++;
645
+ const newState2 = {
646
+ ...state,
647
+ streamId: streamId2,
648
+ body: state.body ? new Uint8Array(state.body) : null,
649
+ bodyUsed: false
650
+ };
651
+ stateMap.set(newId2, newState2);
652
+ return newId2;
653
+ }
615
654
  const newId = nextInstanceId++;
616
655
  const newState = {
617
656
  ...state,
@@ -838,6 +877,11 @@ function setupResponse(context, stateMap) {
838
877
  }
839
878
 
840
879
  get body() {
880
+ // Null-body responses (204, 304, HEAD) must return null
881
+ if (__Response_get_nullBody(this.#instanceId)) {
882
+ return null;
883
+ }
884
+
841
885
  // Return cached body if available (WHATWG spec requires same object on repeated access)
842
886
  if (this.#cachedBody !== null) {
843
887
  return this.#cachedBody;
@@ -869,6 +913,26 @@ function setupResponse(context, stateMap) {
869
913
  } catch (err) {
870
914
  throw __decodeError(err);
871
915
  }
916
+ if (__Response_get_nullBody(this.#instanceId)) {
917
+ return "";
918
+ }
919
+ if (this.#streamId !== null) {
920
+ const reader = this.body.getReader();
921
+ const chunks = [];
922
+ while (true) {
923
+ const { done, value } = await reader.read();
924
+ if (done) break;
925
+ if (value) chunks.push(value);
926
+ }
927
+ const totalLength = chunks.reduce((acc, c) => acc + c.length, 0);
928
+ const result = new Uint8Array(totalLength);
929
+ let offset = 0;
930
+ for (const chunk of chunks) {
931
+ result.set(chunk, offset);
932
+ offset += chunk.length;
933
+ }
934
+ return new TextDecoder().decode(result);
935
+ }
872
936
  return __Response_text(this.#instanceId);
873
937
  }
874
938
 
@@ -1119,30 +1183,10 @@ function setupRequest(context, stateMap) {
1119
1183
  return Array.from(new TextEncoder().encode(body.toString()));
1120
1184
  }
1121
1185
  if (body instanceof FormData) {
1122
- // Check if FormData has any File/Blob entries
1123
- let hasFiles = false;
1124
- for (const [, value] of body.entries()) {
1125
- if (value instanceof File || value instanceof Blob) {
1126
- hasFiles = true;
1127
- break;
1128
- }
1129
- }
1130
-
1131
- if (hasFiles) {
1132
- // Serialize as multipart/form-data
1133
- const { body: bytes, contentType } = __serializeFormData(body);
1134
- globalThis.__pendingFormDataContentType = contentType;
1135
- return Array.from(bytes);
1136
- }
1137
-
1138
- // URL-encoded for string-only FormData
1139
- const parts = [];
1140
- body.forEach((value, key) => {
1141
- if (typeof value === 'string') {
1142
- parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value));
1143
- }
1144
- });
1145
- return Array.from(new TextEncoder().encode(parts.join('&')));
1186
+ // Always serialize as multipart/form-data per spec
1187
+ const { body: bytes, contentType } = __serializeFormData(body);
1188
+ globalThis.__pendingFormDataContentType = contentType;
1189
+ return Array.from(bytes);
1146
1190
  }
1147
1191
  // Try to convert to string
1148
1192
  return Array.from(new TextEncoder().encode(String(body)));
@@ -1240,7 +1284,7 @@ function setupRequest(context, stateMap) {
1240
1284
  if (globalThis.__pendingFormDataContentType) {
1241
1285
  headers.set('content-type', globalThis.__pendingFormDataContentType);
1242
1286
  delete globalThis.__pendingFormDataContentType;
1243
- } else if (body instanceof FormData && !headers.has('content-type')) {
1287
+ } else if (body instanceof URLSearchParams && !headers.has('content-type')) {
1244
1288
  headers.set('content-type', 'application/x-www-form-urlencoded');
1245
1289
  }
1246
1290
 
@@ -1430,32 +1474,56 @@ function setupRequest(context, stateMap) {
1430
1474
  var FETCH_STREAM_THRESHOLD = 64 * 1024;
1431
1475
  function setupFetchFunction(context, stateMap, streamRegistry, options) {
1432
1476
  const global = context.global;
1433
- const fetchRef = new import_isolated_vm.default.Reference(async (url, method, headersJson, bodyJson, signalAborted) => {
1477
+ const fetchAbortControllers = new Map;
1478
+ global.setSync("__fetch_abort", new import_isolated_vm.default.Callback((fetchId) => {
1479
+ const controller = fetchAbortControllers.get(fetchId);
1480
+ if (controller) {
1481
+ setImmediate(() => controller.abort());
1482
+ }
1483
+ }));
1484
+ const fetchRef = new import_isolated_vm.default.Reference(async (url, method, headersJson, bodyJson, signalAborted, fetchId) => {
1434
1485
  if (signalAborted) {
1435
1486
  throw new Error("[AbortError]The operation was aborted.");
1436
1487
  }
1488
+ const hostController = new AbortController;
1489
+ fetchAbortControllers.set(fetchId, hostController);
1437
1490
  const headers = JSON.parse(headersJson);
1438
1491
  const bodyBytes = bodyJson ? JSON.parse(bodyJson) : null;
1439
1492
  const body = bodyBytes ? new Uint8Array(bodyBytes) : null;
1440
1493
  const nativeRequest = new Request(url, {
1441
1494
  method,
1442
1495
  headers,
1443
- body
1496
+ body,
1497
+ signal: hostController.signal
1444
1498
  });
1445
1499
  const onFetch = options?.onFetch ?? fetch;
1446
- const nativeResponse = await onFetch(nativeRequest);
1447
- const contentLength = nativeResponse.headers.get("content-length");
1448
- const knownSize = contentLength ? parseInt(contentLength, 10) : null;
1449
- const isCallbackStream = nativeResponse.__isCallbackStream;
1450
- const isNetworkResponse = nativeResponse.url && (nativeResponse.url.startsWith("http://") || nativeResponse.url.startsWith("https://"));
1451
- const shouldStream = nativeResponse.body && (isCallbackStream || isNetworkResponse && (knownSize === null || knownSize > FETCH_STREAM_THRESHOLD));
1452
- if (shouldStream && nativeResponse.body) {
1453
- if (isCallbackStream) {
1454
- const streamId2 = streamRegistry.create();
1455
- const passthruMap = getPassthruBodiesForContext(context);
1456
- passthruMap.set(streamId2, nativeResponse.body);
1457
- const instanceId3 = nextInstanceId++;
1458
- const state3 = {
1500
+ try {
1501
+ let cleanupAbort;
1502
+ const abortPromise = new Promise((_, reject) => {
1503
+ if (hostController.signal.aborted) {
1504
+ reject(Object.assign(new Error("The operation was aborted."), { name: "AbortError" }));
1505
+ return;
1506
+ }
1507
+ const onAbort = () => {
1508
+ reject(Object.assign(new Error("The operation was aborted."), { name: "AbortError" }));
1509
+ };
1510
+ hostController.signal.addEventListener("abort", onAbort, { once: true });
1511
+ cleanupAbort = () => hostController.signal.removeEventListener("abort", onAbort);
1512
+ });
1513
+ abortPromise.catch(() => {});
1514
+ const nativeResponse = await Promise.race([onFetch(nativeRequest), abortPromise]);
1515
+ cleanupAbort?.();
1516
+ const status = nativeResponse.status;
1517
+ const isNullBody = status === 204 || status === 304 || method.toUpperCase() === "HEAD";
1518
+ const isCallbackStream = nativeResponse.__isCallbackStream;
1519
+ const isNetworkResponse = nativeResponse.url && (nativeResponse.url.startsWith("http://") || nativeResponse.url.startsWith("https://"));
1520
+ const shouldStream = !isNullBody && nativeResponse.body && (isCallbackStream || isNetworkResponse);
1521
+ if (shouldStream && nativeResponse.body) {
1522
+ const streamId = streamRegistry.create();
1523
+ const streamCleanupFn = import_stream_state.startNativeStreamReader(nativeResponse.body, streamId, streamRegistry);
1524
+ streamRegistry.setCleanup(streamId, streamCleanupFn);
1525
+ const instanceId2 = nextInstanceId++;
1526
+ const state2 = {
1459
1527
  status: nativeResponse.status,
1460
1528
  statusText: nativeResponse.statusText,
1461
1529
  headers: Array.from(nativeResponse.headers.entries()),
@@ -1464,65 +1532,37 @@ function setupFetchFunction(context, stateMap, streamRegistry, options) {
1464
1532
  type: "default",
1465
1533
  url: nativeResponse.url,
1466
1534
  redirected: nativeResponse.redirected,
1467
- streamId: streamId2
1535
+ streamId,
1536
+ nullBody: isNullBody
1468
1537
  };
1469
- stateMap.set(instanceId3, state3);
1470
- return instanceId3;
1538
+ stateMap.set(instanceId2, state2);
1539
+ return instanceId2;
1471
1540
  }
1472
- const streamId = streamRegistry.create();
1473
- const instanceId2 = nextInstanceId++;
1474
- const state2 = {
1541
+ const responseBody = await nativeResponse.arrayBuffer();
1542
+ const responseBodyArray = Array.from(new Uint8Array(responseBody));
1543
+ const instanceId = nextInstanceId++;
1544
+ const state = {
1475
1545
  status: nativeResponse.status,
1476
1546
  statusText: nativeResponse.statusText,
1477
1547
  headers: Array.from(nativeResponse.headers.entries()),
1478
- body: new Uint8Array(0),
1548
+ body: new Uint8Array(responseBodyArray),
1479
1549
  bodyUsed: false,
1480
1550
  type: "default",
1481
1551
  url: nativeResponse.url,
1482
1552
  redirected: nativeResponse.redirected,
1483
- streamId
1553
+ streamId: null,
1554
+ nullBody: isNullBody
1484
1555
  };
1485
- stateMap.set(instanceId2, state2);
1486
- const reader = nativeResponse.body.getReader();
1487
- (async () => {
1488
- try {
1489
- while (true) {
1490
- const { done, value } = await reader.read();
1491
- if (done) {
1492
- streamRegistry.close(streamId);
1493
- break;
1494
- }
1495
- if (value) {
1496
- while (streamRegistry.isQueueFull(streamId)) {
1497
- await new Promise((r) => setTimeout(r, 1));
1498
- }
1499
- streamRegistry.push(streamId, value);
1500
- }
1501
- }
1502
- } catch (err) {
1503
- streamRegistry.error(streamId, err);
1504
- } finally {
1505
- reader.releaseLock();
1506
- }
1507
- })();
1508
- return instanceId2;
1556
+ stateMap.set(instanceId, state);
1557
+ return instanceId;
1558
+ } catch (err) {
1559
+ if (err instanceof Error && err.name === "AbortError") {
1560
+ throw new Error("[AbortError]The operation was aborted.");
1561
+ }
1562
+ throw err;
1563
+ } finally {
1564
+ fetchAbortControllers.delete(fetchId);
1509
1565
  }
1510
- const responseBody = await nativeResponse.arrayBuffer();
1511
- const responseBodyArray = Array.from(new Uint8Array(responseBody));
1512
- const instanceId = nextInstanceId++;
1513
- const state = {
1514
- status: nativeResponse.status,
1515
- statusText: nativeResponse.statusText,
1516
- headers: Array.from(nativeResponse.headers.entries()),
1517
- body: new Uint8Array(responseBodyArray),
1518
- bodyUsed: false,
1519
- type: "default",
1520
- url: nativeResponse.url,
1521
- redirected: nativeResponse.redirected,
1522
- streamId: null
1523
- };
1524
- stateMap.set(instanceId, state);
1525
- return instanceId;
1526
1566
  });
1527
1567
  global.setSync("__fetch_ref", fetchRef);
1528
1568
  const fetchCode = `
@@ -1540,7 +1580,28 @@ function setupFetchFunction(context, stateMap, streamRegistry, options) {
1540
1580
  return err;
1541
1581
  }
1542
1582
 
1543
- globalThis.fetch = function(input, init = {}) {
1583
+ let __nextFetchId = 1;
1584
+
1585
+ globalThis.fetch = async function(input, init = {}) {
1586
+ // Handle Blob and ReadableStream bodies before creating Request
1587
+ if (init.body instanceof Blob && !(init.body instanceof File)) {
1588
+ const buf = await init.body.arrayBuffer();
1589
+ init = Object.assign({}, init, { body: new Uint8Array(buf) });
1590
+ } else if (init.body instanceof ReadableStream) {
1591
+ const reader = init.body.getReader();
1592
+ const chunks = [];
1593
+ while (true) {
1594
+ const { done, value } = await reader.read();
1595
+ if (done) break;
1596
+ chunks.push(value);
1597
+ }
1598
+ const total = chunks.reduce((s, c) => s + c.length, 0);
1599
+ const buf = new Uint8Array(total);
1600
+ let off = 0;
1601
+ for (const c of chunks) { buf.set(c, off); off += c.length; }
1602
+ init = Object.assign({}, init, { body: buf });
1603
+ }
1604
+
1544
1605
  // Create Request from input
1545
1606
  const request = input instanceof Request ? input : new Request(input, init);
1546
1607
 
@@ -1548,20 +1609,34 @@ function setupFetchFunction(context, stateMap, streamRegistry, options) {
1548
1609
  const signal = init.signal ?? request.signal;
1549
1610
  const signalAborted = signal?.aborted ?? false;
1550
1611
 
1612
+ // Assign a fetch ID for abort tracking
1613
+ const fetchId = __nextFetchId++;
1614
+
1615
+ // Register abort listener if signal exists
1616
+ if (signal && !signalAborted) {
1617
+ signal.addEventListener('abort', () => { __fetch_abort(fetchId); });
1618
+ }
1619
+
1551
1620
  // Serialize headers and body to JSON for transfer
1552
1621
  const headersJson = JSON.stringify(Array.from(request.headers.entries()));
1553
1622
  const bodyBytes = request._getBodyBytes();
1554
1623
  const bodyJson = bodyBytes ? JSON.stringify(bodyBytes) : null;
1555
1624
 
1625
+ // Short-circuit: if signal is already aborted, throw without calling host
1626
+ if (signalAborted) {
1627
+ throw new DOMException('The operation was aborted.', 'AbortError');
1628
+ }
1629
+
1556
1630
  // Call host - returns just the response instance ID
1557
1631
  try {
1558
- const instanceId = __fetch_ref.applySyncPromise(undefined, [
1632
+ const instanceId = await __fetch_ref.apply(undefined, [
1559
1633
  request.url,
1560
1634
  request.method,
1561
1635
  headersJson,
1562
1636
  bodyJson,
1563
- signalAborted
1564
- ]);
1637
+ signalAborted,
1638
+ fetchId
1639
+ ], { result: { promise: true, copy: true } });
1565
1640
 
1566
1641
  // Construct Response from the instance ID
1567
1642
  return Response._fromInstanceId(instanceId);
@@ -1678,7 +1753,7 @@ async function setupFetch(context, options) {
1678
1753
  context.evalSync(multipartCode);
1679
1754
  setupStreamCallbacks(context, streamRegistry);
1680
1755
  context.evalSync(hostBackedStreamCode);
1681
- setupResponse(context, stateMap);
1756
+ setupResponse(context, stateMap, streamRegistry);
1682
1757
  setupRequest(context, stateMap);
1683
1758
  setupFetchFunction(context, stateMap, streamRegistry, options);
1684
1759
  const serveState = {
@@ -1801,8 +1876,7 @@ async function setupFetch(context, options) {
1801
1876
  },
1802
1877
  cancel() {
1803
1878
  streamDone = true;
1804
- streamRegistry.error(responseStreamId, new Error("Stream cancelled"));
1805
- streamRegistry.delete(responseStreamId);
1879
+ streamRegistry.cancel(responseStreamId);
1806
1880
  }
1807
1881
  });
1808
1882
  const responseHeaders2 = new Headers(responseState.headers);
@@ -1951,4 +2025,4 @@ async function setupFetch(context, options) {
1951
2025
  };
1952
2026
  }
1953
2027
 
1954
- //# debugId=B1E0412945057BF364756E2164756E21
2028
+ //# debugId=E8993FE5C4E862A664756E2164756E21