@ricsam/isolate-fetch 0.1.12 → 0.1.14

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.
@@ -80,25 +80,32 @@ var headersCode = `
80
80
  }
81
81
 
82
82
  forEach(callback, thisArg) {
83
- for (const [key, [originalName, values]] of this.#headers) {
84
- callback.call(thisArg, values.join(', '), originalName, this);
83
+ const sortedKeys = [...this.#headers.keys()].sort();
84
+ for (const key of sortedKeys) {
85
+ const [, values] = this.#headers.get(key);
86
+ callback.call(thisArg, values.join(', '), key, this);
85
87
  }
86
88
  }
87
89
 
88
90
  *entries() {
89
- for (const [key, [name, values]] of this.#headers) {
90
- yield [name, values.join(', ')];
91
+ const sortedKeys = [...this.#headers.keys()].sort();
92
+ for (const key of sortedKeys) {
93
+ const [, values] = this.#headers.get(key);
94
+ yield [key, values.join(', ')];
91
95
  }
92
96
  }
93
97
 
94
98
  *keys() {
95
- for (const [key, [name]] of this.#headers) {
96
- yield name;
99
+ const sortedKeys = [...this.#headers.keys()].sort();
100
+ for (const key of sortedKeys) {
101
+ yield key;
97
102
  }
98
103
  }
99
104
 
100
105
  *values() {
101
- for (const [key, [name, values]] of this.#headers) {
106
+ const sortedKeys = [...this.#headers.keys()].sort();
107
+ for (const key of sortedKeys) {
108
+ const [, values] = this.#headers.get(key);
102
109
  yield values.join(', ');
103
110
  }
104
111
  }
@@ -366,6 +373,9 @@ function setupStreamCallbacks(context, streamRegistry) {
366
373
  global.setSync("__Stream_isQueueFull", new ivm.Callback((streamId) => {
367
374
  return streamRegistry.isQueueFull(streamId);
368
375
  }));
376
+ global.setSync("__Stream_cancel", new ivm.Callback((streamId) => {
377
+ streamRegistry.cancel(streamId);
378
+ }));
369
379
  const pullRef = new ivm.Reference(async (streamId) => {
370
380
  const result = await streamRegistry.pull(streamId);
371
381
  if (result.done) {
@@ -416,7 +426,7 @@ var hostBackedStreamCode = `
416
426
  async pull(controller) {
417
427
  if (closed) return;
418
428
 
419
- const resultJson = __Stream_pull_ref.applySyncPromise(undefined, [streamId]);
429
+ const resultJson = await __Stream_pull_ref.apply(undefined, [streamId], { result: { promise: true, copy: true } });
420
430
  const result = JSON.parse(resultJson);
421
431
 
422
432
  if (result.done) {
@@ -428,7 +438,7 @@ var hostBackedStreamCode = `
428
438
  },
429
439
  cancel(reason) {
430
440
  closed = true;
431
- __Stream_error(streamId, String(reason || "cancelled"));
441
+ __Stream_cancel(streamId);
432
442
  }
433
443
  });
434
444
 
@@ -455,7 +465,7 @@ var hostBackedStreamCode = `
455
465
  globalThis.HostBackedReadableStream = HostBackedReadableStream;
456
466
  })();
