@statewalker/webrun-http-browser 0.7.0 → 0.9.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.
package/dist/sw.js CHANGED
@@ -22,8 +22,6 @@ function deserializeError(error) {
22
22
  const payload = typeof error === "string" ? { message: error } : error;
23
23
  return Object.assign(new Error(payload.message), payload);
24
24
  }
25
- new TextEncoder();
26
- new TextDecoder();
27
25
  /**
28
26
  * The newAsyncGenerator function creates async generators from callback-based initialization
29
27
  * functions, providing a bridge between imperative event handling and declarative async
@@ -176,7 +174,6 @@ async function* newAsyncGenerator(init, skipValues = false) {
176
174
  drainQueue();
177
175
  }
178
176
  }
179
- new TextEncoder();
180
177
  /**
181
178
  * The iterator ↔ `ReadableStream` boundary.
182
179
  *
@@ -446,8 +443,8 @@ var HttpParseError = class extends Error {
446
443
  const NEWLINE = 10;
447
444
  /** Mirrors the HTTP/1.1 codec's default `maxHeaderBytes`. */
448
445
  const MAX_ENVELOPE_BYTES = 65536;
449
- const encoder$1 = new TextEncoder();
450
- const decoder = new TextDecoder();
446
+ const encoder$1 = /* @__PURE__ */ new TextEncoder();
447
+ const decoder = /* @__PURE__ */ new TextDecoder();
451
448
  /**
452
449
  * Encode an HTTP envelope plus optional body as one continuous byte stream:
453
450
  *
@@ -701,15 +698,15 @@ function resolveFraming(headers, version) {
701
698
  };
702
699
  return { kind: "none" };
703
700
  }
704
- const CRLF = new Uint8Array([13, 10]);
705
- const LAST_CHUNK = new Uint8Array([
701
+ const CRLF = /* @__PURE__ */ new Uint8Array([13, 10]);
702
+ const LAST_CHUNK = /* @__PURE__ */ new Uint8Array([
706
703
  48,
707
704
  13,
708
705
  10,
709
706
  13,
710
707
  10
711
708
  ]);
712
- const encoder = new TextEncoder();
709
+ const encoder = /* @__PURE__ */ new TextEncoder();
713
710
  /**
714
711
  * Chunk sizes are written in HEXADECIMAL — a 21-byte chunk is `15`. Writing
715
712
  * them in decimal is the defect note 15 found in @libp2p/http, and it is the
@@ -1014,7 +1011,7 @@ function newHttpCodec(options = {}) {
1014
1011
  decodeResponse: (input, o) => decodeResponse(input, opts, o.method)
1015
1012
  };
1016
1013
  }
1017
- const httpCodec = newHttpCodec();
1014
+ const httpCodec = /* @__PURE__ */ newHttpCodec();
1018
1015
  /**
1019
1016
  * Dispatches on byte 0. The formats are self-identifying — a JSON envelope
1020
1017
  * always begins `{`, which is not a token character and so can never begin an
@@ -1064,10 +1061,29 @@ function newSniffingCodec(options) {
1064
1061
  * Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
1065
1062
  * no `codec` option is supplied.
1066
1063
  */
1067
- const defaultCodec = newSniffingCodec({
1064
+ const defaultCodec = /* @__PURE__ */ newSniffingCodec({
1068
1065
  write: httpCodec,
1069
1066
  accept: [httpCodec, jsonEnvelopeCodec]
1070
1067
  });
1068
+ /**
1069
+ * Makes `target` disposable with `await using`: installs `dispose` as its
1070
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
1071
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
1072
+ * this gives it back. A no-op where the symbol is missing. Kept off the
1073
+ * declared types, which would require consumers to compile with the
1074
+ * `esnext.disposable` lib.
1075
+ */
1076
+ function asyncDisposable$2(target, dispose) {
1077
+ const asyncDispose = Symbol.asyncDispose;
1078
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
1079
+ value: async () => {
1080
+ await dispose();
1081
+ },
1082
+ configurable: true,
1083
+ writable: true
1084
+ });
1085
+ return target;
1086
+ }
1071
1087
  /** Carries the serialized JS error between two webrun peers. */
1072
1088
  const PEER_ERROR_HEADER = "x-webrun-error";
1073
1089
  /** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
@@ -1145,41 +1161,105 @@ async function throwIfPeerError(result, output) {
1145
1161
  */
