@statewalker/webrun-http-browser 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +319 -65
  2. package/dist/core/data-calls.d.ts +17 -0
  3. package/dist/core/data-calls.d.ts.map +1 -1
  4. package/dist/core/service-worker-control.d.ts +3 -1
  5. package/dist/core/service-worker-control.d.ts.map +1 -1
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +3907 -266
  9. package/dist/link/errors.d.ts +2 -0
  10. package/dist/link/errors.d.ts.map +1 -0
  11. package/dist/link/http-link.d.ts +58 -0
  12. package/dist/link/http-link.d.ts.map +1 -0
  13. package/dist/link/index.d.ts +4 -0
  14. package/dist/link/index.d.ts.map +1 -0
  15. package/dist/link/locks.d.ts +39 -0
  16. package/dist/link/locks.d.ts.map +1 -0
  17. package/dist/link/rendezvous.d.ts +21 -0
  18. package/dist/link/rendezvous.d.ts.map +1 -0
  19. package/dist/relay/index-sw.d.ts +12 -2
  20. package/dist/relay/index-sw.d.ts.map +1 -1
  21. package/dist/relay/index.d.ts +66 -8
  22. package/dist/relay/index.d.ts.map +1 -1
  23. package/dist/relay-sw.js +1160 -55
  24. package/dist/relay-worker.js +1161 -55
  25. package/dist/sw/handler-endpoint.d.ts +81 -0
  26. package/dist/sw/handler-endpoint.d.ts.map +1 -0
  27. package/dist/sw/handler-table.d.ts +53 -0
  28. package/dist/sw/handler-table.d.ts.map +1 -0
  29. package/dist/sw/http-sw-dispatcher.d.ts +162 -21
  30. package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
  31. package/dist/sw/index.d.ts +5 -0
  32. package/dist/sw/index.d.ts.map +1 -1
  33. package/dist/sw/protocol.d.ts +115 -0
  34. package/dist/sw/protocol.d.ts.map +1 -0
  35. package/dist/sw/sw-dispatcher.d.ts +2 -1
  36. package/dist/sw/sw-dispatcher.d.ts.map +1 -1
  37. package/dist/sw/sw-http-adapter.d.ts +199 -0
  38. package/dist/sw/sw-http-adapter.d.ts.map +1 -0
  39. package/dist/sw/sw-websocket.d.ts +16 -0
  40. package/dist/sw/sw-websocket.d.ts.map +1 -0
  41. package/dist/sw/worker-service.d.ts +45 -0
  42. package/dist/sw/worker-service.d.ts.map +1 -0
  43. package/dist/sw/worker-url.d.ts +9 -0
  44. package/dist/sw/worker-url.d.ts.map +1 -0
  45. package/dist/sw-worker.js +2075 -210
  46. package/dist/sw.d.ts +2 -0
  47. package/dist/sw.d.ts.map +1 -1
  48. package/dist/sw.js +3853 -480
  49. package/dist/worker.d.ts +10 -0
  50. package/dist/worker.d.ts.map +1 -0
  51. package/dist/worker.js +3507 -0
  52. package/dist/ws/client.d.ts +73 -0
  53. package/dist/ws/client.d.ts.map +1 -0
  54. package/dist/ws/frames.d.ts +68 -0
  55. package/dist/ws/frames.d.ts.map +1 -0
  56. package/dist/ws/index.d.ts +6 -0
  57. package/dist/ws/index.d.ts.map +1 -0
  58. package/dist/ws/server.d.ts +53 -0
  59. package/dist/ws/server.d.ts.map +1 -0
  60. package/dist/ws/socket-core.d.ts +91 -0
  61. package/dist/ws/socket-core.d.ts.map +1 -0
  62. package/dist/ws/upgrade.d.ts +93 -0
  63. package/dist/ws/upgrade.d.ts.map +1 -0
  64. package/package.json +10 -5
  65. package/src/core/data-calls.ts +52 -2
  66. package/src/core/service-worker-control.ts +7 -1
  67. package/src/index.ts +3 -0
  68. package/src/link/errors.ts +3 -0
  69. package/src/link/http-link.ts +222 -0
  70. package/src/link/index.ts +3 -0
  71. package/src/link/locks.ts +97 -0
  72. package/src/link/rendezvous.ts +60 -0
  73. package/src/relay/index-sw.ts +84 -12
  74. package/src/relay/index.ts +205 -29
  75. package/src/sw/handler-endpoint.ts +278 -0
  76. package/src/sw/handler-table.ts +130 -0
  77. package/src/sw/http-sw-dispatcher.ts +649 -90
  78. package/src/sw/index.ts +5 -0
  79. package/src/sw/protocol.ts +103 -0
  80. package/src/sw/sw-dispatcher.ts +12 -30
  81. package/src/sw/sw-http-adapter.ts +613 -0
  82. package/src/sw/sw-websocket.ts +43 -0
  83. package/src/sw/worker-service.ts +133 -0
  84. package/src/sw/worker-url.ts +19 -0
  85. package/src/sw-worker.ts +8 -3
  86. package/src/sw.ts +2 -0
  87. package/src/worker.ts +9 -0
  88. package/src/ws/client.ts +346 -0
  89. package/src/ws/frames.ts +119 -0
  90. package/src/ws/index.ts +11 -0
  91. package/src/ws/server.ts +132 -0
  92. package/src/ws/socket-core.ts +251 -0
  93. package/src/ws/upgrade.ts +200 -0
@@ -281,6 +281,34 @@ async function sendIterator(send, it) {
281
281
  });
282
282
  }
283
283
  }
284
+ const DEFAULT_CHUNK_SIZE = 16384;
285
+ /**
286
+ * Curried `Duplex`-shaped transformer that splits incoming `Uint8Array`s into
287
+ * chunks no larger than `size` bytes. Empty input chunks are skipped; small
288
+ * input chunks pass through unchanged (zero-copy `subarray` views are used
289
+ * when splitting, so no allocation per chunk).
290
+ *
291
+ * Use to respect transport MTUs before reaching `channel.send`. Reassembly on
292
+ * the receive side is not needed — consumers iterate a continuous byte stream.
293
+ */
294
+ function toChunks(size = DEFAULT_CHUNK_SIZE) {
295
+ if (!(Number.isInteger(size) && size > 0)) throw new RangeError(`toChunks: size must be a positive integer, got ${size}`);
296
+ return async function* (input) {
297
+ for await (const block of input) {
298
+ if (block.byteLength === 0) continue;
299
+ if (block.byteLength <= size) {
300
+ yield block;
301
+ continue;
302
+ }
303
+ let offset = 0;
304
+ while (offset < block.byteLength) {
305
+ const end = Math.min(offset + size, block.byteLength);
306
+ yield block.subarray(offset, end);
307
+ offset = end;
308
+ }
309
+ }
310
+ };
311
+ }
284
312
  //#endregion
285
313
  //#region ../webrun-http-streams/dist/index.js
286
314
  const CR = 13;
@@ -1032,10 +1060,99 @@ function newSniffingCodec(options) {
1032
1060
  }
1033
1061
  };
1034
1062
  }