457
467
  `;
458
- function setupResponse(context, stateMap) {
468
+ function setupResponse(context, stateMap, streamRegistry) {
459
469
  const global = context.global;
460
470
  global.setSync("__Response_construct", new ivm.Callback((bodyBytes, status, statusText, headers) => {
461
471
  const instanceId = nextInstanceId++;
@@ -535,6 +545,10 @@ function setupResponse(context, stateMap) {
535
545
  const state = stateMap.get(instanceId);
536
546
  return state?.type ?? "default";
537
547
  }));
548
+ global.setSync("__Response_get_nullBody", new ivm.Callback((instanceId) => {
549
+ const state = stateMap.get(instanceId);
550
+ return state?.nullBody ?? false;
551
+ }));
538
552
  global.setSync("__Response_setType", new ivm.Callback((instanceId, type) => {
539
553
  const state = stateMap.get(instanceId);
540
554
  if (state) {
@@ -568,6 +582,38 @@ function setupResponse(context, stateMap) {
568
582
  if (!state) {
569
583
  throw new Error("[TypeError]Cannot clone invalid Response");
570
584
  }
585
+ if (state.streamId !== null) {
586
+ const streamId1 = streamRegistry.create();
587
+ const streamId2 = streamRegistry.create();
588
+ const origStreamId = state.streamId;
589
+ (async () => {
590
+ try {
591
+ while (true) {
592
+ const result = await streamRegistry.pull(origStreamId);
593
+ if (result.done) {
594
+ streamRegistry.close(streamId1);
595
+ streamRegistry.close(streamId2);
596
+ break;
597
+ }
598
+ streamRegistry.push(streamId1, new Uint8Array(result.value));
599
+ streamRegistry.push(streamId2, new Uint8Array(result.value));
600
+ }
601
+ } catch (err) {
602
+ streamRegistry.error(streamId1, err);
603
+ streamRegistry.error(streamId2, err);
604
+ }
605
+ })();
606
+ state.streamId = streamId1;
607
+ const newId2 = nextInstanceId++;
608
+ const newState2 = {
609
+ ...state,
610
+ streamId: streamId2,
611
+ body: state.body ? new Uint8Array(state.body) : null,
612
+ bodyUsed: false
613
+ };
614
+ stateMap.set(newId2, newState2);
615
+ return newId2;
616
+ }
571
617
  const newId = nextInstanceId++;
572
618
  const newState = {
573
619
  ...state,
@@ -794,6 +840,11 @@ function setupResponse(context, stateMap) {
794
840
  }
795
841
 
796
842
  get body() {
843
+ // Null-body responses (204, 304, HEAD) must return null
844
+ if (__Response_get_nullBody(this.#instanceId)) {
845
+ return null;
846
+ }
847
+
797
848
  // Return cached body if available (WHATWG spec requires same object on repeated access)
798
849
  if (this.#cachedBody !== null) {
799
850
  return this.#cachedBody;
@@ -825,6 +876,26 @@ function setupResponse(context, stateMap) {
825
876
  } catch (err) {
826
877
  throw __decodeError(err);
827
878
  }
879
+ if (__Response_get_nullBody(this.#instanceId)) {
880
+ return "";
881
+ }
882
+ if (this.#streamId !== null) {
883
+ const reader = this.body.getReader();
884
+ const chunks = [];
885
+ while (true) {
886
+ const { done, value } = await reader.read();
887
+ if (done) break;
888
+ if (value) chunks.push(value);
889
+ }
890
+ const totalLength = chunks.reduce((acc, c) => acc + c.length, 0);
891
+ const result = new Uint8Array(totalLength);
892
+ let offset = 0;
893
+ for (const chunk of chunks) {
894
+ result.set(chunk, offset);
895
+ offset += chunk.length;
896
+ }
897
+ return new TextDecoder().decode(result);
898
+ }
828
899
  return __Response_text(this.#instanceId);
829
900
  }
830
901
 
@@ -1075,30 +1146,10 @@ function setupRequest(context, stateMap) {
1075
1146
  return Array.from(new TextEncoder().encode(body.toString()));
1076
1147
  }
1077
1148
  if (body instanceof FormData) {
1078
- // Check if FormData has any File/Blob entries
1079
- let hasFiles = false;
1080
- for (const [, value] of body.entries()) {
1081
- if (value instanceof File || value instanceof Blob) {
1082
- hasFiles = true;
1083
- break;
1084
- }
1085
- }
1086
-
1087
- if (hasFiles) {
1088
- // Serialize as multipart/form-data
1089
- const { body: bytes, contentType } = __serializeFormData(body);
1090
- globalThis.__pendingFormDataContentType = contentType;
1091
- return Array.from(bytes);
1092
- }
1093
-
1094
- // URL-encoded for string-only FormData
1095
- const parts = [];
1096
- body.forEach((value, key) => {
1097
- if (typeof value === 'string') {
1098
- parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value));
1099
- }
1100
- });
1101
- return Array.from(new TextEncoder().encode(parts.join('&')));
1149
+ // Always serialize as multipart/form-data per spec
1150
+ const { body: bytes, contentType } = __serializeFormData(body);
1151
+ globalThis.__pendingFormDataContentType = contentType;
1152
+ return Array.from(bytes);
1102
1153
  }
1103
1154
  // Try to convert to string
1104
1155
  return Array.from(new TextEncoder().encode(String(body)));
@@ -1196,7 +1247,7 @@ function setupRequest(context, stateMap) {
1196
1247
  if (globalThis.__pendingFormDataContentType) {
1197
1248
  headers.set('content-type', globalThis.__pendingFormDataContentType);
1198
1249
  delete globalThis.__pendingFormDataContentType;
1199
- } else if (body instanceof FormData && !headers.has('content-type')) {
1250
+ } else if (body instanceof URLSearchParams && !headers.has('content-type')) {
1200
1251
  headers.set('content-type', 'application/x-www-form-urlencoded');
1201
1252
  }
1202
1253
 
@@ -1386,32 +1437,62 @@ function setupRequest(context, stateMap) {
1386
1437
  var FETCH_STREAM_THRESHOLD = 64 * 1024;
1387
1438
  function setupFetchFunction(context, stateMap, streamRegistry, options) {
1388
1439
  const global = context.global;
1389
- const fetchRef = new ivm.Reference(async (url, method, headersJson, bodyJson, signalAborted) => {
1440
+ const fetchAbortControllers = new Map;
1441
+ global.setSync("__fetch_abort", new ivm.Callback((fetchId) => {
1442
+ const controller = fetchAbortControllers.get(fetchId);
1443
+ if (controller) {
1444
+ setImmediate(() => controller.abort());
1445
+ }
1446
+ }));
1447
+ const fetchRef = new ivm.Reference(async (url, method, headersJson, bodyJson, signalAborted, fetchId) => {
1390
1448
  if (signalAborted) {
1391
1449
  throw new Error("[AbortError]The operation was aborted.");
1392
1450
  }
1451
+ const hostController = new AbortController;
1452
+ fetchAbortControllers.set(fetchId, hostController);
1393
1453
  const headers = JSON.parse(headersJson);
1394
1454
  const bodyBytes = bodyJson ? JSON.parse(bodyJson) : null;
1395
- const body = bodyBytes ? new Uint8Array(bodyBytes) : null;
1396
- const nativeRequest = new Request(url, {
1455
+ const rawBody = bodyBytes ? new Uint8Array(bodyBytes) : null;
1456
+ const init = {
1397
1457
  method,
1398
1458
  headers,
1399
- body
1400
- });
1401
- const onFetch = options?.onFetch ?? fetch;
1402
- const nativeResponse = await onFetch(nativeRequest);
1403
- const contentLength = nativeResponse.headers.get("content-length");
1404
- const knownSize = contentLength ? parseInt(contentLength, 10) : null;
1405
- const isCallbackStream = nativeResponse.__isCallbackStream;
1406
- const isNetworkResponse = nativeResponse.url && (nativeResponse.url.startsWith("http://") || nativeResponse.url.startsWith("https://"));
1407
- const shouldStream = nativeResponse.body && (isCallbackStream || isNetworkResponse && (knownSize === null || knownSize > FETCH_STREAM_THRESHOLD));
1408
- if (shouldStream && nativeResponse.body) {
1409
- if (isCallbackStream) {
1410
- const streamId2 = streamRegistry.create();
1411
- const passthruMap = getPassthruBodiesForContext(context);
1412
- passthruMap.set(streamId2, nativeResponse.body);
1413
- const instanceId3 = nextInstanceId++;
1414
- const state3 = {
1459
+ rawBody,
1460
+ body: rawBody,
1461
+ signal: hostController.signal
1462
+ };
1463
+ const onFetch = options?.onFetch ?? ((url2, init2) => fetch(url2, {
1464
+ method: init2.method,
1465
+ headers: init2.headers,
1466
+ body: init2.body,
1467
+ signal: init2.signal
1468
+ }));
1469
+ try {
1470
+ let cleanupAbort;
1471
+ const abortPromise = new Promise((_, reject) => {
1472
+ if (hostController.signal.aborted) {
1473
+ reject(Object.assign(new Error("The operation was aborted."), { name: "AbortError" }));
1474
+ return;
1475
+ }
1476
+ const onAbort = () => {
1477
+ reject(Object.assign(new Error("The operation was aborted."), { name: "AbortError" }));
1478
+ };
1479
+ hostController.signal.addEventListener("abort", onAbort, { once: true });
1480
+ cleanupAbort = () => hostController.signal.removeEventListener("abort", onAbort);
1481
+ });
1482
+ abortPromise.catch(() => {});
1483
+ const nativeResponse = await Promise.race([onFetch(url, init), abortPromise]);
1484
+ cleanupAbort?.();
1485
+ const status = nativeResponse.status;
1486
+ const isNullBody = status === 204 || status === 304 || method.toUpperCase() === "HEAD";
1487
+ const isCallbackStream = nativeResponse.__isCallbackStream;
1488
+ const isNetworkResponse = nativeResponse.url && (nativeResponse.url.startsWith("http://") || nativeResponse.url.startsWith("https://"));
1489
+ const shouldStream = !isNullBody && nativeResponse.body && (isCallbackStream || isNetworkResponse);
1490
+ if (shouldStream && nativeResponse.body) {
1491
+ const streamId = streamRegistry.create();
1492
+ const streamCleanupFn = startNativeStreamReader(nativeResponse.body, streamId, streamRegistry);
1493
+ streamRegistry.setCleanup(streamId, streamCleanupFn);
1494
+ const instanceId2 = nextInstanceId++;
1495
+ const state2 = {
1415
1496
  status: nativeResponse.status,
1416
1497
  statusText: nativeResponse.statusText,
1417
1498
  headers: Array.from(nativeResponse.headers.entries()),
@@ -1420,65 +1501,37 @@ function setupFetchFunction(context, stateMap, streamRegistry, options) {
1420
1501
  type: "default",
1421
1502
  url: nativeResponse.url,
1422
1503
  redirected: nativeResponse.redirected,
1423
- streamId: streamId2
1504
+ streamId,
1505
+ nullBody: isNullBody
1424
1506
  };
1425
- stateMap.set(instanceId3, state3);
1426
- return instanceId3;
1507
+ stateMap.set(instanceId2, state2);
1508
+ return instanceId2;
1427
1509
  }
1428
- const streamId = streamRegistry.create();
1429
- const instanceId2 = nextInstanceId++;
1430
- const state2 = {
1510
+ const responseBody = await nativeResponse.arrayBuffer();
1511
+ const responseBodyArray = Array.from(new Uint8Array(responseBody));
1512
+ const instanceId = nextInstanceId++;
1513
+ const state = {
1431
1514
  status: nativeResponse.status,
1432
1515
  statusText: nativeResponse.statusText,
1433
1516
  headers: Array.from(nativeResponse.headers.entries()),
1434
- body: new Uint8Array(0),
1517
+ body: new Uint8Array(responseBodyArray),
1435
1518
  bodyUsed: false,
1436
1519
  type: "default",
1437
1520
  url: nativeResponse.url,
1438
1521
  redirected: nativeResponse.redirected,
1439
- streamId
1522
+ streamId: null,
1523
+ nullBody: isNullBody
1440
1524
  };
1441
- stateMap.set(instanceId2, state2);
1442
- const reader = nativeResponse.body.getReader();
1443
- (async () => {
1444
- try {
1445
- while (true) {
1446
- const { done, value } = await reader.read();
1447
- if (done) {
1448
- streamRegistry.close(streamId);
1449
- break;
1450
- }
1451
- if (value) {
1452
- while (streamRegistry.isQueueFull(streamId)) {
1453
- await new Promise((r) => setTimeout(r, 1));
1454
- }
1455
- streamRegistry.push(streamId, value);
1456
- }
1457
- }
1458
- } catch (err) {
1459
- streamRegistry.error(streamId, err);
1460
- } finally {
1461
- reader.releaseLock();
1462
- }
1463
- })();
1464
- return instanceId2;
1525
+ stateMap.set(instanceId, state);
1526
+ return instanceId;
1527
+ } catch (err) {
1528
+ if (err instanceof Error && err.name === "AbortError") {
1529
+ throw new Error("[AbortError]The operation was aborted.");
1530
+ }
1531
+ throw err;
1532
+ } finally {
1533
+ fetchAbortControllers.delete(fetchId);
1465
1534
  }
1466
- const responseBody = await nativeResponse.arrayBuffer();
1467
- const responseBodyArray = Array.from(new Uint8Array(responseBody));
1468
- const instanceId = nextInstanceId++;
1469
- const state = {
1470
- status: nativeResponse.status,
1471
- statusText: nativeResponse.statusText,
1472
- headers: Array.from(nativeResponse.headers.entries()),
1473
- body: new Uint8Array(responseBodyArray),
1474
- bodyUsed: false,
1475
- type: "default",
1476
- url: nativeResponse.url,
1477
- redirected: nativeResponse.redirected,
1478
- streamId: null
1479
- };
1480
- stateMap.set(instanceId, state);
1481
- return instanceId;
1482
1535
  });
1483
1536
  global.setSync("__fetch_ref", fetchRef);
1484
1537
  const fetchCode = `