1146
1162
  async function httpFetch(call, env, body, options = {}) {
1147
1163
  const codec = options.codec ?? defaultCodec;
1164
+ const { signal } = options;
1165
+ if (signal?.aborted) throw abortReason$1(signal);
1148
1166
  const output = call(codec.encodeRequest(env, body));
1149
- const decoded = await codec.decodeResponse(output, { method: env.method });
1150
- const release = async () => {
1151
- try {
1152
- await output.return?.(void 0);
1153
- } catch {}
1167
+ let released;
1168
+ const release = () => {
1169
+ released ??= (async () => {
1170
+ try {
1171
+ await output.return?.(void 0);
1172
+ } catch {}
1173
+ })();
1174
+ return released;
1175
+ };
1176
+ let finished = false;
1177
+ const finish = () => {
1178
+ if (finished || released) return;
1179
+ finished = true;
1180
+ if (signal?.aborted) {
1181
+ release();
1182
+ return;
1183
+ }
1184
+ const onAbort = () => void release();
1185
+ signal?.addEventListener("abort", onAbort, { once: true });
1186
+ drain(output).finally(() => signal?.removeEventListener("abort", onAbort));
1154
1187
  };
1188
+ let decoded;
1189
+ try {
1190
+ decoded = await untilAborted(codec.decodeResponse(output, { method: env.method }), signal);
1191
+ } catch (error) {
1192
+ if (signal?.aborted) release();
1193
+ else await release();
1194
+ throw error;
1195
+ }
1155
1196
  const result = {
1156
1197
  envelope: decoded.envelope,
1157
- body: releasingOnExit(decoded.body, release),
1158
- release
1198
+ body: releasingOnExit(decoded.body, finish, release),
1199
+ release,
1200
+ finish
1159
1201
  };
1160
1202
  await throwIfPeerError(result, output);
1161
1203
  return result;
1162
1204
  }
1205
+ /** `promise`, rejected with the signal's reason if it aborts first. */
1206
+ function untilAborted(promise, signal) {
1207
+ if (!signal) return promise;
1208
+ return new Promise((resolve, reject) => {
1209
+ const onAbort = () => reject(abortReason$1(signal));
1210
+ if (signal.aborted) return onAbort();
1211
+ signal.addEventListener("abort", onAbort, { once: true });
1212
+ promise.then((value) => {
1213
+ signal.removeEventListener("abort", onAbort);
1214
+ resolve(value);
1215
+ }, (error) => {
1216
+ signal.removeEventListener("abort", onAbort);
1217
+ reject(error);
1218
+ });
1219
+ });
1220
+ }
1221
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
1222
+ function abortReason$1(signal) {
1223
+ const reason = signal.reason;
1224
+ if (reason instanceof Error) return reason;
1225
+ const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1226
+ err.name = "AbortError";
1227
+ return err;
1228
+ }
1163
1229
  /**
1164
- * `body`, but leaving it before its end releases the call.
1230
+ * `body`, ending the call however it is left: read to its end, or left early.
1165
1231
  *
1166
1232
  * The body the codec hands back is a generator of its own over a `ByteReader`,
1167
- * and `.return()` on it never reaches the call's `output` (see
1168
- * `throwIfPeerError` for why the codec must not do that itself). So a caller
1169
- * that stopped reading — an aborted `fetch`, a cancelled response stream —
1170
- * left the peer's producer parked forever, holding its handler open. Safe here
1171
- * for the same reason it is safe in `throwIfPeerError`: `output` is this
1172
- * call's own response-only generator, and the request was fully sent.
1233
+ * and its ending never reaches the call's `output` (see `throwIfPeerError` for
1234
+ * why the codec must not do that itself). A caller that stopped reading — an
1235
+ * aborted `fetch`, a cancelled response stream — left the peer's producer
1236
+ * parked forever; and one that read the body to its end left the call itself
1237
+ * open, which on a multiplexed transport is one port per exchange until the
1238
+ * multiplexer refuses to open more (`maxPorts`).
1239
+ *
1240
+ * Left early, the call is abandoned (`.return()`): the peer's producer is
1241
+ * released. Read to its end, the call is not cut short but read to ITS end, in
1242
+ * the background: the peer ends it once its side of the exchange is over,
1243
+ * which may be later than the response — a handler that answered before it
1244
+ * read the request body is still receiving it (`HttpDataHandlerResult.settled`),
1245
+ * and abandoning the call would abort that upload.
1173
1246
  */
1174
- async function* releasingOnExit(body, release) {
1247
+ async function* releasingOnExit(body, finish, release) {
1175
1248
  let completed = false;
1176
1249
  try {
1177
1250
  yield* body;
1178
1251
  completed = true;
1179
1252
  } finally {
1180
- if (!completed) await release();
1253
+ if (completed) finish();
1254
+ else await release();
1181
1255
  }
1182
1256
  }
1257
+ /** Reads `output` to its end, discarding what is left, ignoring a failure. */
1258
+ async function drain(output) {
1259
+ try {
1260
+ for (let r = await output.next(); !r.done; r = await output.next());
1261
+ } catch {}
1262
+ }
1183
1263
  /**
1184
1264
  * Wrap an HTTP handler as a `Duplex` so it can be registered with any
1185
1265
  * `webrun-streams-*` adapter's `serve`.
@@ -1207,6 +1287,7 @@ function httpServe(handler, options = {}) {
1207
1287
  try {
1208
1288
  yield* replyCodec.encodeResponse(result.envelope, result.body, { method: decoded.envelope.method });
1209
1289
  completed = true;
1290
+ await result.settled?.();
1210
1291
  } finally {
1211
1292
  if (!completed) try {
1212
1293
  await result.release?.();
@@ -1430,17 +1511,21 @@ async function fetchOverDuplex(call, request, options = {}) {
1430
1511
  const buffered = new Uint8Array(await request.arrayBuffer());
1431
1512
  if (buffered.byteLength > 0) body = [buffered];
1432
1513
  }
1433
- const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
1514
+ const { envelope, body: respBody, release, finish } = await httpFetch(call, env, body, {
1515
+ ...options,
1516
+ signal: request.signal ?? void 0
1517
+ });
1434
1518
  const responseInit = {
1435
1519
  status: envelope.status,
1436
1520
  statusText: envelope.statusText,
1437
1521
  headers: forwardableHeaders(envelope.headers)
1438
1522
  };
1439
1523
  if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
1440
- await release?.();
1524
+ if (finish) finish();
1525
+ else await release?.();
1441
1526
  return new Response(null, responseInit);
1442
1527
  }
1443
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
1528
+ return new Response(responseBody(respBody, request.signal, release), responseInit);
1444
1529
  }
1445
1530
  /**
1446
1531
  * Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
@@ -1452,13 +1537,21 @@ function serveFetchOverDuplex(handler, options = {}) {
1452
1537
  method: env.method,
1453
1538
  headers: forwardableHeaders(env.headers)
1454
1539
  };
1540
+ let upload;
1455
1541
  if (env.method !== "GET" && env.method !== "HEAD") {
1456
1542
  if (supportsRequestStreams()) {
1457
- reqInit.body = asyncIterableToReadable(body);
1543
+ upload = trackingEnd(body);
1544
+ reqInit.body = asyncIterableToReadable(upload.source);
1458
1545
  reqInit.duplex = "half";
1459
1546
  } else reqInit.body = await collectBytes(body);
1460
1547
  }
1461
- const response = await handler(new Request(env.url, reqInit));
1548
+ const request = new Request(env.url, reqInit);
1549
+ const response = await handler(request);
1550
+ const settled = async () => {
1551
+ if (!upload) return;
1552
+ if (!request.bodyUsed && !request.body?.locked) request.body?.cancel().catch(() => {});
1553
+ await upload.settle(options.requestIdleMs ?? 3e4);
1554
+ };
1462
1555
  const respEnv = {
1463
1556
  status: response.status,
1464
1557
  statusText: response.statusText,
@@ -1468,32 +1561,173 @@ function serveFetchOverDuplex(handler, options = {}) {
1468
1561
  await response.body?.cancel();
1469
1562
  return {
1470
1563
  envelope: respEnv,
1471
- body: void 0
1564
+ body: void 0,
1565
+ settled
1472
1566
  };
1473
1567
  }
1474
1568
  return {
1475
1569
  envelope: respEnv,
1476
1570
  body: response.body ? readableToAsyncIterable(response.body) : void 0,
1477
- release: () => response.body?.cancel()
1571
+ release: () => response.body?.cancel(),
1572
+ settled
1478
1573
  };
1479
1574
  }, options);
1480
1575
  }
1481
- async function* withAbort(iter, signal) {
1482
- if (!signal) {
1483
- yield* iter;
1484
- return;
1485
- }
1486
- for await (const chunk of iter) {
1487
- if (signal.aborted) throw abortReason$1(signal);
1488
- yield chunk;
1489
- }
1576
+ /**
1577
+ * `source`, and `settle(idleMs)`: resolves once it is done — read to its end,
1578
+ * failed, or abandoned (`return()` included before the first `next()`, which a
1579
+ * generator's `finally` would not see) — or once nobody has asked it for a
1580
+ * chunk for `idleMs`, in which case it is abandoned: later reads fail.
1581
+ *
1582
+ * Idle counts only while no `next()` is pending: a reader waiting for a slow
1583
+ * sender is still reading.
1584
+ */
1585
+ function trackingEnd(source) {
1586
+ let end = () => {};
1587
+ let isEnded = false;
1588
+ const ended = new Promise((resolve) => {
1589
+ end = () => {
1590
+ isEnded = true;
1591
+ resolve();
1592
+ };
1593
+ });
1594
+ let abandoned;
1595
+ let pending = 0;
1596
+ let idleSince = Date.now();
1597
+ const it = source[Symbol.asyncIterator]();
1598
+ const abandon = (reason) => {
1599
+ abandoned ??= reason;
1600
+ end();
1601
+ Promise.resolve(it.return?.(void 0)).catch(() => {});
1602
+ };
1603
+ const tracked = {
1604
+ [Symbol.asyncIterator]() {
1605
+ return tracked;
1606
+ },
1607
+ async next() {
1608
+ if (abandoned) throw abandoned;
1609
+ pending++;
1610
+ try {
1611
+ const r = await it.next();
1612
+ if (r.done) end();
1613
+ return r;
1614
+ } catch (error) {
1615
+ end();
1616
+ throw error;
1617
+ } finally {
1618
+ if (--pending === 0) idleSince = Date.now();
1619
+ }
1620
+ },
1621
+ async return(value) {
1622
+ end();
1623
+ try {
1624
+ await it.return?.(value);
1625
+ } catch {}
1626
+ return {
1627
+ done: true,
1628
+ value: void 0
1629
+ };
1630
+ }
1631
+ };
1632
+ asyncDisposable$2(tracked, () => tracked.return?.() ?? Promise.resolve());
1633
+ const settle = (idleMs) => {
1634
+ if (isEnded || !Number.isFinite(idleMs)) return ended;
1635
+ return new Promise((resolve) => {
1636
+ let timer;
1637
+ const check = () => {
1638
+ if (isEnded) return resolve();
1639
+ const idle = pending === 0 ? Date.now() - idleSince : 0;
1640
+ if (idle >= idleMs) {
1641
+ abandon(/* @__PURE__ */ new Error(`webrun-http-streams: the request body was left unread for ${idleMs} ms after the response; abandoned`));
1642
+ return resolve();
1643
+ }
1644
+ timer = setTimeout(check, Math.max(1, Math.min(idleMs - idle, 1e3)));
1645
+ timer.unref?.();
1646
+ };
1647
+ ended.then(() => {
1648
+ clearTimeout(timer);
1649
+ resolve();
1650
+ });
1651
+ check();
1652
+ });
1653
+ };
1654
+ return {
1655
+ source: tracked,
1656
+ settle
1657
+ };
1490
1658
  }
1491
- function abortReason$1(signal) {
1492
- const reason = signal.reason;
1493
- if (reason instanceof Error) return reason;
1494
- const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1495
- err.name = "AbortError";
1496
- return err;
1659
+ /**
1660
+ * How long a cancelled body waits for the read in flight before it abandons the call anyway.
1661
+ * The read's answer tells a body that has ended (the call is finished: an upload the handler
1662
+ * reads after answering goes on) from one that has not; a peer that sends nothing more never
1663
+ * answers it. A body whose end comes later than this after the cancel is abandoned too, so a
1664
+ * handler still streaming its response and still reading the upload then loses the upload.
1665
+ */
1666
+ const CANCEL_GRACE_MS = 1e3;
1667
+ /**
1668
+ * The response body as a stream that the request's signal can end at any time.
1669
+ *
1670
+ * An abort errors the stream with the abort's reason at once — a read waiting
1671
+ * for a peer that sends nothing more rejects then, not when a next chunk
1672
+ * arrives, which may be never — and abandons the call (`release`), so on a
1673
+ * multiplexed transport its port is given back whether or not anyone still
1674
+ * reads the body. The listener is removed once the body ends, however it ends:
1675
+ * read to its end, cancelled, failed or aborted. A body nobody reads or cancels
1676
+ * keeps it until the signal aborts (and keeps the call open until then anyway).
1677
+ * A cancel abandons the call too — unless the read in flight comes back with the
1678
+ * body's end, which finishes it instead (see `cancel`).
1679
+ */
1680
+ function responseBody(body, signal, release) {
1681
+ const it = body[Symbol.asyncIterator]();
1682
+ let controller;
1683
+ let ended = false;
1684
+ let reading = false;
1685
+ const end = () => {
1686
+ ended = true;
1687
+ signal?.removeEventListener("abort", onAbort);
1688
+ };
1689
+ const onAbort = () => {
1690
+ if (ended) return;
1691
+ end();
1692
+ try {
1693
+ controller.error(abortReason$1(signal));
1694
+ } catch {}
1695
+ release?.();
1696
+ };
1697
+ return new ReadableStream({
1698
+ start(c) {
1699
+ controller = c;
1700
+ if (signal?.aborted) onAbort();
1701
+ else signal?.addEventListener("abort", onAbort, { once: true });
1702
+ },
1703
+ async pull(c) {
1704
+ try {
1705
+ reading = true;
1706
+ const { done, value } = await it.next().finally(() => {
1707
+ reading = false;
1708
+ });
1709
+ if (ended) return;
1710
+ if (done) {
1711
+ end();
1712
+ c.close();
1713
+ return;
1714
+ }
1715
+ c.enqueue(value);
1716
+ } catch (err) {
1717
+ if (ended) return;
1718
+ end();
1719
+ c.error(err);
1720
+ }
1721
+ },
1722
+ cancel() {
1723
+ end();
1724
+ const returned = Promise.resolve(it.return?.()).catch(() => {});
1725
+ if (!reading) return;
1726
+ const grace = setTimeout(() => void release?.(), CANCEL_GRACE_MS);
1727
+ grace.unref?.();
1728
+ returned.finally(() => clearTimeout(grace));
1729
+ }
1730
+ });
1497
1731
  }
1498
1732
  //#endregion
1499
1733
  //#region ../webrun-rpc/dist/index.js
@@ -1618,8 +1852,11 @@ function listenPort(port, handler, { channelName = "", log = () => {} } = {}) {
1618
1852
  error
1619
1853
  });
1620
1854
  };
1621
- port.addEventListener("message", onMessage);
1622
- return () => port.removeEventListener("message", onMessage);
1855
+ const listener = (event) => {
1856
+ onMessage(event);
1857
+ };
1858
+ port.addEventListener("message", listener);
1859
+ return () => port.removeEventListener("message", listener);
1623
1860
  }