1035
- newSniffingCodec({
1063
+ /**
1064
+ * Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
1065
+ * no `codec` option is supplied.
1066
+ */
1067
+ const defaultCodec = newSniffingCodec({
1036
1068
  write: httpCodec,
1037
1069
  accept: [httpCodec, jsonEnvelopeCodec]
1038
1070
  });
1071
+ /** Carries the serialized JS error between two webrun peers. */
1072
+ const PEER_ERROR_HEADER = "x-webrun-error";
1073
+ /**
1074
+ * `output` is the `Duplex` call's own generator — one logical call, per the
1075
+ * `Duplex` contract in `@statewalker/webrun-streams`: "Consumer `.return()`
1076
+ * on the output → producer's `finally` runs." On a mux transport
1077
+ * (`emulateMux`), that `finally` is what frees the stream-table slot; skip it
1078
+ * and every peer-error response leaks one slot, unboundedly, until the mux
1079
+ * itself is exhausted (`maxStreams` reached, every further call rejected).
1080
+ *
1081
+ * Cancelling `output` here — not inside the codec's own decode logic — is
1082
+ * deliberate. `codec.decodeResponse` already pulled from `output` to read the
1083
+ * head before this function runs, so unlike the body case above there's no
1084
+ * suspended-start no-op to worry about. And it's safe specifically *because*
1085
+ * this is the client's own response-only generator: the request was already
1086
+ * fully sent before we got here, so there is nothing left to write on it.
1087
+ * The equivalent is NOT safe inside `src/http1/decode.ts`'s `ByteReader` —
1088
+ * that code also runs when a caller wires `codec.decodeRequest`/
1089
+ * `decodeResponse` directly onto a raw bidirectional socket (see
1090
+ * `tests/http1-node-interop.test.ts`), where the *same* object is read from
1091
+ * and then written back to (a server reads the request, then replies on the
1092
+ * same socket); cancelling the read side there tears down the whole
1093
+ * connection out from under the pending write. That was tried and reverted —
1094
+ * see the Task 11 report for the "socket hang up" failure it caused.
1095
+ */
1096
+ async function throwIfPeerError(result, output) {
1097
+ const found = result.envelope.headers.find(([k]) => k.toLowerCase() === PEER_ERROR_HEADER);
1098
+ if (!found) return;
1099
+ await discard(result.body);
1100
+ try {
1101
+ await output.return?.(void 0);
1102
+ } catch {}
1103
+ let payload;
1104
+ try {
1105
+ payload = JSON.parse(found[1]);
1106
+ } catch {
1107
+ payload = { message: found[1] };
1108
+ }
1109
+ throw deserializeError(payload);
1110
+ }
1111
+ /**
1112
+ * Initiate an HTTP call over a `Duplex`. The caller's `call: Duplex` is
1113
+ * obtained from any `webrun-streams-*` adapter's `connect`. Returns the
1114
+ * response envelope and an async iterable over the response body bytes.
1115
+ *
1116
+ * The call is one logical Duplex invocation; multiplexing of concurrent
1117
+ * calls is the adapter's concern (native or `emulateMux`).
1118
+ */
1119
+ async function httpFetch(call, env, body, options = {}) {
1120
+ const codec = options.codec ?? defaultCodec;
1121
+ const output = call(codec.encodeRequest(env, body));
1122
+ const decoded = await codec.decodeResponse(output, { method: env.method });
1123
+ const release = async () => {
1124
+ try {
1125
+ await output.return?.(void 0);
1126
+ } catch {}
1127
+ };
1128
+ const result = {
1129
+ envelope: decoded.envelope,
1130
+ body: releasingOnExit(decoded.body, release),
1131
+ release
1132
+ };
1133
+ await throwIfPeerError(result, output);
1134
+ return result;
1135
+ }
1136
+ /**
1137
+ * `body`, but leaving it before its end releases the call.
1138
+ *
1139
+ * The body the codec hands back is a generator of its own over a `ByteReader`,
1140
+ * and `.return()` on it never reaches the call's `output` (see
1141
+ * `throwIfPeerError` for why the codec must not do that itself). So a caller
1142
+ * that stopped reading — an aborted `fetch`, a cancelled response stream —
1143
+ * left the peer's producer parked forever, holding its handler open. Safe here
1144
+ * for the same reason it is safe in `throwIfPeerError`: `output` is this
1145
+ * call's own response-only generator, and the request was fully sent.
1146
+ */
1147
+ async function* releasingOnExit(body, release) {
1148
+ let completed = false;
1149
+ try {
1150
+ yield* body;
1151
+ completed = true;
1152
+ } finally {
1153
+ if (!completed) await release();
1154
+ }
1155
+ }
1039
1156
  /**
1040
1157
  * The one place that knows a runtime may not implement request body streams,
1041
1158
  * and the buffering both fallbacks need. `fetch.ts` and `http-stubs.ts` each
@@ -1141,6 +1258,121 @@ function newHttpClientStub(send) {
1141
1258
  return new Response(toReadableStream(result.content[Symbol.asyncIterator]()), responseOptions);
1142
1259
  };
1143
1260
  }
1261
+ /**
1262
+ * Connection-scoped headers. The codec surfaces them verbatim (decision 12),
1263
+ * but they are meaningless to a `Request`/`Response`, and re-emitting them
1264
+ * from a relay would corrupt its framing.
1265
+ */
1266
+ const HOP_BY_HOP = /* @__PURE__ */ new Set([
1267
+ "connection",
1268
+ "host",
1269
+ "keep-alive",
1270
+ "proxy-authenticate",
1271
+ "proxy-authorization",
1272
+ "te",
1273
+ "trailer",
1274
+ "transfer-encoding",
1275
+ "upgrade"
1276
+ ]);
1277
+ function forwardableHeaders(headers) {
1278
+ return headers.filter(([name]) => !HOP_BY_HOP.has(name.toLowerCase()));
1279
+ }
1280
+ function headersToArray(headers) {
1281
+ const out = [];
1282
+ headers.forEach((value, key) => {
1283
+ out.push([key, value]);
1284
+ });
1285
+ return out;
1286
+ }
1287
+ async function* readableToAsyncIterable(stream) {
1288
+ if (!stream) return;
1289
+ const reader = stream.getReader();
1290
+ let consumed = false;
1291
+ try {
1292
+ while (true) {
1293
+ const { done, value } = await reader.read();
1294
+ if (done) {
1295
+ consumed = true;
1296
+ return;
1297
+ }
1298
+ if (value) yield value;
1299
+ }
1300
+ } finally {
1301
+ if (!consumed) try {
1302
+ await reader.cancel();
1303
+ } catch {}
1304
+ try {
1305
+ reader.releaseLock();
1306
+ } catch {}
1307
+ }
1308
+ }
1309
+ function asyncIterableToReadable(iter) {
1310
+ const it = iter[Symbol.asyncIterator]();
1311
+ return new ReadableStream({
1312
+ async pull(controller) {
1313
+ try {
1314
+ const { done, value } = await it.next();
1315
+ if (done) {
1316
+ controller.close();
1317
+ return;
1318
+ }
1319
+ controller.enqueue(value);
1320
+ } catch (err) {
1321
+ controller.error(err);
1322
+ }
1323
+ },
1324
+ async cancel() {
1325
+ await it.return?.();
1326
+ }
1327
+ });
1328
+ }
1329
+ /**
1330
+ * Run a `Request` through a `Duplex` call and reconstruct the `Response` on
1331
+ * the other side. The request's `signal` is plumbed into the body iteration —
1332
+ * abort terminates the underlying call.
1333
+ */
1334
+ async function fetchOverDuplex(call, request, options = {}) {
1335
+ if (request.signal?.aborted) throw abortReason$1(request.signal);
1336
+ const env = {
1337
+ url: request.url,
1338
+ method: request.method,
1339
+ headers: headersToArray(request.headers)
1340
+ };
1341
+ let body;
1342
+ if (request.body != null) body = readableToAsyncIterable(request.body);
1343
+ else if (!supportsRequestStreams()) {
1344
+ const buffered = new Uint8Array(await request.arrayBuffer());
1345
+ if (buffered.byteLength > 0) body = [buffered];
1346
+ }
1347
+ const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
1348
+ const responseInit = {
1349
+ status: envelope.status,
1350
+ statusText: envelope.statusText,
1351
+ headers: forwardableHeaders(envelope.headers)
1352
+ };
1353
+ if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
1354
+ await release?.();
1355
+ return new Response(null, responseInit);
1356
+ }
1357
+ return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
1358
+ }
1359
+ async function* withAbort(iter, signal) {
1360
+ if (!signal) {
1361
+ yield* iter;
1362
+ return;
1363
+ }
1364
+ for await (const chunk of iter) {
1365
+ if (signal.aborted) throw abortReason$1(signal);
1366
+ yield chunk;
1367
+ }
1368
+ }
1369
+ function abortReason$1(signal) {
1370
+ const reason = signal.reason;
1371
+ if (reason instanceof Error) return reason;
1372
+ const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1373
+ err.name = "AbortError";
1374
+ return err;
1375
+ }
1144
1376
  var HttpError = class HttpError extends Error {
1145
1377
  status;
1146
1378
  statusText;
@@ -1253,6 +1485,32 @@ function set(key, value, customStore = defaultGetStore()) {
1253
1485
  }
1254
1486
  //#endregion
1255
1487
  //#region src/core/data-calls.ts
1488
+ const TRANSFER = Symbol.for("webrun-http-browser:transfer");
1489
+ /**
1490
+ * A channel call's result that carries transferables, a `MessagePort` say:
1491
+ * return `withTransfer({ port }, [port])` from a `handleChannelCalls` handler
1492
+ * and `callChannel` resolves with `{ port }`.
1493
+ */
1494
+ function withTransfer(value, transfer) {
1495
+ return {
1496
+ [TRANSFER]: true,
1497
+ value,
1498
+ transfer
1499
+ };
1500
+ }
1501
+ function isTransferResult(value) {
1502
+ return typeof value === "object" && value !== null && TRANSFER in value;
1503
+ }
1504
+ /** Splits a handler's result into the value to post and its transfer list. */
1505
+ function unwrapTransfer(result) {
1506
+ return isTransferResult(result) ? {
1507
+ value: result.value,
1508
+ transfer: result.transfer
1509
+ } : {
1510
+ value: result,
1511
+ transfer: []
1512
+ };
1513
+ }
1256
1514
  function callChannel(target, callType, params, ...transfers) {
1257
1515
  return new Promise((resolve, reject) => {
1258
1516
  const channel = new MessageChannel();
@@ -1272,12 +1530,14 @@ function handleChannelCalls(target, callType, handler) {
1272
1530
  if (!event.data || event.data.type !== callType) return;
1273
1531
  const [port, ...transfers] = event.ports ?? [];
1274
1532
  const response = {};
1533
+ let transfer = [];
1275
1534
  try {
1276
- response.result = await handler(event, event.data.params, ...transfers);
1535
+ const result = await handler(event, event.data.params, ...transfers);
1536
+ ({value: response.result, transfer} = unwrapTransfer(result));
1277
1537
  } catch (error) {
1278
1538
  response.error = serializeError(error);
1279
1539
  }
1280
- port?.postMessage(response);
1540
+ port?.postMessage(response, transfer);
1281
1541
  };
1282
1542
  target.addEventListener("message", listener);
1283
1543
  target.start?.();
@@ -1334,6 +1594,798 @@ function handleClaimRequests(self) {
1334
1594
  });
1335
1595
  }
1336
1596
  //#endregion
1597
+ //#region ../webrun-rpc/dist/index.js
1598
+ const closeSignals = /* @__PURE__ */ new WeakMap();
1599
+ /**
1600
+ * Internal: register a port's close signal. Called once by `bindBytesToPort`.
1601
+ *
1602
+ * @internal
1603
+ */
1604
+ function setPortCloseSignal(port, signal) {
1605
+ closeSignals.set(port, signal);
1606
+ }
1607
+ /**
1608
+ * Return the AbortSignal that fires when `port`'s transport closes, or
1609
+ * `undefined` if `port` is not transport-backed (e.g., a raw
1610
+ * `MessageChannel().port1`).
1611
+ */
1612
+ function getPortCloseSignal(port) {
1613
+ return closeSignals.get(port);
1614
+ }
1615
+ /**
1616
+ * Pass as `timeout` to install no deadline at all. Used by the stream tier,
1617
+ * where the deadline belongs to the stream rather than to one chunk (spec D8):
1618
+ * a slow consumer is throttled, never failed.
1619
+ */
1620
+ const NO_TIMEOUT = Number.POSITIVE_INFINITY;
1621
+ /**
1622
+ * Asynchronous request/response over any `MessageTarget`.
1623
+ *
1624
+ * Sends `params` to the peer listening with `listenPort`, waits up to
1625
+ * `timeout` ms for a matching reply, and either resolves with the result or
1626
+ * rejects with the deserialised error.
1627
+ */
1628
+ function callPort(port, params, { timeout = 1e3, channelName = "", log = () => {}, newCallId = () => `call-${Date.now()}-${String(Math.random()).substring(2)}`, signal } = {}) {
1629
+ const callId = newCallId();
1630
+ log("[callPort]", {
1631
+ channelName,
1632
+ callId,
1633
+ params
1634
+ });
1635
+ const combinedSignal = combineSignals(signal, getPortCloseSignal(port));
1636
+ let timerId;
1637
+ let onMessage;
1638
+ let onAbort;
1639
+ const promise = new Promise((resolve, reject) => {
1640
+ if (combinedSignal?.aborted) {
1641
+ reject(abortReason(combinedSignal));
1642
+ return;
1643
+ }
1644
+ if (Number.isFinite(timeout) && timeout > 0) timerId = setTimeout(() => reject(/* @__PURE__ */ new Error(`Call timeout. CallId: "${callId}".`)), timeout);
1645
+ onMessage = (event) => {
1646
+ const data = event.data;
1647
+ if (!data) return;
1648
+ if (data.channelName !== channelName) return;
1649
+ if (data.callId !== callId) return;
1650
+ if (data.type === "response:error") reject(deserializeError(data.error));
1651
+ else if (data.type === "response:result") resolve(data.result);
1652
+ };
1653
+ port.addEventListener("message", onMessage);
1654
+ if (combinedSignal) {
1655
+ onAbort = () => reject(abortReason(combinedSignal));
1656
+ combinedSignal.addEventListener("abort", onAbort, { once: true });
1657
+ }
1658
+ });
1659
+ promise.catch(() => {}).finally(() => {
1660
+ if (timerId !== void 0) clearTimeout(timerId);
1661
+ if (onMessage) port.removeEventListener("message", onMessage);
1662
+ if (onAbort && combinedSignal) combinedSignal.removeEventListener("abort", onAbort);
1663
+ });
1664
+ port.postMessage({
1665
+ type: "request",
1666
+ channelName,
1667
+ callId,
1668
+ params
1669
+ });
1670
+ return promise;
1671
+ }
1672
+ function abortReason(signal) {
1673
+ const reason = signal.reason;
1674
+ if (reason instanceof Error) return reason;
1675
+ const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1676
+ err.name = "AbortError";
1677
+ return err;
1678
+ }
1679
+ function combineSignals(...signals) {
1680
+ const live = signals.filter((s) => s !== void 0);
1681
+ if (live.length === 0) return void 0;
1682
+ if (live.length === 1) return live[0];
1683
+ return AbortSignal.any(live);
1684
+ }
1685
+ /**
1686
+ * Installs `handler` as the server side of a `callPort` / `listenPort`
1687
+ * request/response pair on `port`.
1688
+ *
1689
+ * Returns a cleanup function that removes the listener.
1690
+ */
1691
+ function listenPort(port, handler, { channelName = "", log = () => {} } = {}) {
1692
+ const onMessage = async (event) => {
1693
+ const data = event.data;
1694
+ if (!data || data.channelName !== channelName || data.type !== "request") return;
1695
+ const { callId, params } = data;
1696
+ log("[listenPort]", {
1697
+ channelName,
1698
+ callId,
1699
+ params
1700
+ });
1701
+ let result;
1702
+ let error;
1703
+ let type;
1704
+ try {
1705
+ result = await handler(params);
1706
+ type = "response:result";
1707
+ } catch (e) {
1708
+ error = serializeError(e);
1709
+ type = "response:error";
1710
+ }
1711
+ port.postMessage({
1712
+ callId,
1713
+ channelName,
1714
+ type,
1715
+ result,
1716
+ error
1717
+ });
1718
+ };
1719
+ port.addEventListener("message", onMessage);
1720
+ return () => port.removeEventListener("message", onMessage);
1721
+ }
1722
+ /**
1723
+ * Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
1724
+ * to return cleanly, forwarding `return()` to the underlying iterator so the
1725
+ * producer (e.g., a user-supplied generator) sees its own `finally` blocks
1726
+ * run immediately rather than waiting for the next yield.
1727
+ */
1728
+ async function* throughAbort(input, signal) {
1729
+ const iter = input[Symbol.asyncIterator] ? input[Symbol.asyncIterator]() : input[Symbol.iterator]();
1730
+ const onAbort = () => {
1731
+ iter.return?.(void 0);
1732
+ };
1733
+ if (signal.aborted) {
1734
+ iter.return?.(void 0);
1735
+ return;
1736
+ }
1737
+ signal.addEventListener("abort", onAbort, { once: true });
1738
+ try {
1739
+ while (true) {
1740
+ const r = await iter.next();
1741
+ if (r.done) return;
1742
+ if (signal.aborted) return;
1743
+ yield r.value;
1744
+ }
1745
+ } finally {
1746
+ signal.removeEventListener("abort", onAbort);
1747
+ }
1748
+ }
1749
+ /**
1750
+ * The `type` of the out-of-band notice a side posts when it abandons a stream.
1751
+ *
1752
+ * Layer 1's `close` is not observable to layer 2 — a closed virtual port drops
1753
+ * its listeners silently and is indistinguishable from a working port nobody
1754
+ * is answering — so the peer would otherwise wait forever. Exported because
1755
+ * tests and adapters assert on it.
1756
+ */
1757
+ const STREAM_ABORT = "webrun-rpc:stream-abort";
1758
+ /** Caller's input travels on this channel; the handler listens on it. */
1759
+ const CHANNEL_IN = "in";
1760
+ /** The handler's output travels on this channel; the caller listens on it. */
1761
+ const CHANNEL_OUT = "out";
1762
+ /**
1763
+ * One port in, one `Duplex` out (spec D9).
1764
+ *
1765
+ * The returned `Duplex` runs a single stream on `port`: the caller's `input`
1766
+ * is sent chunk by chunk with `callPort`, and the handler's output arrives the
1767
+ * same way on the other channel. Within each direction the next chunk is never
1768
+ * sent until the previous one has been delivered *and* pulled past by the
1769
+ * consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
1770
+ * `listenPort` withholds it until then.
1771
+ *
1772
+ * A stream port carries exactly one invocation. To make several calls, open
1773
+ * several ports: `mux.openPort({ kind: "stream" })` per call.
1774
+ */
1775
+ function duplexOverPort(port, options = {}) {
1776
+ return (input) => runCallerSide(port, input, options);
1777
+ }
1778
+ function runCallerSide(port, input, options) {
1779
+ const controller = new AbortController();
1780
+ const notice = installAbortNotice(port, controller);
1781
+ abortOnPortClose(port, controller);
1782
+ const clock = installStreamTimeout(controller, options.timeout);
1783
+ const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
1784
+ const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
1785
+ pump.catch(() => {});
1786
+ return (async function* () {
1787
+ try {
1788
+ yield* inbound.stream;
1789
+ } finally {
1790
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
1791
+ notice.post();
1792
+ notice.stop();
1793
+ clock.stop();
1794
+ inbound.stop();
1795
+ await pump.catch(() => {});
1796
+ try {
1797
+ await port.close?.();
1798
+ } catch {}
1799
+ }
1800
+ })();
1801
+ }
1802
+ /**
1803
+ * Listens for the peer's abort notice, and can post our own. The notice is a
1804
+ * plain message with no `channelName` and a `type` that is not `"request"`,
1805
+ * so neither `callPort` nor `listenPort` reacts to it, and it carries no
1806
+ * numeric `id`, so `structuredCodec` never mistakes it for a layer 1 envelope.
1807
+ */
1808
+ function installAbortNotice(port, controller) {
1809
+ const onMessage = (event) => {
1810
+ const data = event.data;
1811
+ if (!data || data.type !== "webrun-rpc:stream-abort") return;
1812
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the peer abandoned the stream"));
1813
+ };
1814
+ port.addEventListener("message", onMessage);
1815
+ let posted = false;
1816
+ return {
1817
+ post() {
1818
+ if (posted) return;
1819
+ posted = true;
1820
+ try {
1821
+ port.postMessage({ type: STREAM_ABORT });
1822
+ } catch {}
1823
+ },
1824
+ stop() {
1825
+ port.removeEventListener("message", onMessage);
1826
+ }
1827
+ };
1828
+ }
1829
+ /**
1830
+ * Aborts the stream when its port closes underneath it — the multiplexer was
1831
+ * closed, or the peer closed the port — for ports that report it (see
1832
+ * `getPortCloseSignal`). Without this a stream waiting for the peer's next
1833
+ * chunk would wait forever on a port that can no longer deliver one: the
1834
+ * outbound half's `callPort` already fails on the close signal, but nothing
1835
+ * failed the inbound half.
1836
+ */
1837
+ function abortOnPortClose(port, controller) {
1838
+ const closed = getPortCloseSignal(port);
1839
+ if (!closed) return;
1840
+ const onClose = () => {
1841
+ if (!controller.signal.aborted) controller.abort(closed.reason);
1842
+ };
1843
+ if (closed.aborted) {
1844
+ onClose();
1845
+ return;
1846
+ }
1847
+ closed.addEventListener("abort", onClose, { once: true });
1848
+ controller.signal.addEventListener("abort", () => closed.removeEventListener("abort", onClose), { once: true });
1849
+ }
1850
+ /**
1851
+ * The per-stream inactivity timeout (spec D8). Reset by any chunk in either
1852
+ * direction; elapsing aborts the stream. Unset, zero or non-finite installs no
1853
+ * timer at all, which is the default: a slow consumer is throttled, not failed.
1854
+ */
1855
+ function installStreamTimeout(controller, timeout) {
1856
+ if (timeout === void 0 || !Number.isFinite(timeout) || timeout <= 0) return {
1857
+ touch() {},
1858
+ stop() {}
1859
+ };
1860
+ let timer;
1861
+ let stopped = false;
1862
+ const arm = () => {
1863
+ timer = setTimeout(() => {
1864
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error(`webrun-rpc: stream idle for ${timeout} ms`));
1865
+ }, timeout);
1866
+ };
1867
+ arm();
1868
+ return {
1869
+ touch() {
1870
+ if (stopped) return;
1871
+ if (timer !== void 0) clearTimeout(timer);
1872
+ arm();
1873
+ },
1874
+ stop() {
1875
+ stopped = true;
1876
+ if (timer !== void 0) clearTimeout(timer);
1877
+ timer = void 0;
1878
+ }
1879
+ };
1880
+ }
1881
+ /**
1882
+ * The receiving half of one direction.
1883
+ *
1884
+ * The `listenPort` listener is installed **eagerly**, not lazily inside
1885
+ * `recieveIterator`'s installer, because a handler that never drains its input
1886
+ * would otherwise leave the peer's chunk calls with nobody to answer them. If
1887
+ * the local consumer has not started iterating, an inbound chunk waits for it —
1888
+ * which is the correct backpressure, and different from having no listener.
1889
+ */
1890
+ function receiveChunks(port, channelName, controller, touch) {
1891
+ let deliver;
1892
+ const waiting = [];
1893
+ let finished = false;
1894
+ let outstanding = false;
1895
+ let poison;
1896
+ let resolveEnded = () => {};
1897
+ const ended = new Promise((resolve) => {
1898
+ resolveEnded = resolve;
1899
+ });
1900
+ let aborted = false;
1901
+ let abortReason;
1902
+ const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
1903
+ const wake = () => {
1904
+ for (const r of waiting.splice(0)) r();
1905
+ };
1906
+ const off = listenPort(port, async ({ done, value, error }) => {
1907
+ touch();
1908
+ if (poison) throw poison;
1909
+ if (outstanding) {
1910
+ poison = /* @__PURE__ */ new Error("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
1911
+ controller.abort(poison);
1912
+ setTimeout(() => {
1913
+ try {
1914
+ port.close?.();
1915
+ } catch {}
1916
+ }, 0);
1917
+ throw poison;
1918
+ }
1919
+ outstanding = true;
1920
+ try {
1921
+ await ready();
1922
+ if (finished) throw poison ?? /* @__PURE__ */ new Error("webrun-rpc: the stream is closed");
1923
+ await deliver?.({
1924
+ done,
1925
+ value,
1926
+ error: error ? deserializeError(error) : void 0
1927
+ });
1928
+ } finally {
1929
+ outstanding = false;
1930
+ }
1931
+ if (done) resolveEnded();
1932
+ }, { channelName });
1933
+ const detach = () => {
1934
+ finished = true;
1935
+ wake();
1936
+ off();
1937
+ controller.signal.removeEventListener("abort", onAbort);
1938
+ resolveEnded();
1939
+ };
1940
+ const onAbort = () => {
1941
+ aborted = true;
1942
+ abortReason = controller.signal.reason;
1943
+ finished = true;
1944
+ wake();
1945
+ deliver?.({
1946
+ done: true,
1947
+ error: abortReason
1948
+ });
1949
+ resolveEnded();
1950
+ };
1951
+ controller.signal.addEventListener("abort", onAbort, { once: true });
1952
+ return {
1953
+ stream: recieveIterator((d) => {
1954
+ if (aborted) {
1955
+ d({
1956
+ done: true,
1957
+ error: abortReason
1958
+ });
1959
+ return detach;
1960
+ }
1961
+ deliver = d;
1962
+ wake();
1963
+ return detach;
1964
+ }),
1965
+ stop: detach,
1966
+ ended
1967
+ };
1968
+ }
1969
+ /**
1970
+ * The sending half of one direction: one `callPort` per chunk, one call
1971
+ * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
1972
+ */
1973
+ async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
1974
+ const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
1975
+ try {
1976
+ await sendIterator(async ({ done, value, error }) => {
1977
+ if (signal.aborted) return;
1978
+ const chunk = {
1979
+ done,
1980
+ value,
1981
+ error: error === void 0 ? void 0 : serializeError(error)
1982
+ };
1983
+ log?.("[duplexOverPort] send", {
1984
+ channelName,
1985
+ done,
1986
+ size: value?.byteLength
1987
+ });
1988
+ await callPort(port, chunk, {
1989
+ channelName,
1990
+ timeout: NO_TIMEOUT,
1991
+ signal
1992
+ });
1993
+ touch();
1994
+ }, stream);
1995
+ } catch (err) {
1996
+ if (signal.aborted) return;
1997
+ throw err;
1998
+ }
1999
+ }
2000
+ /**
2001
+ * One virtual port.
2002
+ *
2003
+ * `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
2004
+ * only a `MessageTarget`, so it cannot forge inbound traffic or close the port
2005
+ * out from under the multiplexer's bookkeeping.
2006
+ */
2007
+ function newVirtualPort(send, requestClose) {
2008
+ const listeners = /* @__PURE__ */ new Set();
2009
+ let closed = false;
2010
+ const closing = new AbortController();
2011
+ const abort = (reason) => {
2012
+ if (closing.signal.aborted) return;
2013
+ closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
2014
+ };
2015
+ const port = {
2016
+ addEventListener(_type, listener) {
2017
+ listeners.add(listener);
2018
+ },
2019
+ removeEventListener(_type, listener) {
2020
+ listeners.delete(listener);
2021
+ },
2022
+ postMessage(message, transfer) {
2023
+ if (closed) return;
2024
+ send(message, transfer);
2025
+ },
2026
+ close() {
2027
+ if (closed) return;
2028
+ closed = true;
2029
+ const notify = requestClose;
2030
+ listeners.clear();
2031
+ notify();
2032
+ abort();
2033
+ }
2034
+ };
2035
+ setPortCloseSignal(port, closing.signal);
2036
+ return {
2037
+ port,
2038
+ deliver(payload) {
2039
+ if (closed) return;
2040
+ const event = new MessageEvent("message", { data: payload });
2041
+ for (const listener of [...listeners]) try {
2042
+ listener(event);
2043
+ } catch {}
2044
+ },
2045
+ markClosed(reason) {
2046
+ closed = true;
2047
+ listeners.clear();
2048
+ abort(reason);
2049
+ },
2050
+ isClosed() {
2051
+ return closed;
2052
+ }
2053
+ };
2054
+ }
2055
+ /**
2056
+ * The message of the error that fails the calls still in flight on a
2057
+ * multiplexer when it is closed (`PortMux.close()`).
2058
+ */
2059
+ const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
2060
+ /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
2061
+ const DEFAULT_MAX_PORTS = 1024;
2062
+ /**
2063
+ * The default `PortMux`: emulates multiplexing over a single port.
2064
+ *
2065
+ * A transport that already multiplexes natively supplies its own `PortMux`
2066
+ * instead — this implementation is for transports that offer one pipe.
2067
+ */
2068
+ function multiplexPort(port, options) {
2069
+ const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
2070
+ const open = /* @__PURE__ */ new Map();
2071
+ let nextId = side === "initiator" ? 0 : 1;
2072
+ let muxClosed = false;
2073
+ const post = (envelope, transfer) => {
2074
+ if (muxClosed) return;
2075
+ try {
2076
+ codec.post(port, envelope, transfer);
2077
+ } catch {}
2078
+ };
2079
+ const attach = (id) => {
2080
+ let self;
2081
+ self = newVirtualPort((payload, transfer) => post({
2082
+ type: "message",
2083
+ id,
2084
+ payload
2085
+ }, transfer), (reason) => {
2086
+ post({
2087
+ type: "close",
2088
+ id,
2089
+ reason
2090
+ });
2091
+ open.delete(id);
2092
+ self.markClosed();
2093
+ });
2094
+ open.set(id, self);
2095
+ return self;
2096
+ };
2097
+ const handleEnvelope = (envelope) => {
2098
+ if (muxClosed) return;
2099
+ if (envelope.type === "open") {
2100
+ if (open.has(envelope.id)) return;
2101
+ if (open.size >= maxPorts) {
2102
+ post({
2103
+ type: "close",
2104
+ id: envelope.id,
2105
+ reason: "max-ports"
2106
+ });
2107
+ return;
2108
+ }
2109
+ const handle = attach(envelope.id);
2110
+ let accepted = false;
2111
+ if (onPort) try {
2112
+ accepted = onPort(handle.port, envelope.meta) !== false;
2113
+ } catch {
2114
+ accepted = false;
2115
+ }
2116
+ if (!accepted) {
2117
+ open.delete(envelope.id);
2118
+ handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was rejected"));
2119
+ post({
2120
+ type: "close",
2121
+ id: envelope.id,
2122
+ reason: "rejected"
2123
+ });
2124
+ }
2125
+ return;
2126
+ }
2127
+ const handle = open.get(envelope.id);
2128
+ if (envelope.type === "message") {
2129
+ if (!handle) return;
2130
+ handle.deliver(envelope.payload);
2131
+ return;
2132
+ }
2133
+ if (!handle) return;
2134
+ open.delete(envelope.id);
2135
+ handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
2136
+ };
2137
+ const listener = (event) => {
2138
+ const envelope = codec.read(event);
2139
+ if (envelope) handleEnvelope(envelope);
2140
+ };
2141
+ port.addEventListener("message", listener);
2142
+ port.start?.();
2143
+ return {
2144
+ maxMessageSize,
2145
+ async openPort(meta) {
2146
+ if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
2147
+ if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
2148
+ while (open.has(nextId)) nextId += 2;
2149
+ const id = nextId;
2150
+ nextId += 2;
2151
+ const handle = attach(id);
2152
+ post({
2153
+ type: "open",
2154
+ id,
2155
+ meta
2156
+ });
2157
+ return handle.port;
2158
+ },
2159
+ async close() {
2160
+ if (muxClosed) return;
2161
+ for (const [id, handle] of [...open]) {
2162
+ post({
2163
+ type: "close",
2164
+ id
2165
+ });
2166
+ open.delete(id);
2167
+ handle.markClosed(/* @__PURE__ */ new Error(MUX_CLOSED_MESSAGE));
2168
+ }
2169
+ muxClosed = true;
2170
+ port.removeEventListener("message", listener);
2171
+ await port.close?.();
2172
+ }
2173
+ };
2174
+ }
2175
+ /**
2176
+ * Announced with every port this pair opens. Layer 1 never reads `meta` — it is
2177
+ * here so a peer running something else over the same multiplexer can tell a
2178
+ * stream port from whatever else it hands out, and so a packet capture says
2179
+ * what the port is for.
2180
+ */
2181
+ const STREAM_META = { kind: "stream" };
2182
+ /**
2183
+ * One port source in, one caller `Duplex` out.
2184
+ *
2185
+ * A call is a port: the mux allocates one, `duplexOverPort` runs the single
2186
+ * invocation on it, and closing the stream closes the port. No stream ids, no
2187
+ * framing and no credit accounting live here — the mux owns the first, and
2188
+ * `duplexOverPort` owns the one-chunk window that makes memory bounded.
2189
+ */
2190
+ const connect = async ({ mux: factory, timeout }) => {
2191
+ const mux = await factory();
2192
+ const streamOptions = {
2193
+ maxMessageSize: mux.maxMessageSize,
2194
+ timeout
2195
+ };
2196
+ const call = (input) => (async function* () {
2197
+ yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
2198
+ })();
2199
+ return {
2200
+ call,
2201
+ async close() {
2202
+ await mux.close();
2203
+ }
2204
+ };
2205
+ };
2206
+ /**
2207
+ * Ports over ONE PIPE OF BYTES — a `MessagePort`, a worker, a WebSocket.
2208
+ *
2209
+ * There is no second port to be had, so `multiplexPort`'s id table invents
2210
+ * them. Use this when the transport gives you exactly one channel.
2211
+ */
2212
+ function overPipe(pipe, options) {
2213
+ return (onPort) => multiplexPort(pipe, {
2214
+ ...options,
2215
+ onPort
2216
+ });
2217
+ }
2218
+ function isEnvelope(value) {
2219
+ if (typeof value !== "object" || value === null) return false;
2220
+ const candidate = value;
2221
+ if (typeof candidate.id !== "number") return false;
2222
+ if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
2223
+ return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
2224
+ }
2225
+ /**
2226
+ * For ports whose messages are structured values — a real `MessagePort`, a
2227
+ * worker, an iframe.
2228
+ *
2229
+ * Envelopes are posted as-is, so nothing is encoded, `ArrayBuffer`s move
2230
+ * zero-copy through the transfer list, and structured clone does the work the
2231
+ * platform already does well. This is a performance choice only: layer 2 may
2232
+ * not send anything a byte codec could not also carry.
2233
+ */
2234
+ const structuredCodec = {
2235
+ post(port, envelope, transfer) {
2236
+ if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
2237
+ else port.postMessage(envelope);
2238
+ },
2239
+ read(event) {
2240
+ return isEnvelope(event.data) ? event.data : void 0;
2241
+ }
2242
+ };
2243
+ //#endregion
2244
+ //#region src/link/http-link.ts
2245
+ /**
2246
+ * The version of the HTTP-over-`MessagePort` protocol this package speaks,
2247
+ * sent in every hand-shake that sets up a link. Version 1 was this package's
2248
+ * own `handleStreams` / `sendStream` (no backpressure); version 2 is
2249
+ * webrun-rpc's `connect` / `serve` over `overPipe` with webrun-http-streams'
2250
+ * `fetchOverDuplex` / `serveFetchOverDuplex`: one port per link, calls
2251
+ * multiplexed on it, each call a stream with a one-chunk window in both
2252
+ * directions, and cancellation that reaches the handler.
2253
+ */
2254
+ const HTTP_LINK_PROTOCOL = "webrun-http-link/2";
2255
+ /**
2256
+ * Why a request failed when its link went away under it: the far side (the
2257
+ * handler, or the ServiceWorker) was lost. The request may or may not have
2258
+ * been applied.
2259
+ */
2260
+ var LinkLostError = class extends Error {
2261
+ constructor(message = "The link was lost while this request was in flight") {
2262
+ super(`${message}; it may or may not have been applied.`);
2263
+ this.name = "LinkLostError";
2264
+ }
2265
+ };
2266
+ /**
2267
+ * Posted on a link's port by the calling end when it closes the link, so the
2268
+ * answering end can release it: a `MessagePort` never reports that its other
2269
+ * end is gone.
2270
+ */
2271
+ const LINK_CLOSE = "webrun-http:link-close";
2272
+ /** Opens the calling end of a link on `port` (one port per link). */
2273
+ async function connectHttpLink(port, { streamTimeout } = {}) {
2274
+ const { call, close } = await connect({
2275
+ mux: overPipe(port, {
2276
+ codec: structuredCodec,
2277
+ side: "initiator"
2278
+ }),
2279
+ timeout: streamTimeout
2280
+ });
2281
+ return trackedLink((request) => fetchOverDuplex(call, request), async () => {
2282
+ try {
2283
+ port.postMessage({ type: LINK_CLOSE });
2284
+ } catch {}
2285
+ await close();
2286
+ });
2287
+ }
2288
+ /**
2289
+ * Wraps a `send` so the link knows its requests in flight (for draining and
2290
+ * load-based routing) and can fail them all at once when it is closed.
2291
+ */
2292
+ function trackedLink(send, release) {
2293
+ let inflight = 0;
2294
+ let closed = false;
2295
+ let lostReason;
2296
+ const failers = /* @__PURE__ */ new Set();
2297
+ const idleWaiters = [];
2298
+ const done = () => {
2299
+ inflight--;
2300
+ if (inflight === 0) for (const resolve of idleWaiters.splice(0)) resolve();
2301
+ };
2302
+ return {
2303
+ get inflight() {
2304
+ return inflight;
2305
+ },
2306
+ get closed() {
2307
+ return closed;
2308
+ },
2309
+ whenIdle() {
2310
+ return inflight === 0 ? Promise.resolve() : new Promise((r) => idleWaiters.push(r));
2311
+ },
2312
+ async send(request) {
2313
+ if (closed) throw lostReason ?? new LinkLostError("The link is closed");
2314
+ inflight++;
2315
+ let finished = false;
2316
+ const finish = () => {
2317
+ if (finished) return;
2318
+ finished = true;
2319
+ failers.delete(fail);
2320
+ done();
2321
+ };
2322
+ let fail = () => {};
2323
+ const lost = new Promise((_, reject) => {
2324
+ fail = reject;
2325
+ });
2326
+ failers.add(fail);
2327
+ lost.catch(() => {});
2328
+ let response;
2329
+ try {
2330
+ response = await Promise.race([send(request), lost]);
2331
+ } catch (error) {
2332
+ finish();
2333
+ throw lostReason ?? error;
2334
+ }
2335
+ if (!response.body) {
2336
+ finish();
2337
+ return response;
2338
+ }
2339
+ return new Response(guardBody(response.body, lost, () => lostReason, finish), {
2340
+ status: response.status,
2341
+ statusText: response.statusText,
2342
+ headers: response.headers
2343
+ });
2344
+ },
2345
+ async close(reason) {
2346
+ if (closed) return;
2347
+ closed = true;
2348
+ lostReason = reason ?? new LinkLostError("The link was closed");
2349
+ for (const fail of [...failers]) fail(lostReason);
2350
+ try {
2351
+ await release();
2352
+ } catch {}
2353
+ }
2354
+ };
2355
+ }
2356
+ /**
2357
+ * A response body that reports its end (done, cancelled or failed) and fails
2358
+ * with the link's reason when the link is lost mid-stream.
2359
+ */
2360
+ function guardBody(body, lost, reason, finish) {
2361
+ const reader = body.getReader();
2362
+ return new ReadableStream({
2363
+ async pull(controller) {
2364
+ try {
2365
+ const { done, value } = await Promise.race([reader.read(), lost]);
2366
+ if (done) {
2367
+ finish();
2368
+ controller.close();
2369
+ return;
2370
+ }
2371
+ controller.enqueue(value);
2372
+ } catch (error) {
2373
+ finish();
2374
+ reader.cancel().catch(() => {});
2375
+ controller.error(reason() ?? error);
2376
+ }
2377
+ },
2378
+ async cancel(why) {
2379
+ finish();
2380
+ await reader.cancel(why).catch(() => {});
2381
+ }
2382
+ });
2383
+ }
2384
+ //#endregion
2385
+ //#region src/sw/protocol.ts
2386
+ /** Page → ServiceWorker: open a WebSocket to a handler. */
2387
+ const WS_CONNECT_CALL = "webrun-http:ws-connect";
2388
+ //#endregion
1337
2389
  //#region src/core/data-channels.ts
1338
2390
  const MESSAGE_TYPE_REQUEST = "REQUEST";
1339
2391
  const MESSAGE_TYPE_RESPONSE = "RESPONSE";
@@ -1532,51 +2584,6 @@ async function sendHttpRequest(communicationPort, request) {
1532
2584
  })(request);
1533
2585
  }