@@ -1496,7 +1549,28 @@ function setupFetchFunction(context, stateMap, streamRegistry, options) {
1496
1549
  return err;
1497
1550
  }
1498
1551
 
1499
- globalThis.fetch = function(input, init = {}) {
1552
+ let __nextFetchId = 1;
1553
+
1554
+ globalThis.fetch = async function(input, init = {}) {
1555
+ // Handle Blob and ReadableStream bodies before creating Request
1556
+ if (init.body instanceof Blob && !(init.body instanceof File)) {
1557
+ const buf = await init.body.arrayBuffer();
1558
+ init = Object.assign({}, init, { body: new Uint8Array(buf) });
1559
+ } else if (init.body instanceof ReadableStream) {
1560
+ const reader = init.body.getReader();
1561
+ const chunks = [];
1562
+ while (true) {
1563
+ const { done, value } = await reader.read();
1564
+ if (done) break;
1565
+ chunks.push(value);
1566
+ }
1567
+ const total = chunks.reduce((s, c) => s + c.length, 0);
1568
+ const buf = new Uint8Array(total);
1569
+ let off = 0;
1570
+ for (const c of chunks) { buf.set(c, off); off += c.length; }
1571
+ init = Object.assign({}, init, { body: buf });
1572
+ }
1573
+
1500
1574
  // Create Request from input
1501
1575
  const request = input instanceof Request ? input : new Request(input, init);
1502
1576
 
@@ -1504,20 +1578,34 @@ function setupFetchFunction(context, stateMap, streamRegistry, options) {
1504
1578
  const signal = init.signal ?? request.signal;
1505
1579
  const signalAborted = signal?.aborted ?? false;
1506
1580
 
1581
+ // Assign a fetch ID for abort tracking
1582
+ const fetchId = __nextFetchId++;
1583
+
1584
+ // Register abort listener if signal exists
1585
+ if (signal && !signalAborted) {
1586
+ signal.addEventListener('abort', () => { __fetch_abort(fetchId); });
1587
+ }
1588
+
1507
1589
  // Serialize headers and body to JSON for transfer
1508
1590
  const headersJson = JSON.stringify(Array.from(request.headers.entries()));
1509
1591
  const bodyBytes = request._getBodyBytes();
1510
1592
  const bodyJson = bodyBytes ? JSON.stringify(bodyBytes) : null;
1511
1593
 
1594
+ // Short-circuit: if signal is already aborted, throw without calling host
1595
+ if (signalAborted) {
1596
+ throw new DOMException('The operation was aborted.', 'AbortError');
1597
+ }
1598
+
1512
1599
  // Call host - returns just the response instance ID
1513
1600
  try {
1514
- const instanceId = __fetch_ref.applySyncPromise(undefined, [
1601
+ const instanceId = await __fetch_ref.apply(undefined, [
1515
1602
  request.url,
1516
1603
  request.method,
1517
1604
  headersJson,
1518
1605
  bodyJson,
1519
- signalAborted
1520
- ]);
1606
+ signalAborted,
1607
+ fetchId
1608
+ ], { result: { promise: true, copy: true } });
1521
1609
 
1522
1610
  // Construct Response from the instance ID
1523
1611
  return Response._fromInstanceId(instanceId);
@@ -1612,6 +1700,373 @@ function setupServerWebSocket(context, wsCommandCallbacks) {
1612
1700
  })();
1613
1701
  `);
1614
1702
  }
1703
+ function setupClientWebSocket(context, clientWsCommandCallbacks) {
1704
+ const global = context.global;
1705
+ global.setSync("__WebSocket_connect", new ivm.Callback((socketId, url, protocols) => {
1706
+ const cmd = {
1707
+ type: "connect",
1708
+ socketId,
1709
+ url,
1710
+ protocols
1711
+ };
1712
+ for (const cb of clientWsCommandCallbacks)
1713
+ cb(cmd);
1714
+ }));
1715
+ global.setSync("__WebSocket_send", new ivm.Callback((socketId, data) => {
1716
+ const cmd = { type: "send", socketId, data };
1717
+ for (const cb of clientWsCommandCallbacks)
1718
+ cb(cmd);
1719
+ }));
1720
+ global.setSync("__WebSocket_close", new ivm.Callback((socketId, code, reason) => {
1721
+ const cmd = { type: "close", socketId, code, reason };
1722
+ for (const cb of clientWsCommandCallbacks)
1723
+ cb(cmd);
1724
+ }));
1725
+ context.evalSync(`
1726
+ (function() {
1727
+ // Socket ID counter
1728
+ let __nextSocketId = 1;
1729
+
1730
+ // Active sockets registry
1731
+ const __clientWebSockets = new Map();
1732
+
1733
+ // Simple Event class (if not defined globally)
1734
+ const _Event = globalThis.Event || class Event {
1735
+ constructor(type, options = {}) {
1736
+ this.type = type;
1737
+ this.bubbles = options.bubbles || false;
1738
+ this.cancelable = options.cancelable || false;
1739
+ this.defaultPrevented = false;
1740
+ this.timeStamp = Date.now();
1741
+ this.target = null;
1742
+ this.currentTarget = null;
1743
+ }
1744
+ preventDefault() {
1745
+ if (this.cancelable) this.defaultPrevented = true;
1746
+ }
1747
+ stopPropagation() {}
1748
+ stopImmediatePropagation() {}
1749
+ };
1750
+
1751
+ // MessageEvent class for WebSocket messages
1752
+ const _MessageEvent = globalThis.MessageEvent || class MessageEvent extends _Event {
1753
+ constructor(type, options = {}) {
1754
+ super(type, options);
1755
+ this.data = options.data !== undefined ? options.data : null;
1756
+ this.origin = options.origin || '';
1757
+ this.lastEventId = options.lastEventId || '';
1758
+ this.source = options.source || null;
1759
+ this.ports = options.ports || [];
1760
+ }
1761
+ };
1762
+
1763
+ // CloseEvent class for WebSocket close
1764
+ const _CloseEvent = globalThis.CloseEvent || class CloseEvent extends _Event {
1765
+ constructor(type, options = {}) {
1766
+ super(type, options);
1767
+ this.code = options.code !== undefined ? options.code : 0;
1768
+ this.reason = options.reason !== undefined ? options.reason : '';
1769
+ this.wasClean = options.wasClean !== undefined ? options.wasClean : false;
1770
+ }
1771
+ };
1772
+
1773
+ // Helper to dispatch events
1774
+ function dispatchEvent(ws, event) {
1775
+ const listeners = ws._listeners.get(event.type) || [];
1776
+ for (const listener of listeners) {
1777
+ try {
1778
+ listener.call(ws, event);
1779
+ } catch (e) {
1780
+ console.error('WebSocket event listener error:', e);
1781
+ }
1782
+ }
1783
+ // Also call on* handler if set
1784
+ const handler = ws['on' + event.type];
1785
+ if (typeof handler === 'function') {
1786
+ try {
1787
+ handler.call(ws, event);
1788
+ } catch (e) {
1789
+ console.error('WebSocket handler error:', e);
1790
+ }
1791
+ }
1792
+ }
1793
+
1794
+ class WebSocket {
1795
+ static CONNECTING = 0;
1796
+ static OPEN = 1;
1797
+ static CLOSING = 2;
1798
+ static CLOSED = 3;
1799
+
1800
+ #socketId;
1801
+ #url;
1802
+ #readyState = WebSocket.CONNECTING;
1803
+ #bufferedAmount = 0;
1804
+ #extensions = '';
1805
+ #protocol = '';
1806
+ #binaryType = 'blob';
1807
+ _listeners = new Map();
1808
+
1809
+ // Event handlers
1810
+ onopen = null;
1811
+ onmessage = null;
1812
+ onerror = null;
1813
+ onclose = null;
1814
+
1815
+ constructor(url, protocols) {
1816
+ // Validate URL
1817
+ let parsedUrl;
1818
+ try {
1819
+ parsedUrl = new URL(url);
1820
+ } catch (e) {
1821
+ throw new DOMException("Failed to construct 'WebSocket': The URL '" + url + "' is invalid.", 'SyntaxError');
1822
+ }
1823
+
1824
+ if (parsedUrl.protocol !== 'ws:' && parsedUrl.protocol !== 'wss:') {
1825
+ throw new DOMException("Failed to construct 'WebSocket': The URL's scheme must be either 'ws' or 'wss'.", 'SyntaxError');
1826
+ }
1827
+
1828
+ // Per WHATWG spec, fragments must be stripped from WebSocket URLs
1829
+ parsedUrl.hash = '';
1830
+ this.#url = parsedUrl.href;
1831
+ this.#socketId = String(__nextSocketId++);
1832
+
1833
+ // Normalize protocols to array
1834
+ let protocolArray = [];
1835
+ if (protocols !== undefined) {
1836
+ if (typeof protocols === 'string') {
1837
+ protocolArray = [protocols];
1838
+ } else if (Array.isArray(protocols)) {
1839
+ protocolArray = protocols;
1840
+ } else {
1841
+ protocolArray = [String(protocols)];
1842
+ }
1843
+ }
1844
+
1845
+ // Check for duplicate protocols
1846
+ const seen = new Set();
1847
+ for (const p of protocolArray) {
1848
+ if (seen.has(p)) {
1849
+ throw new DOMException("Failed to construct 'WebSocket': The subprotocol '" + p + "' is duplicated.", 'SyntaxError');
1850
+ }
1851
+ seen.add(p);
1852
+ }
1853
+
1854
+ // Register socket
1855
+ __clientWebSockets.set(this.#socketId, this);
1856
+
1857
+ // Call host to create connection
1858
+ __WebSocket_connect(this.#socketId, this.#url, protocolArray);
1859
+ }
1860
+
1861
+ get url() {
1862
+ return this.#url;
1863
+ }
1864
+
1865
+ get readyState() {
1866
+ return this.#readyState;
1867
+ }
1868
+
1869
+ get bufferedAmount() {
1870
+ return this.#bufferedAmount;
1871
+ }
1872
+
1873
+ get extensions() {
1874
+ return this.#extensions;
1875
+ }
1876
+
1877
+ get protocol() {
1878
+ return this.#protocol;
1879
+ }
1880
+
1881
+ get binaryType() {
1882
+ return this.#binaryType;
1883
+ }
1884
+
1885
+ set binaryType(value) {
1886
+ if (value !== 'blob' && value !== 'arraybuffer') {
1887
+ throw new DOMException("Failed to set the 'binaryType' property: '" + value + "' is not a valid value.", 'SyntaxError');
1888
+ }
1889
+ this.#binaryType = value;
1890
+ }
1891
+
1892
+ // ReadyState constants
1893
+ get CONNECTING() { return WebSocket.CONNECTING; }
1894
+ get OPEN() { return WebSocket.OPEN; }
1895
+ get CLOSING() { return WebSocket.CLOSING; }
1896
+ get CLOSED() { return WebSocket.CLOSED; }
1897
+
1898
+ send(data) {
1899
+ if (this.#readyState === WebSocket.CONNECTING) {
1900
+ throw new DOMException("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.", 'InvalidStateError');
1901
+ }
1902
+
1903
+ if (this.#readyState !== WebSocket.OPEN) {
1904
+ // Silently discard if not open (per spec)
1905
+ return;
1906
+ }
1907
+
1908
+ // Convert data to string for transfer
1909
+ let dataStr;
1910
+ if (typeof data === 'string') {
1911
+ dataStr = data;
1912
+ } else if (data instanceof ArrayBuffer) {
1913
+ // Convert ArrayBuffer to base64 for transfer
1914
+ const bytes = new Uint8Array(data);
1915
+ let binary = '';
1916
+ for (let i = 0; i < bytes.byteLength; i++) {
1917
+ binary += String.fromCharCode(bytes[i]);
1918
+ }
1919
+ dataStr = '__BINARY__' + btoa(binary);
1920
+ } else if (ArrayBuffer.isView(data)) {
1921
+ const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
1922
+ let binary = '';
1923
+ for (let i = 0; i < bytes.byteLength; i++) {
1924
+ binary += String.fromCharCode(bytes[i]);
1925
+ }
1926
+ dataStr = '__BINARY__' + btoa(binary);
1927
+ } else if (data instanceof Blob) {
1928
+ // Blob.arrayBuffer() is async, but send() is sync
1929
+ // For now, throw - this is a limitation
1930
+ throw new DOMException("Failed to execute 'send' on 'WebSocket': Blob data is not supported in this environment.", 'NotSupportedError');
1931
+ } else {
1932
+ dataStr = String(data);
1933
+ }
1934
+
1935
+ __WebSocket_send(this.#socketId, dataStr);
1936
+ }
1937
+
1938
+ close(code, reason) {
1939
+ if (code !== undefined) {
1940
+ if (typeof code !== 'number' || code !== Math.floor(code)) {
1941
+ throw new DOMException("Failed to execute 'close' on 'WebSocket': The code must be an integer.", 'InvalidAccessError');
1942
+ }
1943
+ if (code !== 1000 && (code < 3000 || code > 4999)) {
1944
+ throw new DOMException("Failed to execute 'close' on 'WebSocket': The code must be either 1000, or between 3000 and 4999.", 'InvalidAccessError');
1945
+ }
1946
+ }
1947
+
1948
+ if (reason !== undefined) {
1949
+ const encoder = new TextEncoder();
1950
+ if (encoder.encode(reason).byteLength > 123) {
1951
+ throw new DOMException("Failed to execute 'close' on 'WebSocket': The message must not be greater than 123 bytes.", 'SyntaxError');
1952
+ }
1953
+ }
1954
+
1955
+ if (this.#readyState === WebSocket.CLOSING || this.#readyState === WebSocket.CLOSED) {
1956
+ return;
1957
+ }
1958
+
1959
+ this.#readyState = WebSocket.CLOSING;
1960
+ __WebSocket_close(this.#socketId, code ?? 1000, reason ?? '');
1961
+ }
1962
+
1963
+ // EventTarget interface
1964
+ addEventListener(type, listener, options) {
1965
+ if (typeof listener !== 'function') return;
1966
+ let listeners = this._listeners.get(type);
1967
+ if (!listeners) {
1968
+ listeners = [];
1969
+ this._listeners.set(type, listeners);
1970
+ }
1971
+ if (!listeners.includes(listener)) {
1972
+ listeners.push(listener);
1973
+ }
1974
+ }
1975
+
1976
+ removeEventListener(type, listener, options) {
1977
+ const listeners = this._listeners.get(type);
1978
+ if (!listeners) return;
1979
+ const index = listeners.indexOf(listener);
1980
+ if (index !== -1) {
1981
+ listeners.splice(index, 1);
1982
+ }
1983
+ }
1984
+
1985
+ dispatchEvent(event) {
1986
+ dispatchEvent(this, event);
1987
+ return !event.defaultPrevented;
1988
+ }
1989
+
1990
+ // Internal methods called from host
1991
+ _setProtocol(protocol) {
1992
+ this.#protocol = protocol;
1993
+ }
1994
+
1995
+ _setExtensions(extensions) {
1996
+ this.#extensions = extensions;
1997
+ }
1998
+
1999
+ _setReadyState(state) {
2000
+ this.#readyState = state;
2001
+ }
2002
+
2003
+ _dispatchOpen() {
2004
+ this.#readyState = WebSocket.OPEN;
2005
+ const event = new _Event('open');
2006
+ dispatchEvent(this, event);
2007
+ }
2008
+
2009
+ _dispatchMessage(data) {
2010
+ // Handle binary data
2011
+ let messageData = data;
2012
+ if (typeof data === 'string' && data.startsWith('__BINARY__')) {
2013
+ const base64 = data.slice(10);
2014
+ const binary = atob(base64);
2015
+ const bytes = new Uint8Array(binary.length);
2016
+ for (let i = 0; i < binary.length; i++) {
2017
+ bytes[i] = binary.charCodeAt(i);
2018
+ }
2019
+ if (this.#binaryType === 'arraybuffer') {
2020
+ messageData = bytes.buffer;
2021
+ } else {
2022
+ messageData = new Blob([bytes]);
2023
+ }
2024
+ }
2025
+
2026
+ const event = new _MessageEvent('message', { data: messageData });
2027
+ dispatchEvent(this, event);
2028
+ }
2029
+
2030
+ _dispatchError() {
2031
+ const event = new _Event('error');
2032
+ dispatchEvent(this, event);
2033
+ }
2034
+
2035
+ _dispatchClose(code, reason, wasClean) {
2036
+ this.#readyState = WebSocket.CLOSED;
2037
+ const event = new _CloseEvent('close', { code, reason, wasClean });
2038
+ dispatchEvent(this, event);
2039
+ __clientWebSockets.delete(this.#socketId);
2040
+ }
2041
+ }
2042
+
2043
+ // Helper to dispatch events from host to a socket by ID
2044
+ globalThis.__dispatchClientWebSocketEvent = function(socketId, eventType, data) {
2045
+ const ws = __clientWebSockets.get(socketId);
2046
+ if (!ws) return;
2047
+
2048
+ switch (eventType) {
2049
+ case 'open':
2050
+ ws._setProtocol(data.protocol || '');
2051
+ ws._setExtensions(data.extensions || '');
2052
+ ws._dispatchOpen();
2053
+ break;
2054
+ case 'message':
2055
+ ws._dispatchMessage(data.data);
2056
+ break;
2057
+ case 'error':
2058
+ ws._dispatchError();
2059
+ break;
2060
+ case 'close':
2061
+ ws._dispatchClose(data.code, data.reason, data.wasClean);
2062
+ break;
2063
+ }
2064
+ };
2065
+
2066
+ globalThis.WebSocket = WebSocket;
2067
+ })();
2068
+ `);
2069
+ }
1615
2070
  function setupServe(context) {
1616
2071
  context.evalSync(`
1617
2072
  (function() {
@@ -1634,7 +2089,7 @@ async function setupFetch(context, options) {
1634
2089
  context.evalSync(multipartCode);
1635
2090
  setupStreamCallbacks(context, streamRegistry);
1636
2091
  context.evalSync(hostBackedStreamCode);
1637
- setupResponse(context, stateMap);
2092
+ setupResponse(context, stateMap, streamRegistry);
1638
2093
  setupRequest(context, stateMap);
1639
2094
  setupFetchFunction(context, stateMap, streamRegistry, options);
1640
2095
  const serveState = {
@@ -1642,9 +2097,11 @@ async function setupFetch(context, options) {
1642
2097
  activeConnections: new Map
1643
2098
  };
1644
2099
  const wsCommandCallbacks = new Set;
2100
+ const clientWsCommandCallbacks = new Set;
1645
2101
  setupServer(context, serveState);
1646
2102
  setupServerWebSocket(context, wsCommandCallbacks);
1647
2103
  setupServe(context);
2104
+ setupClientWebSocket(context, clientWsCommandCallbacks);
1648
2105
  return {
1649
2106
  dispose() {
1650
2107
  stateMap.clear();
@@ -1757,8 +2214,7 @@ async function setupFetch(context, options) {
1757
2214
  },
1758
2215
  cancel() {
1759
2216
  streamDone = true;
1760
- streamRegistry.error(responseStreamId, new Error("Stream cancelled"));
1761
- streamRegistry.delete(responseStreamId);
2217
+ streamRegistry.cancel(responseStreamId);
1762
2218
  }
1763
2219
  });
1764
2220
  const responseHeaders2 = new Headers(responseState.headers);
@@ -1903,6 +2359,53 @@ async function setupFetch(context, options) {
1903
2359
  },
1904
2360
  hasActiveConnections() {
1905
2361
  return serveState.activeConnections.size > 0;
2362
+ },
2363
+ dispatchClientWebSocketOpen(socketId, protocol, extensions) {
2364
+ const safeProtocol = protocol.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
2365
+ const safeExtensions = extensions.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
2366
+ context.evalSync(`
2367
+ __dispatchClientWebSocketEvent("${socketId}", "open", {
2368
+ protocol: "${safeProtocol}",
2369
+ extensions: "${safeExtensions}"
2370
+ });
2371
+ `);
2372
+ },
2373
+ dispatchClientWebSocketMessage(socketId, data) {
2374
+ if (typeof data === "string") {
2375
+ const safeData = data.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r");
2376
+ context.evalSync(`
2377
+ __dispatchClientWebSocketEvent("${socketId}", "message", { data: "${safeData}" });
2378
+ `);
2379
+ } else {
2380
+ const bytes = new Uint8Array(data);
2381
+ let binary = "";
2382
+ for (let i = 0;i < bytes.byteLength; i++) {
2383
+ binary += String.fromCharCode(bytes[i]);
2384
+ }
2385
+ const base64 = Buffer.from(binary, "binary").toString("base64");
2386
+ context.evalSync(`
2387
+ __dispatchClientWebSocketEvent("${socketId}", "message", { data: "__BINARY__${base64}" });
2388
+ `);
2389
+ }
2390
+ },
2391
+ dispatchClientWebSocketClose(socketId, code, reason, wasClean) {
2392
+ const safeReason = reason.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n");
2393
+ context.evalIgnored(`
2394
+ __dispatchClientWebSocketEvent("${socketId}", "close", {
2395
+ code: ${code},
2396
+ reason: "${safeReason}",
2397
+ wasClean: ${wasClean}
2398
+ });
2399
+ `);
2400
+ },
2401
+ dispatchClientWebSocketError(socketId) {
2402
+ context.evalIgnored(`
2403
+ __dispatchClientWebSocketEvent("${socketId}", "error", {});
2404
+ `);
2405
+ },
2406
+ onClientWebSocketCommand(callback) {
2407
+ clientWsCommandCallbacks.add(callback);
2408
+ return () => clientWsCommandCallbacks.delete(callback);
1906
2409
  }
1907
2410
  };
1908
2411
  }
@@ -1911,4 +2414,4 @@ export {
1911
2414
  clearAllInstanceState
1912
2415
  };
1913
2416
 
1914
- //# debugId=5C3C175E215A892A64756E2164756E21
2417
+ //# debugId=9CCD39D3AEDB908864756E2164756E21