1624
1861
  /**
1625
1862
  * Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
@@ -1649,6 +1886,69 @@ async function* throughAbort(input, signal) {
1649
1886
  }
1650
1887
  }
1651
1888
  /**
1889
+ * Makes `target` disposable with `await using`: installs `dispose` as its
1890
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
1891
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
1892
+ * this gives it back. A no-op where the symbol is missing. Kept off the
1893
+ * declared types, which would require consumers to compile with the
1894
+ * `esnext.disposable` lib.
1895
+ */
1896
+ function asyncDisposable$1(target, dispose) {
1897
+ const asyncDispose = Symbol.asyncDispose;
1898
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
1899
+ value: async () => {
1900
+ await dispose();
1901
+ },
1902
+ configurable: true,
1903
+ writable: true
1904
+ });
1905
+ return target;
1906
+ }
1907
+ /**
1908
+ * `gen`, except that `return()` and `throw()` take effect at once.
1909
+ *
1910
+ * On an async generator both are queued behind a `next()` that is still
1911
+ * pending: a consumer that gives up on a stream while it waits for the peer's
1912
+ * next chunk — an aborted fetch whose response head has not come, say — cannot
1913
+ * end it until that chunk arrives, which for a peer that never answers is
1914
+ * never. The stream's `finally` (which closes its port and tells the peer)
1915
+ * then never runs, and on a multiplexer the port is kept for good.
1916
+ *
1917
+ * So `abandon` runs first, synchronously: it must make the pending `next()`
1918
+ * settle (abort the stream), which then resolves done, after which the
1919
+ * generator's own `return()` / `throw()` proceeds as usual. On a generator
1920
+ * suspended at a `yield` it is the same ending, a step earlier. `abandon` runs once, whichever comes first.
1921
+ */
1922
+ function returningAtOnce(gen, abandon) {
1923
+ let abandoned = false;
1924
+ const giveUp = (reason) => {
1925
+ if (abandoned) return;
1926
+ abandoned = true;
1927
+ abandon(reason);
1928
+ };
1929
+ const wrapped = {
1930
+ next: (...args) => gen.next(...args).catch((error) => {
1931
+ if (abandoned) return {
1932
+ done: true,
1933
+ value: void 0
1934
+ };
1935
+ throw error;
1936
+ }),
1937
+ return(value) {
1938
+ giveUp();
1939
+ return gen.return(value);
1940
+ },
1941
+ throw(error) {
1942
+ giveUp(error);
1943
+ return gen.throw(error);
1944
+ },
1945
+ [Symbol.asyncIterator]() {
1946
+ return wrapped;
1947
+ }
1948
+ };
1949
+ return asyncDisposable$1(wrapped, () => wrapped.return(void 0));
1950
+ }
1951
+ /**
1652
1952
  * The `type` of the out-of-band notice a side posts when it abandons a stream.
1653
1953
  *
1654
1954
  * Layer 1's `close` is not observable to layer 2 — a closed virtual port drops
@@ -1744,7 +2044,7 @@ function runCallerSide(port, input, options) {
1744
2044
  const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
1745
2045
  const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
1746
2046
  pump.catch(() => {});
1747
- return (async function* () {
2047
+ return returningAtOnce((async function* () {
1748
2048
  try {
1749
2049
  yield* inbound.stream;
1750
2050
  } finally {
@@ -1758,7 +2058,9 @@ function runCallerSide(port, input, options) {
1758
2058
  await port.close?.();
1759
2059
  } catch {}
1760
2060
  }
1761
- })();
2061
+ })(), () => {
2062
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
2063
+ });
1762
2064
  }
1763
2065
  /**
1764
2066
  * Listens for the peer's abort notice, and can post our own. The notice is a
@@ -1959,6 +2261,33 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
1959
2261
  }
1960
2262
  }
1961
2263
  /**
2264
+ * Runs `fn` on a later task, never a microtask: a producer looping on
2265
+ * `whenPortReady` must let the transport's own events run. `setImmediate`
2266
+ * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
2267
+ * are clamped to 4 ms in browsers.
2268
+ */
2269
+ const setImmediateFn = globalThis.setImmediate;
2270
+ /** @internal */
2271
+ const nextTask = setImmediateFn ? (fn) => {
2272
+ setImmediateFn(fn);
2273
+ } : (() => {
2274
+ let channel;
2275
+ const queue = [];
2276
+ return (fn) => {
2277
+ if (!channel) {
2278
+ channel = new MessageChannel();
2279
+ channel.port1.onmessage = () => queue.shift()?.();
2280
+ }
2281
+ queue.push(fn);
2282
+ channel.port2.postMessage(0);
2283
+ };
2284
+ })();
2285
+ const states = /* @__PURE__ */ new WeakMap();
2286
+ /** @internal Registered by the multiplexer for each port with a window. */
2287
+ function setPortSendState(port, state) {
2288
+ states.set(port, state);
2289
+ }
2290
+ /**
1962
2291
  * One virtual port.
1963
2292
  *
1964
2293
  * `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
@@ -1973,6 +2302,14 @@ function newVirtualPort(send, requestClose) {
1973
2302
  if (closing.signal.aborted) return;
1974
2303
  closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
1975
2304
  };
2305
+ const close = (reason) => {
2306
+ if (closed) return;
2307
+ closed = true;
2308
+ const notify = requestClose;
2309
+ listeners.clear();
2310
+ notify(reason);
2311
+ abort(reason);
2312
+ };
1976
2313
  const port = {
1977
2314
  addEventListener(_type, listener) {
1978
2315
  listeners.add(listener);
@@ -1984,24 +2321,28 @@ function newVirtualPort(send, requestClose) {
1984
2321
  if (closed) return;
1985
2322
  send(message, transfer);
1986
2323
  },
1987
- close() {
1988
- if (closed) return;
1989
- closed = true;
1990
- const notify = requestClose;
1991
- listeners.clear();
1992
- notify();
1993
- abort();
1994
- }
2324
+ close
1995
2325
  };
1996
2326
  setPortCloseSignal(port, closing.signal);
2327
+ const dispose = Symbol.dispose;
2328
+ if (dispose) Object.defineProperty(port, dispose, {
2329
+ value: () => close(),
2330
+ configurable: true
2331
+ });
1997
2332
  return {
1998
2333
  port,
1999
2334
  deliver(payload) {
2000
2335
  if (closed) return;
2001
2336
  const event = new MessageEvent("message", { data: payload });
2337
+ let pending;
2002
2338
  for (const listener of [...listeners]) try {
2003
- listener(event);
2339
+ const result = listener(event);
2340
+ if (result && typeof result.then === "function") {
2341
+ pending ??= [];
2342
+ pending.push(result);
2343
+ }
2004
2344
  } catch {}
2345
+ return pending ? Promise.allSettled(pending) : void 0;
2005
2346
  },
2006
2347
  markClosed(reason) {
2007
2348
  closed = true;
@@ -2010,53 +2351,309 @@ function newVirtualPort(send, requestClose) {
2010
2351
  },
2011
2352
  isClosed() {
2012
2353
  return closed;
2354
+ },
2355
+ hasListeners() {
2356
+ return listeners.size > 0;
2013
2357
  }
2014
2358
  };
2015
2359
  }
2016
2360
  /**
2017
- * The message of the error that fails the calls still in flight on a
2018
- * multiplexer when it is closed (`PortMux.close()`).
2361
+ * The id of keepalive's `ping` / `pong`: one no port ever takes. An older mux
2362
+ * whose codec passes unknown envelopes through reads them as a close of that
2363
+ * id, which must therefore not be a real port (0 is the initiator's first).
2019
2364
  */