1534
2586
  //#endregion
1535
- //#region src/relay/mount-table.ts
1536
- /** `/` stays `/`; `/peers` and `/peers/` both become `/peers/`. */
1537
- function normalise(path) {
1538
- if (path === "" || path === "/") return "/";
1539
- const withSlash = path.startsWith("/") ? path : `/${path}`;
1540
- return withSlash.endsWith("/") ? withSlash : `${withSlash}/`;
1541
- }
1542
- /** Does `prefix` own `pathname`? `/peers/` owns `/peers/`, `/peers` and `/peers/x`. */
1543
- function owns(prefix, pathname) {
1544
- if (prefix === "/") return true;
1545
- if (pathname.startsWith(prefix)) return true;
1546
- return `${pathname}/` === prefix;
1547
- }
1548
- function newMountTable(options = {}) {
1549
- const entries = /* @__PURE__ */ new Map();
1550
- let seq = 0;
1551
- return {
1552
- set(key, spec) {
1553
- entries.set(key, {
1554
- key,
1555
- prefix: spec.path == null ? "" : normalise(spec.path),
1556
- match: spec.match,
1557
- seq: seq++
1558
- });
1559
- },
1560
- remove(key) {
1561
- entries.delete(key);
1562
- },
1563
- excludes(url) {
1564
- return options.exclude?.(url) === true;
1565
- },
1566
- find(url) {
1567
- if (options.exclude?.(url) === true) return void 0;
1568
- let best;
1569
- for (const entry of entries.values()) {
1570
- if (entry.prefix === "" || !owns(entry.prefix, url.pathname)) continue;
1571
- if (best == null || entry.prefix.length > best.prefix.length) best = entry;
1572
- }
1573
- if (best != null) return best.key;
1574
- const predicates = [...entries.values()].filter((entry) => entry.match != null).sort((a, b) => a.seq - b.seq);
1575
- for (const entry of predicates) if (entry.match?.(url) === true) return entry.key;
1576
- }
1577
- };
1578
- }
1579
- //#endregion
1580
2587
  //#region src/relay/split-service-url.ts