2020
- const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
2365
+ const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
2366
+ /** The longest delay a timer holds; past it `setInterval` fires at once. */
2367
+ const MAX_TIMER_MS = 2 ** 31 - 1;
2021
2368
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
2022
2369
  const DEFAULT_MAX_PORTS = 1024;
2023
2370
  /**
2371
+ * What a message costs a port's window when the codec cannot say
2372
+ * (`PortCodec.encode`): its bytes and string lengths, found in its values up
2373
+ * to 4 levels deep (at most 256 values), and at least 64. Both ends compute it
2374
+ * from the same structured value, so they agree.
2375
+ */
2376
+ function estimateMessageSize(payload) {
2377
+ let budget = 256;
2378
+ const walk = (v, depth) => {
2379
+ if (budget-- <= 0) return 0;
2380
+ if (typeof v === "string") return v.length;
2381
+ if (v instanceof ArrayBuffer) return v.byteLength;
2382
+ if (ArrayBuffer.isView(v)) return v.byteLength;
2383
+ if (v === null || typeof v !== "object" || depth >= 4) return 8;
2384
+ let n = 16;
2385
+ if (Array.isArray(v)) for (const x of v) n += walk(x, depth + 1);
2386
+ else for (const k in v) n += k.length + walk(v[k], depth + 1);
2387
+ return n;
2388
+ };
2389
+ return Math.max(64, walk(payload, 0));
2390
+ }
2391
+ /**
2024
2392
  * The default `PortMux`: emulates multiplexing over a single port.
2025
2393
  *
2026
2394
  * A transport that already multiplexes natively supplies its own `PortMux`
2027
2395
  * instead — this implementation is for transports that offer one pipe.
2028
2396
  */
2029
2397
  function multiplexPort(port, options) {
2030
- const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
2398
+ const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
2399
+ const misses = keepalive?.misses ?? 3;
2400
+ if (keepalive && !(Number.isFinite(keepalive.intervalMs) && keepalive.intervalMs > 0 && keepalive.intervalMs <= MAX_TIMER_MS && Number.isSafeInteger(misses) && misses > 0)) throw new RangeError(`webrun-rpc: keepalive needs an intervalMs in (0, ${MAX_TIMER_MS}] and a positive integer misses`);
2401
+ if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
2031
2402
  const open = /* @__PURE__ */ new Map();
2403
+ const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
2404
+ const entry = open.get(id);
2405
+ if (entry?.handle !== record) return;
2406
+ const { handle: _, credit: __, partial: ___, ...info } = entry;
2407
+ if (!record.hasListeners()) {
2408
+ release("leaked");
2409
+ record.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was dropped without close()"));
2410
+ }
2411
+ try {
2412
+ onLeak?.(info);
2413
+ } catch {}
2414
+ }) : void 0;
2415
+ const touch = (id) => {
2416
+ const entry = open.get(id);
2417
+ if (entry) entry.lastActivity = Date.now();
2418
+ };
2032
2419
  let nextId = side === "initiator" ? 0 : 1;