1581
2588
  /**
1582
2589
  * Splits a URL of the form `<base>/<separator><key>/<path>` into parts.
@@ -1635,6 +2642,70 @@ function splitServiceUrl(url, separator = "~") {
1635
2642
  };
1636
2643
  }
1637
2644
  //#endregion
2645
+ //#region src/relay/index.ts
2646
+ /**
2647
+ * Sends `request` on a port a service accepted with `reply`: over an HTTP link
2648
+ * when the service speaks `HTTP_LINK_PROTOCOL`, over version 1's transport
2649
+ * when it answered `true`. The link is closed once the response is done.
2650
+ */
2651
+ async function sendOverConnection(port, reply, request, options = {}) {
2652
+ if (reply === true) return await sendHttpRequest(port, request);
2653
+ const link = await connectHttpLink(port, options);
2654
+ try {
2655
+ const response = await link.send(request);
2656
+ link.whenIdle().then(() => link.close());
2657
+ return response;
2658
+ } catch (error) {
2659
+ await link.close();
2660
+ throw error;
2661
+ }
2662
+ }
2663
+ //#endregion
2664
+ //#region src/relay/mount-table.ts
2665
+ /** `/` stays `/`; `/peers` and `/peers/` both become `/peers/`. */
2666
+ function normalise(path) {
2667
+ if (path === "" || path === "/") return "/";
2668
+ const withSlash = path.startsWith("/") ? path : `/${path}`;
2669
+ return withSlash.endsWith("/") ? withSlash : `${withSlash}/`;
2670
+ }
2671
+ /** Does `prefix` own `pathname`? `/peers/` owns `/peers/`, `/peers` and `/peers/x`. */
2672
+ function owns(prefix, pathname) {
2673
+ if (prefix === "/") return true;
2674
+ if (pathname.startsWith(prefix)) return true;
2675
+ return `${pathname}/` === prefix;
2676
+ }
2677
+ function newMountTable(options = {}) {
2678
+ const entries = /* @__PURE__ */ new Map();
2679
+ let seq = 0;
2680
+ return {
2681
+ set(key, spec) {
2682
+ entries.set(key, {
2683
+ key,
2684
+ prefix: spec.path == null ? "" : normalise(spec.path),
2685
+ match: spec.match,
2686
+ seq: seq++
2687
+ });
2688
+ },
2689
+ remove(key) {
2690
+ entries.delete(key);
2691
+ },
2692
+ excludes(url) {
2693
+ return options.exclude?.(url) === true;
2694
+ },
2695
+ find(url) {
2696
+ if (options.exclude?.(url) === true) return void 0;
2697
+ let best;
2698
+ for (const entry of entries.values()) {
2699
+ if (entry.prefix === "" || !owns(entry.prefix, url.pathname)) continue;
2700
+ if (best == null || entry.prefix.length > best.prefix.length) best = entry;
2701
+ }
2702
+ if (best != null) return best.key;
2703
+ const predicates = [...entries.values()].filter((entry) => entry.match != null).sort((a, b) => a.seq - b.seq);
2704
+ for (const entry of predicates) if (entry.match?.(url) === true) return entry.key;
2705
+ }
2706
+ };
2707
+ }
2708
+ //#endregion
1638
2709
  //#region src/relay/index-sw.ts