2033
2420
  let muxClosed = false;
2421
+ /** This end counts encoded bytes and reassembles frames. */
2422
+ const exactCodec = codec.encode != null && codec.decode != null;
2034
2423
  const post = (envelope, transfer) => {
2035
2424
  if (muxClosed) return;
2425
+ codecPost(envelope, transfer);
2426
+ };
2427
+ /** Like `post`, also while closing: the mux's own close notices. */
2428
+ const codecPost = (envelope, transfer) => {
2036
2429
  try {
2037
2430
  codec.post(port, envelope, transfer);
2038
2431
  } catch {}
2039
2432
  };
2040
- const attach = (id) => {
2041
- let self;
2042
- self = newVirtualPort((payload, transfer) => post({
2043
- type: "message",
2044
- id,
2045
- payload
2046
- }, transfer), (reason) => {
2433
+ const outgoing = /* @__PURE__ */ new Map();
2434
+ const ring = [];
2435
+ let pumping = false;
2436
+ const pump = async () => {
2437
+ pumping = true;
2438
+ try {
2439
+ while (ring.length && !muxClosed) {
2440
+ if (port.whenDrained) await port.whenDrained();
2441
+ if (muxClosed) break;
2442
+ const id = ring.shift();
2443
+ const queue = outgoing.get(id);
2444
+ const item = queue?.shift();
2445
+ if (queue?.length) ring.push(id);
2446
+ else outgoing.delete(id);
2447
+ item?.();
2448
+ }
2449
+ } catch (err) {
2450
+ close(err).catch(() => {});
2451
+ } finally {
2452
+ pumping = false;
2453
+ }
2454
+ };
2455
+ const sendOnPort = (id, item) => {
2456
+ if (frameSize == null) {
2457
+ item();
2458
+ return;
2459
+ }
2460
+ let queue = outgoing.get(id);
2461
+ if (!queue) {
2462
+ queue = [];
2463
+ outgoing.set(id, queue);
2464
+ ring.push(id);
2465
+ }
2466
+ queue.push(item);
2467
+ if (!pumping) pump();
2468
+ };
2469
+ const dropOutgoing = (id) => {
2470
+ if (!outgoing.delete(id)) return;
2471
+ const at = ring.indexOf(id);
2472
+ if (at >= 0) ring.splice(at, 1);
2473
+ };
2474
+ const postBytes = (bytes) => {
2475
+ if (muxClosed) return;
2476
+ try {
2477
+ port.postMessage(bytes);
2478
+ } catch {}
2479
+ };
2480
+ const releaseWaiters = (c) => {
2481
+ for (const w of c?.waiters.splice(0) ?? []) w.resolve();
2482
+ };
2483
+ const flush = (c) => {
2484
+ while (c.queue.length) {
2485
+ const next = c.queue[0];
2486
+ if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
2487
+ c.queue.shift();
2488
+ c.queued -= next.size;
2489
+ c.outstanding += next.size;
2490
+ next.send();
2491
+ }
2492
+ for (const w of [...c.waiters]) if (c.queued <= w.below) {
2493
+ c.waiters.splice(c.waiters.indexOf(w), 1);
2494
+ w.resolve();
2495
+ }
2496
+ };
2497
+ const grant = (id, c, bytes) => {
2498
+ c.unconsumed -= bytes;
2499
+ c.toGrant += bytes;
2500
+ const send = () => {
2501
+ c.grantScheduled = false;
2502
+ if (!c.toGrant || !open.has(id)) return;
2047
2503
  post({
2504
+ type: "credit",
2505
+ id,
2506
+ bytes: c.toGrant
2507
+ });
2508
+ c.toGrant = 0;
2509
+ };
2510
+ if (c.toGrant >= c.window / 4) {
2511
+ send();
2512
+ return;
2513
+ }
2514
+ if (c.grantScheduled) return;
2515
+ c.grantScheduled = true;
2516
+ setTimeout(send, 0);
2517
+ };
2518
+ const attach = (id, meta, portWindow, opener, exact) => {
2519
+ const c = typeof portWindow === "number" && Number.isSafeInteger(portWindow) && portWindow > 0 ? {
2520
+ window: portWindow,
2521
+ enforced: false,
2522
+ opener,
2523
+ exact,
2524
+ outstanding: 0,
2525
+ queue: [],
2526
+ queued: 0,
2527
+ unconsumed: 0,
2528
+ waiters: [],
2529
+ toGrant: 0,
2530
+ grantScheduled: false
2531
+ } : void 0;
2532
+ const self = newVirtualPort((payload, transfer) => {
2533
+ touch(id);
2534
+ const envelope = {
2535
+ type: "message",
2536
+ id,
2537
+ payload
2538
+ };
2539
+ if (!c) {
2540
+ post(envelope, transfer);
2541
+ return;
2542
+ }
2543
+ const bytes = c.exact ? codec.encode?.(envelope) : void 0;
2544
+ const size = bytes?.byteLength ?? estimateMessageSize(payload);
2545
+ const waits = frameSize != null || c.queue.length > 0 || c.enforced && c.outstanding > 0 && c.outstanding + size > c.window;
2546
+ const held = !bytes && waits ? {
2547
+ ...envelope,
2548
+ payload: structuredClone(payload, { transfer })
2549
+ } : void 0;
2550
+ const send = () => {
2551
+ if (!bytes) {
2552
+ sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
2553
+ return;
2554
+ }
2555
+ if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2556
+ for (let at = 0; at < bytes.byteLength; at += frameSize) {
2557
+ const data = bytes.subarray(at, at + frameSize);
2558
+ const frame = at + frameSize >= bytes.byteLength ? {
2559
+ type: "frame",
2560
+ id,
2561
+ data,
2562
+ last: true
2563
+ } : {
2564
+ type: "frame",
2565
+ id,
2566
+ data
2567
+ };
2568
+ sendOnPort(id, () => post(frame));
2569
+ }
2570
+ return;
2571
+ }
2572
+ sendOnPort(id, () => postBytes(bytes));
2573
+ };
2574
+ c.queue.push({
2575
+ send,
2576
+ size
2577
+ });
2578
+ c.queued += size;
2579
+ flush(c);
2580
+ }, (reason) => {
2581
+ release(reason);
2582
+ record.markClosed(reason);
2583
+ });
2584
+ function release(reason) {
2585
+ open.delete(id);
2586
+ leaks?.unregister(record);
2587
+ if (c?.queue.length) {
2588
+ for (const item of c.queue.splice(0)) item.send();
2589
+ c.queued = 0;
2590
+ }
2591
+ sendOnPort(id, () => post({
2048
2592
  type: "close",
2049
2593
  id,
2050
2594
  reason
2595
+ }));
2596
+ releaseWaiters(c);
2597
+ }
2598
+ const { markClosed, isClosed, hasListeners } = self;
2599
+ if (c) {
2600
+ const deliver = self.deliver;
2601
+ self.deliver = (payload, size = estimateMessageSize(payload)) => {
2602
+ c.unconsumed += size;
2603
+ const done = deliver(payload);
2604
+ if (done) done.then(() => grant(id, c, size));
2605
+ else grant(id, c, size);
2606
+ return done;
2607
+ };
2608
+ setPortSendState(self.port, {
2609
+ bufferedAmount: () => c.queued,
2610
+ ready: (below = 0) => new Promise((resolve) => {
2611
+ if (c.queued <= below || isClosed()) nextTask(resolve);
2612
+ else c.waiters.push({
2613
+ below,
2614
+ resolve: () => nextTask(resolve)
2615
+ });
2616
+ })
2051
2617
  });
2052
- open.delete(id);
2053
- self.markClosed();
2618
+ }
2619
+ const record = {
2620
+ deliver: self.deliver,
2621
+ markClosed,
2622
+ isClosed,
2623
+ hasListeners,
2624
+ close: (reason) => {
2625
+ if (isClosed()) return;
2626
+ release(reason);
2627
+ markClosed(reason);
2628
+ }
2629
+ };
2630
+ leaks?.register(self.port, {
2631
+ id,
2632
+ record,
2633
+ release
2634
+ }, record);
2635
+ const now = Date.now();
2636
+ open.set(id, {
2637
+ id,
2638
+ meta,
2639
+ openedAt: now,
2640
+ lastActivity: now,
2641
+ handle: record,
2642
+ credit: c
2054
2643
  });
2055
- open.set(id, self);
2056
2644
  return self;
2057
2645
  };
2058
- const handleEnvelope = (envelope) => {
2646
+ const handleEnvelope = (envelope, event) => {
2059
2647
  if (muxClosed) return;
2648
+ heardSincePing = true;
2649
+ if (envelope.type === "ping" || envelope.type === "pong") {
2650
+ peerKeepsAlive = true;
2651
+ if (envelope.type === "ping") post({
2652
+ type: "pong",
2653
+ id: KEEPALIVE_ID
2654
+ });
2655
+ return;
2656
+ }
2060
2657
  if (envelope.type === "open") {
2061
2658
  if (open.has(envelope.id)) return;
2062
2659
  if (open.size >= maxPorts) {
@@ -2067,7 +2664,18 @@ function multiplexPort(port, options) {
2067
2664
  });
2068
2665
  return;
2069
2666
  }
2070
- const handle = attach(envelope.id);
2667
+ const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
2668
+ const credit = open.get(envelope.id)?.credit;
2669
+ if (credit) post(credit.exact ? {
2670
+ type: "credit",
2671
+ id: envelope.id,
2672
+ bytes: 0,
2673
+ exact: true
2674
+ } : {
2675
+ type: "credit",
2676
+ id: envelope.id,
2677
+ bytes: 0
2678
+ });
2071
2679
  let accepted = false;
2072
2680
  if (onPort) try {
2073
2681
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -2082,57 +2690,191 @@ function multiplexPort(port, options) {
2082
2690
  id: envelope.id,
2083
2691
  reason: "rejected"
2084
2692
  });
2693
+ return;
2085
2694
  }
2086
2695
  return;
2087
2696
  }
2088
- const handle = open.get(envelope.id);
2697
+ const entry = open.get(envelope.id);
2698
+ if (envelope.type === "credit") {
2699
+ const c = entry?.credit;
2700
+ if (!c || !(envelope.bytes >= 0)) return;
2701
+ if (!c.enforced && c.opener) {
2702
+ if (c.exact && envelope.exact !== true) {
2703
+ c.exact = false;
2704
+ c.outstanding = 0;
2705
+ }
2706
+ post({
2707
+ type: "credit",
2708
+ id: envelope.id,
2709
+ bytes: 0
2710
+ });
2711
+ }
2712
+ c.enforced = true;
2713
+ c.outstanding = Math.max(0, c.outstanding - envelope.bytes);
2714
+ flush(c);
2715
+ return;
2716
+ }
2089
2717
  if (envelope.type === "message") {
2090
- if (!handle) return;
2091
- handle.deliver(envelope.payload);
2718
+ const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
2719
+ if (!entry) return;
2720
+ touch(envelope.id);
2721
+ entry.handle.deliver(envelope.payload, entry.credit ? sizeOf(entry.credit) : void 0);
2092
2722
  return;
2093
2723
  }
2094
- if (!handle) return;
2724
+ if (envelope.type === "frame") {
2725
+ if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
2726
+ touch(envelope.id);
2727
+ const partial = entry.partial ?? {
2728
+ chunks: [],
2729
+ bytes: 0
2730
+ };
2731
+ entry.partial = partial;
2732
+ partial.chunks.push(envelope.data);
2733
+ partial.bytes += envelope.data.byteLength;
2734
+ if (!envelope.last) return;
2735
+ entry.partial = void 0;
2736
+ const whole = new Uint8Array(partial.bytes);
2737
+ let at = 0;
2738
+ for (const chunk of partial.chunks) {
2739
+ whole.set(chunk, at);
2740
+ at += chunk.byteLength;
2741
+ }
2742
+ const inner = codec.decode(whole);
2743
+ if (inner?.type !== "message" || inner.id !== envelope.id) return;
2744
+ entry.handle.deliver(inner.payload, whole.byteLength);
2745
+ return;
2746
+ }
2747
+ dropOutgoing(envelope.id);
2748
+ if (!entry) return;
2095
2749
  open.delete(envelope.id);
2096
- handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
2750
+ releaseWaiters(entry.credit);
2751
+ entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
2097
2752
  };
2098
2753
  const listener = (event) => {
2099
2754
  const envelope = codec.read(event);
2100
- if (envelope) handleEnvelope(envelope);
2755
+ if (envelope) handleEnvelope(envelope, event);
2756
+ };
2757
+ let peerKeepsAlive = false;
2758
+ let heardSincePing = true;
2759
+ let unanswered = 0;
2760
+ let pinger;
2761
+ const tick = () => {
2762
+ if (!keepalive) return;
2763
+ const backlogged = (port.bufferedAmount ?? 0) > 0;
2764
+ if (heardSincePing) unanswered = 0;
2765
+ else if (peerKeepsAlive && !backlogged) unanswered++;
2766
+ if (unanswered >= misses) {
2767
+ close(/* @__PURE__ */ new Error(`webrun-rpc: the peer did not answer ${misses} keepalive pings, ${keepalive.intervalMs} ms apart`)).catch(() => {});
2768
+ return;
2769
+ }
2770
+ heardSincePing = false;
2771
+ post({
2772
+ type: "ping",
2773
+ id: KEEPALIVE_ID
2774
+ });
2775
+ };
2776
+ const close = async (reason) => {
2777
+ if (muxClosed) return;
2778
+ clearInterval(pinger);
2779
+ muxClosed = true;
2780
+ signal?.removeEventListener("abort", onTransportAbort);
2781
+ port.removeEventListener("message", listener);
2782
+ const closedNow = /* @__PURE__ */ new Set();
2783
+ for (const [id, { handle, credit }] of [...open]) {
2784
+ closedNow.add(id);
2785
+ codecPost({
2786
+ type: "close",
2787
+ id
2788
+ });
2789
+ open.delete(id);
2790
+ releaseWaiters(credit);
2791
+ handle.markClosed(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the multiplexer was closed"));
2792
+ }
2793
+ for (const id of outgoing.keys()) if (!closedNow.has(id)) codecPost({
2794
+ type: "close",
2795
+ id
2796
+ });
2797
+ outgoing.clear();
2798
+ ring.length = 0;
2799
+ await port.close?.();
2800
+ };
2801
+ const onTransportAbort = () => {
2802
+ close(signal?.reason).catch(() => {});
2101
2803
  };
2102
2804
  port.addEventListener("message", listener);
2103
2805
  port.start?.();
2806
+ if (keepalive) {
2807
+ pinger = setInterval(tick, keepalive.intervalMs);
2808
+ pinger.unref?.();
2809
+ }
2810
+ if (signal?.aborted) close(signal.reason).catch(() => {});
2811
+ else signal?.addEventListener("abort", onTransportAbort, { once: true });
2104
2812
  return {
2105
2813
  maxMessageSize,
2106
- async openPort(meta) {
2814
+ async openPort(meta, { signal: portSignal } = {}) {
2107
2815
  if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
2108
- if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
2816
+ if (portSignal?.aborted) throw portSignal.reason;
2817
+ if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached; open ports by kind: ${describeKinds(open.values())}`);
2109
2818
  while (open.has(nextId)) nextId += 2;
2110
2819
  const id = nextId;
2111
2820
  nextId += 2;
2112
- const handle = attach(id);
2113
- post({
2821
+ const handle = attach(id, meta, window, true, exactCodec);
2822
+ post(window ? exactCodec ? {
2823
+ type: "open",
2824
+ id,
2825
+ meta,
2826
+ window,
2827
+ exact: true
2828
+ } : {
2829
+ type: "open",
2830
+ id,
2831
+ meta,
2832
+ window
2833
+ } : {
2114
2834
  type: "open",
2115
2835
  id,
2116
2836
  meta
2117
2837
  });
2838
+ if (portSignal) {
2839
+ const record = open.get(id)?.handle;
2840
+ const onAbort = () => {
2841
+ if (record && open.get(id)?.handle === record) record.close(portSignal.reason);
2842
+ };
2843
+ portSignal.addEventListener("abort", onAbort, { once: true });
2844
+ getPortCloseSignal(handle.port)?.addEventListener("abort", () => portSignal.removeEventListener("abort", onAbort), { once: true });
2845
+ }
2118
2846
  return handle.port;
2119
2847
  },
2120
- async close() {
2121
- if (muxClosed) return;
2122
- for (const [id, handle] of [...open]) {
2123
- post({
2124
- type: "close",
2125
- id
2126
- });
2127
- open.delete(id);
2128
- handle.markClosed(/* @__PURE__ */ new Error(MUX_CLOSED_MESSAGE));
2129
- }
2130
- muxClosed = true;
2131
- port.removeEventListener("message", listener);
2132
- await port.close?.();
2848
+ close,
2849
+ ports() {
2850
+ return [...open.values()].map(({ handle: _, credit, ...info }) => credit ? {
2851
+ ...info,
2852
+ ...credit.enforced ? { window: credit.window } : {},
2853
+ queued: credit.queued,
2854
+ outstanding: credit.outstanding,
2855
+ unconsumed: credit.unconsumed
2856
+ } : info);
2133
2857
  }
2134
2858
  };
2135
2859
  }
2860
+ /** The byte length of a received message's data, or `undefined` if it is not bytes. */
2861
+ function byteLengthOf(data) {
2862
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return data.byteLength;
2863
+ }
2864
+ /**
2865
+ * `"http: 1000, ws: 24"`: the three commonest `meta.kind`s among `entries`, so
2866
+ * a full table names who filled it. A port without a kind counts as `"?"`.
2867
+ */
2868
+ function describeKinds(entries) {
2869
+ const counts = /* @__PURE__ */ new Map();
2870
+ for (const { meta } of entries) {
2871
+ const kind = meta?.kind;
2872
+ const name = typeof kind === "string" ? kind : "?";
2873
+ counts.set(name, (counts.get(name) ?? 0) + 1);
2874
+ }
2875
+ if (!counts.size) return "none";
2876
+ return [...counts].sort((x, y) => y[1] - x[1]).slice(0, 3).map(([name, n]) => `${name}: ${n}`).join(", ");
2877
+ }
2136
2878
  /**
2137
2879
  * Announced with every port this pair opens. Layer 1 never reads `meta` — it is
2138
2880
  * here so a peer running something else over the same multiplexer can tell a
@@ -2154,9 +2896,22 @@ const connect = async ({ mux: factory, timeout }) => {
2154
2896
  maxMessageSize: mux.maxMessageSize,
2155
2897
  timeout
2156
2898
  };
2157
- const call = (input) => (async function* () {
2158
- yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
2159
- })();
2899
+ const call = (input) => {
2900
+ let stream;
2901
+ let abandoned = false;
2902
+ return returningAtOnce((async function* () {
2903
+ const port = await mux.openPort(STREAM_META);
2904
+ if (abandoned) {
2905
+ await port.close?.();
2906
+ return;
2907
+ }
2908
+ stream = duplexOverPort(port, streamOptions)(input);
2909
+ yield* stream;
2910
+ })(), () => {
2911
+ abandoned = true;
2912
+ stream?.return(void 0).catch(() => {});
2913
+ });
2914
+ };
2160
2915
  return {
2161
2916
  call,
2162
2917
  async close() {
@@ -2226,7 +2981,7 @@ function isEnvelope(value) {
2226
2981
  const candidate = value;
2227
2982
  if (typeof candidate.id !== "number") return false;
2228
2983
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
2229
- return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
2984
+ return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
2230
2985
  }
2231
2986
  /**
2232
2987
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -2247,6 +3002,27 @@ const structuredCodec = {
2247
3002
  }
2248
3003
  };
2249
3004
  //#endregion
3005
+ //#region src/link/async-dispose.ts
3006
+ /**
3007
+ * Makes `target` disposable with `await using`: installs `dispose` as its
3008
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
3009
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
3010
+ * this gives it back. A no-op where the symbol is missing. Kept off the
3011
+ * declared types, which would require consumers to compile with the
3012
+ * `esnext.disposable` lib.
3013
+ */
3014
+ function asyncDisposable(target, dispose) {
3015
+ const asyncDispose = Symbol.asyncDispose;
3016
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
3017
+ value: async () => {
3018
+ await dispose();
3019
+ },
3020
+ configurable: true,
3021
+ writable: true
3022
+ });
3023
+ return target;
3024
+ }
3025
+ //#endregion
2250
3026
  //#region src/link/http-link.ts
2251
3027
  /**
2252
3028
  * The version of the HTTP-over-`MessagePort` protocol this package speaks,
@@ -2276,11 +3052,12 @@ var LinkLostError = class extends Error {
2276
3052
  */
2277
3053
  const LINK_CLOSE = "webrun-http:link-close";
2278
3054
  /** Opens the calling end of a link on `port` (one port per link). */
2279
- async function connectHttpLink(port, { streamTimeout } = {}) {
3055
+ async function connectHttpLink(port, { streamTimeout, maxPorts } = {}) {
2280
3056
  const { call, close } = await connect({
2281
3057
  mux: overPipe(port, {
2282
3058
  codec: structuredCodec,
2283
- side: "initiator"
3059
+ side: "initiator",
3060
+ maxPorts
2284
3061
  }),
2285
3062
  timeout: streamTimeout
2286
3063
  });
@@ -2296,14 +3073,15 @@ async function connectHttpLink(port, { streamTimeout } = {}) {
2296
3073
  * that abandons the requests still running (their handlers see the
2297
3074
  * cancellation) and releases the port.
2298
3075
  */
2299
- async function serveHttpLink(port, handler, { streamTimeout } = {}) {
3076
+ async function serveHttpLink(port, handler, { streamTimeout, maxPorts, requestIdleMs } = {}) {
2300
3077
  const teardown = await serve({
2301
3078
  mux: overPipe(port, {
2302
3079
  codec: structuredCodec,
2303
- side: "responder"
3080
+ side: "responder",
3081
+ maxPorts
2304
3082
  }),
2305
3083
  timeout: streamTimeout
2306
- }, serveFetchOverDuplex(async (request) => await handler(request)));
3084
+ }, serveFetchOverDuplex(async (request) => await handler(request), { requestIdleMs }));
2307
3085
  const onClose = (event) => {
2308
3086
  if (event.data?.type === LINK_CLOSE) close();
2309
3087
  };
@@ -2328,7 +3106,7 @@ function trackedLink(send, release) {
2328
3106
  inflight--;
2329
3107
  if (inflight === 0) for (const resolve of idleWaiters.splice(0)) resolve();
2330
3108
  };
2331
- return {
3109
+ const link = {
2332
3110
  get inflight() {
2333
3111
  return inflight;
2334
3112
  },
@@ -2365,7 +3143,7 @@ function trackedLink(send, release) {
2365
3143
  finish();
2366
3144
  return response;
2367
3145
  }
2368
- return new Response(guardBody(response.body, lost, () => lostReason, finish), {
3146
+ return new Response(guardBody(response.body, lost, () => lostReason, finish, request.signal), {
2369
3147
  status: response.status,
2370
3148
  statusText: response.statusText,
2371
3149
  headers: response.headers
@@ -2381,35 +3159,68 @@ function trackedLink(send, release) {
2381
3159
  } catch {}
2382
3160
  }
2383
3161
  };
3162
+ return asyncDisposable(link, () => link.close());
2384
3163
  }
2385
3164
  /**
2386
- * A response body that reports its end (done, cancelled or failed) and fails
2387
- * with the link's reason when the link is lost mid-stream.
3165
+ * A response body that reports its end (done, cancelled, failed or aborted) and
3166
+ * fails with the link's reason when the link is lost mid-stream. An abort of the
3167
+ * request's signal ends it at once, read or not: the exchange is over (the layer
3168
+ * below has given its port back), so the request leaves `inflight` then.
2388
3169
  */
2389
- function guardBody(body, lost, reason, finish) {
3170
+ function guardBody(body, lost, reason, finish, signal) {
2390
3171
  const reader = body.getReader();
3172
+ let controller;
3173
+ let ended = false;
3174
+ const end = () => {
3175
+ ended = true;
3176
+ signal?.removeEventListener("abort", onAbort);
3177
+ finish();
3178
+ };
3179
+ const onAbort = () => {
3180
+ if (ended) return;
3181
+ end();
3182
+ reader.cancel().catch(() => {});
3183
+ try {
3184
+ controller.error(abortError(signal));
3185
+ } catch {}
3186
+ };
2391
3187
  return new ReadableStream({
3188
+ start(c) {
3189
+ controller = c;
3190
+ if (signal?.aborted) onAbort();
3191
+ else signal?.addEventListener("abort", onAbort, { once: true });
3192
+ },
2392
3193
  async pull(controller) {
2393
3194
  try {
2394
3195
  const { done, value } = await Promise.race([reader.read(), lost]);
3196
+ if (ended) return;
2395
3197
  if (done) {
2396
- finish();
3198
+ end();
2397
3199
  controller.close();
2398
3200
  return;
2399
3201
  }
2400
3202
  controller.enqueue(value);
2401
3203
  } catch (error) {
2402
- finish();
3204
+ if (ended) return;
3205
+ end();
2403
3206
  reader.cancel().catch(() => {});
2404
3207
  controller.error(reason() ?? error);
2405
3208
  }
2406
3209
  },
2407
3210
  async cancel(why) {
2408
- finish();
3211
+ end();
2409
3212
  await reader.cancel(why).catch(() => {});
2410
3213
  }
2411
3214
  });
2412
3215
  }
3216
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
3217
+ function abortError(signal) {
3218
+ const why = signal.reason;
3219
+ if (why instanceof Error) return why;
3220
+ const err = new Error(why === void 0 ? "Aborted" : String(why));
3221
+ err.name = "AbortError";
3222
+ return err;
3223
+ }
2413
3224
  //#endregion
2414
3225
  //#region src/link/locks.ts
2415
3226
  function lockManager() {
@@ -5225,6 +6036,8 @@ var WorkerLinkImpl = class {
5225
6036
  _ownerLock;
5226
6037
  _attaching;
5227
6038
  _detached = false;
6039
+ /** The `seq` of the last link sent to the worker. */
6040
+ _sent = 0;
5228
6041
  constructor(_adapter, _control, init) {
5229
6042
  this._adapter = _adapter;
5230
6043
  this._control = _control;
@@ -5236,6 +6049,7 @@ var WorkerLinkImpl = class {
5236
6049
  _control.onmessage = (event) => {
5237
6050
  const message = event.data;
5238
6051
  if (message?.type === "relink") {
6052
+ if (message.seen !== void 0 && message.seen < this._sent) return;
5239
6053
  this._adapter._poke(WAKE_MESSAGE);
5240
6054
  this.attach().catch(() => {});
5241
6055
  } else if (message?.type === "keepalive") this._adapter._poke(KEEPALIVE_MESSAGE);
@@ -5282,7 +6096,8 @@ var WorkerLinkImpl = class {
5282
6096
  this._control.postMessage({
5283
6097
  type: "link",
5284
6098
  key: this.key,
5285
- routerLock: reply.routerLock
6099
+ routerLock: reply.routerLock,
6100
+ seq: ++this._sent
5286
6101
  }, [
5287
6102
  down.port2,
5288
6103
  ctl.port2,
@@ -5383,12 +6198,18 @@ function serveHttpInWorker(handler, options = {}) {
5383
6198
  const ownerLock = newLockName("webrun-http-owner");
5384
6199
  const held = holdLock(ownerLock);
5385
6200
  const controls = /* @__PURE__ */ new Set();
6201
+ const seen = /* @__PURE__ */ new Map();
5386
6202
  const tellPages = (message) => {
5387
6203
  for (const control of controls) control.postMessage(message);
5388
6204
  };
5389
6205
  const endpoint = new HandlerEndpoint(handler, {
5390
6206
  ...options,
5391
- requestRelink: () => tellPages({ type: "relink" }),
6207
+ requestRelink: () => {
6208
+ for (const control of controls) control.postMessage({
6209
+ type: "relink",
6210
+ seen: seen.get(control) ?? 0
6211
+ });
6212
+ },
5392
6213
  keepAlive: () => tellPages({ type: "keepalive" })
5393
6214
  });
5394
6215
  const onMessage = (event) => {
@@ -5400,6 +6221,7 @@ function serveHttpInWorker(handler, options = {}) {
5400
6221
  control.onmessage = (message) => {
5401
6222
  const msg = message.data;
5402
6223
  if (msg?.type === "link") {
6224
+ if (msg.seq !== void 0) seen.set(control, msg.seq);
5403
6225
  const [down, ctl, up] = message.ports;
5404
6226
  if (down) endpoint.link(msg.key, {
5405
6227
  down,
@@ -5440,6 +6262,7 @@ function serveHttpInWorker(handler, options = {}) {
5440
6262
  await endpoint.close();
5441
6263
  for (const control of controls) control.close();
5442
6264
  controls.clear();
6265
+ seen.clear();
5443
6266
  (await held)();
5444
6267
  }
5445
6268
  };