1639
2710
  /**
1640
2711
  * One stored registry entry, whatever shape it is on disk.
@@ -1761,7 +2832,7 @@ function startRelayServiceWorker(self, options = {}) {
1761
2832
  const source = event.source;
1762
2833
  if (!source) return false;
1763
2834
  const { key, path } = data;
1764
- if (options.canRegister != null && !await options.canRegister(source, key)) throw new Error(`this client may not register "${key}"`);
2835
+ if (options.canRegister != null && !await options.canRegister(source, key, path)) throw new Error(path == null ? `this client may not register "${key}"` : `this client may not register "${key}" at "${path}"`);
1765
2836
  if (takeover === "first-wins") {
1766
2837
  const current = await clientsRegistry.getMount(key);
1767
2838
  const isCurrentLive = current != null && await clientsRegistry.getClient(key) != null;
@@ -1776,8 +2847,14 @@ function startRelayServiceWorker(self, options = {}) {
1776
2847
  applyRegisteredMount(mounts, key, path, hostKeys);
1777
2848
  return added;
1778
2849
  }));
1779
- register(handleChannelCalls(self, "UNREGISTER", async (_event, data) => {
2850
+ register(handleChannelCalls(self, "UNREGISTER", async (event, data) => {
2851
+ const source = event.source;
2852
+ if (!source) return false;
1780
2853
  const { key } = data;
2854
+ const current = await clientsRegistry.getMount(key);
2855
+ if (current == null) return false;
2856
+ if (await clientsRegistry.getClient(key) != null && current.clientId !== source.id) throw new Error(`"${key}" is served by another client`);
2857
+ if (options.canRegister != null && !await options.canRegister(source, key, current.path)) throw new Error(`this client may not unregister "${key}"`);
1781
2858
  removeRegisteredMount(mounts, key, hostKeys);
1782
2859
  return await clientsRegistry.removeClient(key);
1783
2860
  }));
@@ -1785,7 +2862,34 @@ function startRelayServiceWorker(self, options = {}) {
1785
2862
  const { key } = data;
1786
2863
  const client = await clientsRegistry.getClient(key);
1787
2864
  if (!client) throw new Error(`Target client was not found. Target key: "${key}".`);
1788
- return await callChannel(client, "CONNECT", data, port);
2865
+ const reply = await callChannel(client, "CONNECT", data, port);
2866
+ const socketPort = reply?.port;
2867
+ return socketPort ? withTransfer(reply, [socketPort]) : reply;
2868
+ }));
2869
+ register(handleChannelCalls(self, WS_CONNECT_CALL, async (event, data) => {
2870
+ const source = event.source;
2871
+ if (!source?.url || new URL(source.url).origin !== self.location.origin) throw new Error("Only this origin's pages may open sockets");
2872
+ const { url, protocols = [] } = data;
2873
+ const httpUrl = new URL(url);
2874
+ if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
2875
+ else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
2876
+ await restored.catch(() => {});
2877
+ const key = resolveServiceKey(httpUrl, mounts, self.location.origin);
2878
+ if (key == null) throw new Error(`No service serves ${url}`);
2879
+ const client = await clientsRegistry.getClient(key);
2880
+ if (!client) throw new Error(`No service serves ${url}`);
2881
+ const channel = new MessageChannel();
2882
+ try {
2883
+ const connection = await callChannel(client, "CONNECT", {
2884
+ type: "ws",
2885
+ key,
2886
+ url: `${httpUrl}`,
2887
+ protocols
2888
+ }, channel.port2);
2889
+ return withTransfer(connection, [connection.port]);
2890
+ } finally {
2891
+ channel.port1.close();
2892
+ }
1789
2893
  }));
1790
2894
  /** Answers `request` as the service registered under `key`. */
1791
2895
  async function serve(key, request, url) {
@@ -1794,11 +2898,13 @@ function startRelayServiceWorker(self, options = {}) {
1794
2898
  const channel = new MessageChannel();
1795
2899
  const client = await clientsRegistry.getClient(key);
1796
2900
  if (!client) throw HttpError.errorResourceGone(params);
1797
- if (!await callChannel(client, "CONNECT", {
2901
+ const reply = await callChannel(client, "CONNECT", {
1798
2902
  type: "http",
1799
- key
1800
- }, channel.port2)) throw HttpError.errorForbidden(params);
1801
- const response = await sendHttpRequest(channel.port1, request);
2903
+ key,
2904
+ wire: HTTP_LINK_PROTOCOL
2905
+ }, channel.port2);
2906
+ if (!reply) throw HttpError.errorForbidden(params);
2907
+ const response = await sendOverConnection(channel.port1, reply, request, { streamTimeout: options.streamTimeout });
1802
2908
  return options.decorateResponse?.(response, request) ?? response;
1803
2909
  } catch (error) {
1804
2910
  const httpError = HttpError.fromError(error);