@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-worker.js CHANGED
@@ -74,8 +74,6 @@
74
74
  const payload = typeof error === "string" ? { message: error } : error;
75
75
  return Object.assign(new Error(payload.message), payload);
76
76
  }
77
- new TextEncoder();
78
- new TextDecoder();
79
77
  /**
80
78
  * The newAsyncGenerator function creates async generators from callback-based initialization
81
79
  * functions, providing a bridge between imperative event handling and declarative async
@@ -228,7 +226,6 @@
228
226
  drainQueue();
229
227
  }
230
228
  }
231
- new TextEncoder();
232
229
  /**
233
230
  * The iterator ↔ `ReadableStream` boundary.
234
231
  *
@@ -574,8 +571,8 @@
574
571
  const NEWLINE = 10;
575
572
  /** Mirrors the HTTP/1.1 codec's default `maxHeaderBytes`. */
576
573
  const MAX_ENVELOPE_BYTES = 65536;
577
- const encoder$1 = new TextEncoder();
578
- const decoder = new TextDecoder();
574
+ const encoder$1 = /* @__PURE__ */ new TextEncoder();
575
+ const decoder = /* @__PURE__ */ new TextDecoder();
579
576
  /**
580
577
  * Encode an HTTP envelope plus optional body as one continuous byte stream:
581
578
  *
@@ -829,15 +826,15 @@
829
826
  };
830
827
  return { kind: "none" };
831
828
  }
832
- const CRLF = new Uint8Array([13, 10]);
833
- const LAST_CHUNK = new Uint8Array([
829
+ const CRLF = /* @__PURE__ */ new Uint8Array([13, 10]);
830
+ const LAST_CHUNK = /* @__PURE__ */ new Uint8Array([
834
831
  48,
835
832
  13,
836
833
  10,
837
834
  13,
838
835
  10
839
836
  ]);
840
- const encoder = new TextEncoder();
837
+ const encoder = /* @__PURE__ */ new TextEncoder();
841
838
  /**
842
839
  * Chunk sizes are written in HEXADECIMAL — a 21-byte chunk is `15`. Writing
843
840
  * them in decimal is the defect note 15 found in @libp2p/http, and it is the
@@ -1142,7 +1139,7 @@
1142
1139
  decodeResponse: (input, o) => decodeResponse(input, opts, o.method)
1143
1140
  };
1144
1141
  }
1145
- const httpCodec = newHttpCodec();
1142
+ const httpCodec = /* @__PURE__ */ newHttpCodec();
1146
1143
  /**
1147
1144
  * Dispatches on byte 0. The formats are self-identifying — a JSON envelope
1148
1145
  * always begins `{`, which is not a token character and so can never begin an
@@ -1192,10 +1189,29 @@
1192
1189
  * Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
1193
1190
  * no `codec` option is supplied.
1194
1191
  */
1195
- const defaultCodec = newSniffingCodec({
1192
+ const defaultCodec = /* @__PURE__ */ newSniffingCodec({
1196
1193
  write: httpCodec,
1197
1194
  accept: [httpCodec, jsonEnvelopeCodec]
1198
1195
  });
1196
+ /**
1197
+ * Makes `target` disposable with `await using`: installs `dispose` as its
1198
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
1199
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
1200
+ * this gives it back. A no-op where the symbol is missing. Kept off the
1201
+ * declared types, which would require consumers to compile with the
1202
+ * `esnext.disposable` lib.
1203
+ */
1204
+ function asyncDisposable$2(target, dispose) {
1205
+ const asyncDispose = Symbol.asyncDispose;
1206
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
1207
+ value: async () => {
1208
+ await dispose();
1209
+ },
1210
+ configurable: true,
1211
+ writable: true
1212
+ });
1213
+ return target;
1214
+ }
1199
1215
  /** Carries the serialized JS error between two webrun peers. */
1200
1216
  const PEER_ERROR_HEADER = "x-webrun-error";
1201
1217
  /** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
@@ -1273,41 +1289,105 @@
1273
1289
  */
1274
1290
  async function httpFetch(call, env, body, options = {}) {
1275
1291
  const codec = options.codec ?? defaultCodec;
1292
+ const { signal } = options;
1293
+ if (signal?.aborted) throw abortReason$1(signal);
1276
1294
  const output = call(codec.encodeRequest(env, body));
1277
- const decoded = await codec.decodeResponse(output, { method: env.method });
1278
- const release = async () => {
1279
- try {
1280
- await output.return?.(void 0);
1281
- } catch {}
1295
+ let released;
1296
+ const release = () => {
1297
+ released ??= (async () => {
1298
+ try {
1299
+ await output.return?.(void 0);
1300
+ } catch {}
1301
+ })();
1302
+ return released;
1303
+ };
1304
+ let finished = false;
1305
+ const finish = () => {
1306
+ if (finished || released) return;
1307
+ finished = true;
1308
+ if (signal?.aborted) {
1309
+ release();
1310
+ return;
1311
+ }
1312
+ const onAbort = () => void release();
1313
+ signal?.addEventListener("abort", onAbort, { once: true });
1314
+ drain(output).finally(() => signal?.removeEventListener("abort", onAbort));
1282
1315
  };
1316
+ let decoded;
1317
+ try {
1318
+ decoded = await untilAborted(codec.decodeResponse(output, { method: env.method }), signal);
1319
+ } catch (error) {
1320
+ if (signal?.aborted) release();
1321
+ else await release();
1322
+ throw error;
1323
+ }
1283
1324
  const result = {
1284
1325
  envelope: decoded.envelope,
1285
- body: releasingOnExit(decoded.body, release),
1286
- release
1326
+ body: releasingOnExit(decoded.body, finish, release),
1327
+ release,
1328
+ finish
1287
1329
  };
1288
1330
  await throwIfPeerError(result, output);
1289
1331
  return result;
1290
1332
  }
1333
+ /** `promise`, rejected with the signal's reason if it aborts first. */
1334
+ function untilAborted(promise, signal) {
1335
+ if (!signal) return promise;
1336
+ return new Promise((resolve, reject) => {
1337
+ const onAbort = () => reject(abortReason$1(signal));
1338
+ if (signal.aborted) return onAbort();
1339
+ signal.addEventListener("abort", onAbort, { once: true });
1340
+ promise.then((value) => {
1341
+ signal.removeEventListener("abort", onAbort);
1342
+ resolve(value);
1343
+ }, (error) => {
1344
+ signal.removeEventListener("abort", onAbort);
1345
+ reject(error);
1346
+ });
1347
+ });
1348
+ }
1349
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
1350
+ function abortReason$1(signal) {
1351
+ const reason = signal.reason;
1352
+ if (reason instanceof Error) return reason;
1353
+ const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1354
+ err.name = "AbortError";
1355
+ return err;
1356
+ }
1291
1357
  /**
1292
- * `body`, but leaving it before its end releases the call.
1358
+ * `body`, ending the call however it is left: read to its end, or left early.
1293
1359
  *
1294
1360
  * The body the codec hands back is a generator of its own over a `ByteReader`,
1295
- * and `.return()` on it never reaches the call's `output` (see
1296
- * `throwIfPeerError` for why the codec must not do that itself). So a caller
1297
- * that stopped reading — an aborted `fetch`, a cancelled response stream —
1298
- * left the peer's producer parked forever, holding its handler open. Safe here
1299
- * for the same reason it is safe in `throwIfPeerError`: `output` is this
1300
- * call's own response-only generator, and the request was fully sent.
1301
- */
1302
- async function* releasingOnExit(body, release) {
1361
+ * and its ending never reaches the call's `output` (see `throwIfPeerError` for
1362
+ * why the codec must not do that itself). A caller that stopped reading — an
1363
+ * aborted `fetch`, a cancelled response stream — left the peer's producer
1364
+ * parked forever; and one that read the body to its end left the call itself
1365
+ * open, which on a multiplexed transport is one port per exchange until the
1366
+ * multiplexer refuses to open more (`maxPorts`).
1367
+ *
1368
+ * Left early, the call is abandoned (`.return()`): the peer's producer is
1369
+ * released. Read to its end, the call is not cut short but read to ITS end, in
1370
+ * the background: the peer ends it once its side of the exchange is over,
1371
+ * which may be later than the response — a handler that answered before it
1372
+ * read the request body is still receiving it (`HttpDataHandlerResult.settled`),
1373
+ * and abandoning the call would abort that upload.
1374
+ */
1375
+ async function* releasingOnExit(body, finish, release) {
1303
1376
  let completed = false;
1304
1377
  try {
1305
1378
  yield* body;
1306
1379
  completed = true;
1307
1380
  } finally {
1308
- if (!completed) await release();
1381
+ if (completed) finish();
1382
+ else await release();
1309
1383
  }
1310
1384
  }
1385
+ /** Reads `output` to its end, discarding what is left, ignoring a failure. */
1386
+ async function drain(output) {
1387
+ try {
1388
+ for (let r = await output.next(); !r.done; r = await output.next());
1389
+ } catch {}
1390
+ }
1311
1391
  /**
1312
1392
  * Wrap an HTTP handler as a `Duplex` so it can be registered with any
1313
1393
  * `webrun-streams-*` adapter's `serve`.
@@ -1335,6 +1415,7 @@
1335
1415
  try {
1336
1416
  yield* replyCodec.encodeResponse(result.envelope, result.body, { method: decoded.envelope.method });
1337
1417
  completed = true;
1418
+ await result.settled?.();
1338
1419
  } finally {
1339
1420
  if (!completed) try {
1340
1421
  await result.release?.();
@@ -1558,17 +1639,21 @@
1558
1639
  const buffered = new Uint8Array(await request.arrayBuffer());
1559
1640
  if (buffered.byteLength > 0) body = [buffered];
1560
1641
  }
1561
- const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
1642
+ const { envelope, body: respBody, release, finish } = await httpFetch(call, env, body, {
1643
+ ...options,
1644
+ signal: request.signal ?? void 0
1645
+ });
1562
1646
  const responseInit = {
1563
1647
  status: envelope.status,
1564
1648
  statusText: envelope.statusText,
1565
1649
  headers: forwardableHeaders(envelope.headers)
1566
1650
  };
1567
1651
  if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
1568
- await release?.();
1652
+ if (finish) finish();
1653
+ else await release?.();
1569
1654
  return new Response(null, responseInit);
1570
1655
  }
1571
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
1656
+ return new Response(responseBody(respBody, request.signal, release), responseInit);
1572
1657
  }
1573
1658
  /**
1574
1659
  * Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
@@ -1580,13 +1665,21 @@
1580
1665
  method: env.method,
1581
1666
  headers: forwardableHeaders(env.headers)
1582
1667
  };
1668
+ let upload;
1583
1669
  if (env.method !== "GET" && env.method !== "HEAD") {
1584
1670
  if (supportsRequestStreams()) {
1585
- reqInit.body = asyncIterableToReadable(body);
1671
+ upload = trackingEnd(body);
1672
+ reqInit.body = asyncIterableToReadable(upload.source);
1586
1673
  reqInit.duplex = "half";
1587
1674
  } else reqInit.body = await collectBytes(body);
1588
1675
  }
1589
- const response = await handler(new Request(env.url, reqInit));
1676
+ const request = new Request(env.url, reqInit);
1677
+ const response = await handler(request);
1678
+ const settled = async () => {
1679
+ if (!upload) return;
1680
+ if (!request.bodyUsed && !request.body?.locked) request.body?.cancel().catch(() => {});
1681
+ await upload.settle(options.requestIdleMs ?? 3e4);
1682
+ };
1590
1683
  const respEnv = {
1591
1684
  status: response.status,
1592
1685
  statusText: response.statusText,
@@ -1596,32 +1689,173 @@
1596
1689
  await response.body?.cancel();
1597
1690
  return {
1598
1691
  envelope: respEnv,
1599
- body: void 0
1692
+ body: void 0,
1693
+ settled
1600
1694
  };
1601
1695
  }
1602
1696
  return {
1603
1697
  envelope: respEnv,
1604
1698
  body: response.body ? readableToAsyncIterable(response.body) : void 0,
1605
- release: () => response.body?.cancel()
1699
+ release: () => response.body?.cancel(),
1700
+ settled
1606
1701
  };
1607
1702
  }, options);
1608
1703
  }
1609
- async function* withAbort(iter, signal) {
1610
- if (!signal) {
1611
- yield* iter;
1612
- return;
1613
- }
1614
- for await (const chunk of iter) {
1615
- if (signal.aborted) throw abortReason$1(signal);
1616
- yield chunk;
1617
- }
1704
+ /**
1705
+ * `source`, and `settle(idleMs)`: resolves once it is done — read to its end,
1706
+ * failed, or abandoned (`return()` included before the first `next()`, which a
1707
+ * generator's `finally` would not see) — or once nobody has asked it for a
1708
+ * chunk for `idleMs`, in which case it is abandoned: later reads fail.
1709
+ *
1710
+ * Idle counts only while no `next()` is pending: a reader waiting for a slow
1711
+ * sender is still reading.
1712
+ */
1713
+ function trackingEnd(source) {
1714
+ let end = () => {};
1715
+ let isEnded = false;
1716
+ const ended = new Promise((resolve) => {
1717
+ end = () => {
1718
+ isEnded = true;
1719
+ resolve();
1720
+ };
1721
+ });
1722
+ let abandoned;
1723
+ let pending = 0;
1724
+ let idleSince = Date.now();
1725
+ const it = source[Symbol.asyncIterator]();
1726
+ const abandon = (reason) => {
1727
+ abandoned ??= reason;
1728
+ end();
1729
+ Promise.resolve(it.return?.(void 0)).catch(() => {});
1730
+ };
1731
+ const tracked = {
1732
+ [Symbol.asyncIterator]() {
1733
+ return tracked;
1734
+ },
1735
+ async next() {
1736
+ if (abandoned) throw abandoned;
1737
+ pending++;
1738
+ try {
1739
+ const r = await it.next();
1740
+ if (r.done) end();
1741
+ return r;
1742
+ } catch (error) {
1743
+ end();
1744
+ throw error;
1745
+ } finally {
1746
+ if (--pending === 0) idleSince = Date.now();
1747
+ }
1748
+ },
1749
+ async return(value) {
1750
+ end();
1751
+ try {
1752
+ await it.return?.(value);
1753
+ } catch {}
1754
+ return {
1755
+ done: true,
1756
+ value: void 0
1757
+ };
1758
+ }
1759
+ };
1760
+ asyncDisposable$2(tracked, () => tracked.return?.() ?? Promise.resolve());
1761
+ const settle = (idleMs) => {
1762
+ if (isEnded || !Number.isFinite(idleMs)) return ended;
1763
+ return new Promise((resolve) => {
1764
+ let timer;
1765
+ const check = () => {
1766
+ if (isEnded) return resolve();
1767
+ const idle = pending === 0 ? Date.now() - idleSince : 0;
1768
+ if (idle >= idleMs) {
1769
+ abandon(/* @__PURE__ */ new Error(`webrun-http-streams: the request body was left unread for ${idleMs} ms after the response; abandoned`));
1770
+ return resolve();
1771
+ }
1772
+ timer = setTimeout(check, Math.max(1, Math.min(idleMs - idle, 1e3)));
1773
+ timer.unref?.();
1774
+ };
1775
+ ended.then(() => {
1776
+ clearTimeout(timer);
1777
+ resolve();
1778
+ });
1779
+ check();
1780
+ });
1781
+ };
1782
+ return {
1783
+ source: tracked,
1784
+ settle
1785
+ };
1618
1786
  }
1619
- function abortReason$1(signal) {
1620
- const reason = signal.reason;
1621
- if (reason instanceof Error) return reason;
1622
- const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1623
- err.name = "AbortError";
1624
- return err;
1787
+ /**
1788
+ * How long a cancelled body waits for the read in flight before it abandons the call anyway.
1789
+ * The read's answer tells a body that has ended (the call is finished: an upload the handler
1790
+ * reads after answering goes on) from one that has not; a peer that sends nothing more never
1791
+ * answers it. A body whose end comes later than this after the cancel is abandoned too, so a
1792
+ * handler still streaming its response and still reading the upload then loses the upload.
1793
+ */
1794
+ const CANCEL_GRACE_MS = 1e3;
1795
+ /**
1796
+ * The response body as a stream that the request's signal can end at any time.
1797
+ *
1798
+ * An abort errors the stream with the abort's reason at once — a read waiting
1799
+ * for a peer that sends nothing more rejects then, not when a next chunk
1800
+ * arrives, which may be never — and abandons the call (`release`), so on a
1801
+ * multiplexed transport its port is given back whether or not anyone still
1802
+ * reads the body. The listener is removed once the body ends, however it ends:
1803
+ * read to its end, cancelled, failed or aborted. A body nobody reads or cancels
1804
+ * keeps it until the signal aborts (and keeps the call open until then anyway).
1805
+ * A cancel abandons the call too — unless the read in flight comes back with the
1806
+ * body's end, which finishes it instead (see `cancel`).
1807
+ */
1808
+ function responseBody(body, signal, release) {
1809
+ const it = body[Symbol.asyncIterator]();
1810
+ let controller;
1811
+ let ended = false;
1812
+ let reading = false;
1813
+ const end = () => {
1814
+ ended = true;
1815
+ signal?.removeEventListener("abort", onAbort);
1816
+ };
1817
+ const onAbort = () => {
1818
+ if (ended) return;
1819
+ end();
1820
+ try {
1821
+ controller.error(abortReason$1(signal));
1822
+ } catch {}
1823
+ release?.();
1824
+ };
1825
+ return new ReadableStream({
1826
+ start(c) {
1827
+ controller = c;
1828
+ if (signal?.aborted) onAbort();
1829
+ else signal?.addEventListener("abort", onAbort, { once: true });
1830
+ },
1831
+ async pull(c) {
1832
+ try {
1833
+ reading = true;
1834
+ const { done, value } = await it.next().finally(() => {
1835
+ reading = false;
1836
+ });
1837
+ if (ended) return;
1838
+ if (done) {
1839
+ end();
1840
+ c.close();
1841
+ return;
1842
+ }
1843
+ c.enqueue(value);
1844
+ } catch (err) {
1845
+ if (ended) return;
1846
+ end();
1847
+ c.error(err);
1848
+ }
1849
+ },
1850
+ cancel() {
1851
+ end();
1852
+ const returned = Promise.resolve(it.return?.()).catch(() => {});
1853
+ if (!reading) return;
1854
+ const grace = setTimeout(() => void release?.(), CANCEL_GRACE_MS);
1855
+ grace.unref?.();
1856
+ returned.finally(() => clearTimeout(grace));
1857
+ }
1858
+ });
1625
1859
  }
1626
1860
  //#endregion
1627
1861
  //#region src/core/data-channels.ts
@@ -1949,8 +2183,11 @@
1949
2183
  error
1950
2184
  });
1951
2185
  };
1952
- port.addEventListener("message", onMessage);
1953
- return () => port.removeEventListener("message", onMessage);
2186
+ const listener = (event) => {
2187
+ onMessage(event);
2188
+ };
2189
+ port.addEventListener("message", listener);
2190
+ return () => port.removeEventListener("message", listener);
1954
2191
  }
1955
2192
  /**
1956
2193
  * Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
@@ -1980,6 +2217,69 @@
1980
2217
  }
1981
2218
  }
1982
2219
  /**
2220
+ * Makes `target` disposable with `await using`: installs `dispose` as its
2221
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
2222
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
2223
+ * this gives it back. A no-op where the symbol is missing. Kept off the
2224
+ * declared types, which would require consumers to compile with the
2225
+ * `esnext.disposable` lib.
2226
+ */
2227
+ function asyncDisposable$1(target, dispose) {
2228
+ const asyncDispose = Symbol.asyncDispose;
2229
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
2230
+ value: async () => {
2231
+ await dispose();
2232
+ },
2233
+ configurable: true,
2234
+ writable: true
2235
+ });
2236
+ return target;
2237
+ }
2238
+ /**
2239
+ * `gen`, except that `return()` and `throw()` take effect at once.
2240
+ *
2241
+ * On an async generator both are queued behind a `next()` that is still
2242
+ * pending: a consumer that gives up on a stream while it waits for the peer's
2243
+ * next chunk — an aborted fetch whose response head has not come, say — cannot
2244
+ * end it until that chunk arrives, which for a peer that never answers is
2245
+ * never. The stream's `finally` (which closes its port and tells the peer)
2246
+ * then never runs, and on a multiplexer the port is kept for good.
2247
+ *
2248
+ * So `abandon` runs first, synchronously: it must make the pending `next()`
2249
+ * settle (abort the stream), which then resolves done, after which the
2250
+ * generator's own `return()` / `throw()` proceeds as usual. On a generator
2251
+ * suspended at a `yield` it is the same ending, a step earlier. `abandon` runs once, whichever comes first.
2252
+ */
2253
+ function returningAtOnce(gen, abandon) {
2254
+ let abandoned = false;
2255
+ const giveUp = (reason) => {
2256
+ if (abandoned) return;
2257
+ abandoned = true;
2258
+ abandon(reason);
2259
+ };
2260
+ const wrapped = {
2261
+ next: (...args) => gen.next(...args).catch((error) => {
2262
+ if (abandoned) return {
2263
+ done: true,
2264
+ value: void 0
2265
+ };
2266
+ throw error;
2267
+ }),
2268
+ return(value) {
2269
+ giveUp();
2270
+ return gen.return(value);
2271
+ },
2272
+ throw(error) {
2273
+ giveUp(error);
2274
+ return gen.throw(error);
2275
+ },
2276
+ [Symbol.asyncIterator]() {
2277
+ return wrapped;
2278
+ }
2279
+ };
2280
+ return asyncDisposable$1(wrapped, () => wrapped.return(void 0));
2281
+ }
2282
+ /**
1983
2283
  * The `type` of the out-of-band notice a side posts when it abandons a stream.
1984
2284
  *
1985
2285
  * Layer 1's `close` is not observable to layer 2 — a closed virtual port drops
@@ -2075,7 +2375,7 @@
2075
2375
  const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
2076
2376
  const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
2077
2377
  pump.catch(() => {});
2078
- return (async function* () {
2378
+ return returningAtOnce((async function* () {
2079
2379
  try {
2080
2380
  yield* inbound.stream;
2081
2381
  } finally {
@@ -2089,7 +2389,9 @@
2089
2389
  await port.close?.();
2090
2390
  } catch {}
2091
2391
  }
2092
- })();
2392
+ })(), () => {
2393
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
2394
+ });
2093
2395
  }
2094
2396
  /**
2095
2397
  * Listens for the peer's abort notice, and can post our own. The notice is a
@@ -2290,6 +2592,33 @@
2290
2592
  }
2291
2593
  }
2292
2594
  /**
2595
+ * Runs `fn` on a later task, never a microtask: a producer looping on
2596
+ * `whenPortReady` must let the transport's own events run. `setImmediate`
2597
+ * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
2598
+ * are clamped to 4 ms in browsers.
2599
+ */
2600
+ const setImmediateFn = globalThis.setImmediate;
2601
+ /** @internal */
2602
+ const nextTask = setImmediateFn ? (fn) => {
2603
+ setImmediateFn(fn);
2604
+ } : (() => {
2605
+ let channel;
2606
+ const queue = [];
2607
+ return (fn) => {
2608
+ if (!channel) {
2609
+ channel = new MessageChannel();
2610
+ channel.port1.onmessage = () => queue.shift()?.();
2611
+ }
2612
+ queue.push(fn);
2613
+ channel.port2.postMessage(0);
2614
+ };
2615
+ })();
2616
+ const states = /* @__PURE__ */ new WeakMap();
2617
+ /** @internal Registered by the multiplexer for each port with a window. */
2618
+ function setPortSendState(port, state) {
2619
+ states.set(port, state);
2620
+ }
2621
+ /**
2293
2622
  * One virtual port.
2294
2623
  *
2295
2624
  * `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
@@ -2304,6 +2633,14 @@
2304
2633
  if (closing.signal.aborted) return;
2305
2634
  closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
2306
2635
  };
2636
+ const close = (reason) => {
2637
+ if (closed) return;
2638
+ closed = true;
2639
+ const notify = requestClose;
2640
+ listeners.clear();
2641
+ notify(reason);
2642
+ abort(reason);
2643
+ };
2307
2644
  const port = {
2308
2645
  addEventListener(_type, listener) {
2309
2646
  listeners.add(listener);
@@ -2315,24 +2652,28 @@
2315
2652
  if (closed) return;
2316
2653
  send(message, transfer);
2317
2654
  },
2318
- close() {
2319
- if (closed) return;
2320
- closed = true;
2321
- const notify = requestClose;
2322
- listeners.clear();
2323
- notify();
2324
- abort();
2325
- }
2655
+ close
2326
2656
  };
2327
2657
  setPortCloseSignal(port, closing.signal);
2658
+ const dispose = Symbol.dispose;
2659
+ if (dispose) Object.defineProperty(port, dispose, {
2660
+ value: () => close(),
2661
+ configurable: true
2662
+ });
2328
2663
  return {
2329
2664
  port,
2330
2665
  deliver(payload) {
2331
2666
  if (closed) return;
2332
2667
  const event = new MessageEvent("message", { data: payload });
2668
+ let pending;
2333
2669
  for (const listener of [...listeners]) try {
2334
- listener(event);
2670
+ const result = listener(event);
2671
+ if (result && typeof result.then === "function") {
2672
+ pending ??= [];
2673
+ pending.push(result);
2674
+ }
2335
2675
  } catch {}
2676
+ return pending ? Promise.allSettled(pending) : void 0;
2336
2677
  },
2337
2678
  markClosed(reason) {
2338
2679
  closed = true;
@@ -2341,53 +2682,309 @@
2341
2682
  },
2342
2683
  isClosed() {
2343
2684
  return closed;
2685
+ },
2686
+ hasListeners() {
2687
+ return listeners.size > 0;
2344
2688
  }
2345
2689
  };
2346
2690
  }
2347
2691
  /**
2348
- * The message of the error that fails the calls still in flight on a
2349
- * multiplexer when it is closed (`PortMux.close()`).
2692
+ * The id of keepalive's `ping` / `pong`: one no port ever takes. An older mux
2693
+ * whose codec passes unknown envelopes through reads them as a close of that
2694
+ * id, which must therefore not be a real port (0 is the initiator's first).
2350
2695
  */
2351
- const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
2696
+ const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
2697
+ /** The longest delay a timer holds; past it `setInterval` fires at once. */
2698
+ const MAX_TIMER_MS = 2 ** 31 - 1;
2352
2699
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
2353
2700
  const DEFAULT_MAX_PORTS = 1024;
2354
2701
  /**
2702
+ * What a message costs a port's window when the codec cannot say
2703
+ * (`PortCodec.encode`): its bytes and string lengths, found in its values up
2704
+ * to 4 levels deep (at most 256 values), and at least 64. Both ends compute it
2705
+ * from the same structured value, so they agree.
2706
+ */
2707
+ function estimateMessageSize(payload) {
2708
+ let budget = 256;
2709
+ const walk = (v, depth) => {
2710
+ if (budget-- <= 0) return 0;
2711
+ if (typeof v === "string") return v.length;
2712
+ if (v instanceof ArrayBuffer) return v.byteLength;
2713
+ if (ArrayBuffer.isView(v)) return v.byteLength;
2714
+ if (v === null || typeof v !== "object" || depth >= 4) return 8;
2715
+ let n = 16;
2716
+ if (Array.isArray(v)) for (const x of v) n += walk(x, depth + 1);
2717
+ else for (const k in v) n += k.length + walk(v[k], depth + 1);
2718
+ return n;
2719
+ };
2720
+ return Math.max(64, walk(payload, 0));
2721
+ }
2722
+ /**
2355
2723
  * The default `PortMux`: emulates multiplexing over a single port.
2356
2724
  *
2357
2725
  * A transport that already multiplexes natively supplies its own `PortMux`
2358
2726
  * instead — this implementation is for transports that offer one pipe.
2359
2727
  */
2360
2728
  function multiplexPort(port, options) {
2361
- const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
2729
+ const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
2730
+ const misses = keepalive?.misses ?? 3;
2731
+ 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`);
2732
+ if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
2362
2733
  const open = /* @__PURE__ */ new Map();
2734
+ const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
2735
+ const entry = open.get(id);
2736
+ if (entry?.handle !== record) return;
2737
+ const { handle: _, credit: __, partial: ___, ...info } = entry;
2738
+ if (!record.hasListeners()) {
2739
+ release("leaked");
2740
+ record.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was dropped without close()"));
2741
+ }
2742
+ try {
2743
+ onLeak?.(info);
2744
+ } catch {}
2745
+ }) : void 0;
2746
+ const touch = (id) => {
2747
+ const entry = open.get(id);
2748
+ if (entry) entry.lastActivity = Date.now();
2749
+ };
2363
2750
  let nextId = side === "initiator" ? 0 : 1;
2364
2751
  let muxClosed = false;
2752
+ /** This end counts encoded bytes and reassembles frames. */
2753
+ const exactCodec = codec.encode != null && codec.decode != null;
2365
2754
  const post = (envelope, transfer) => {
2366
2755
  if (muxClosed) return;
2756
+ codecPost(envelope, transfer);
2757
+ };
2758
+ /** Like `post`, also while closing: the mux's own close notices. */
2759
+ const codecPost = (envelope, transfer) => {
2367
2760
  try {
2368
2761
  codec.post(port, envelope, transfer);
2369
2762
  } catch {}
2370
2763
  };
2371
- const attach = (id) => {
2372
- let self;
2373
- self = newVirtualPort((payload, transfer) => post({
2374
- type: "message",
2375
- id,
2376
- payload
2377
- }, transfer), (reason) => {
2764
+ const outgoing = /* @__PURE__ */ new Map();
2765
+ const ring = [];
2766
+ let pumping = false;
2767
+ const pump = async () => {
2768
+ pumping = true;
2769
+ try {
2770
+ while (ring.length && !muxClosed) {
2771
+ if (port.whenDrained) await port.whenDrained();
2772
+ if (muxClosed) break;
2773
+ const id = ring.shift();
2774
+ const queue = outgoing.get(id);
2775
+ const item = queue?.shift();
2776
+ if (queue?.length) ring.push(id);
2777
+ else outgoing.delete(id);
2778
+ item?.();
2779
+ }
2780
+ } catch (err) {
2781
+ close(err).catch(() => {});
2782
+ } finally {
2783
+ pumping = false;
2784
+ }
2785
+ };
2786
+ const sendOnPort = (id, item) => {
2787
+ if (frameSize == null) {
2788
+ item();
2789
+ return;
2790
+ }
2791
+ let queue = outgoing.get(id);
2792
+ if (!queue) {
2793
+ queue = [];
2794
+ outgoing.set(id, queue);
2795
+ ring.push(id);
2796
+ }
2797
+ queue.push(item);
2798
+ if (!pumping) pump();
2799
+ };
2800
+ const dropOutgoing = (id) => {
2801
+ if (!outgoing.delete(id)) return;
2802
+ const at = ring.indexOf(id);
2803
+ if (at >= 0) ring.splice(at, 1);
2804
+ };
2805
+ const postBytes = (bytes) => {
2806
+ if (muxClosed) return;
2807
+ try {
2808
+ port.postMessage(bytes);
2809
+ } catch {}
2810
+ };
2811
+ const releaseWaiters = (c) => {
2812
+ for (const w of c?.waiters.splice(0) ?? []) w.resolve();
2813
+ };
2814
+ const flush = (c) => {
2815
+ while (c.queue.length) {
2816
+ const next = c.queue[0];
2817
+ if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
2818
+ c.queue.shift();
2819
+ c.queued -= next.size;
2820
+ c.outstanding += next.size;
2821
+ next.send();
2822
+ }
2823
+ for (const w of [...c.waiters]) if (c.queued <= w.below) {
2824
+ c.waiters.splice(c.waiters.indexOf(w), 1);
2825
+ w.resolve();
2826
+ }
2827
+ };
2828
+ const grant = (id, c, bytes) => {
2829
+ c.unconsumed -= bytes;
2830
+ c.toGrant += bytes;
2831
+ const send = () => {
2832
+ c.grantScheduled = false;
2833
+ if (!c.toGrant || !open.has(id)) return;
2378
2834
  post({
2835
+ type: "credit",
2836
+ id,
2837
+ bytes: c.toGrant
2838
+ });
2839
+ c.toGrant = 0;
2840
+ };
2841
+ if (c.toGrant >= c.window / 4) {
2842
+ send();
2843
+ return;
2844
+ }
2845
+ if (c.grantScheduled) return;
2846
+ c.grantScheduled = true;
2847
+ setTimeout(send, 0);
2848
+ };
2849
+ const attach = (id, meta, portWindow, opener, exact) => {
2850
+ const c = typeof portWindow === "number" && Number.isSafeInteger(portWindow) && portWindow > 0 ? {
2851
+ window: portWindow,
2852
+ enforced: false,
2853
+ opener,
2854
+ exact,
2855
+ outstanding: 0,
2856
+ queue: [],
2857
+ queued: 0,
2858
+ unconsumed: 0,
2859
+ waiters: [],
2860
+ toGrant: 0,
2861
+ grantScheduled: false
2862
+ } : void 0;
2863
+ const self = newVirtualPort((payload, transfer) => {
2864
+ touch(id);
2865
+ const envelope = {
2866
+ type: "message",
2867
+ id,
2868
+ payload
2869
+ };
2870
+ if (!c) {
2871
+ post(envelope, transfer);
2872
+ return;
2873
+ }
2874
+ const bytes = c.exact ? codec.encode?.(envelope) : void 0;
2875
+ const size = bytes?.byteLength ?? estimateMessageSize(payload);
2876
+ const waits = frameSize != null || c.queue.length > 0 || c.enforced && c.outstanding > 0 && c.outstanding + size > c.window;
2877
+ const held = !bytes && waits ? {
2878
+ ...envelope,
2879
+ payload: structuredClone(payload, { transfer })
2880
+ } : void 0;
2881
+ const send = () => {
2882
+ if (!bytes) {
2883
+ sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
2884
+ return;
2885
+ }
2886
+ if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2887
+ for (let at = 0; at < bytes.byteLength; at += frameSize) {
2888
+ const data = bytes.subarray(at, at + frameSize);
2889
+ const frame = at + frameSize >= bytes.byteLength ? {
2890
+ type: "frame",
2891
+ id,
2892
+ data,
2893
+ last: true
2894
+ } : {
2895
+ type: "frame",
2896
+ id,
2897
+ data
2898
+ };
2899
+ sendOnPort(id, () => post(frame));
2900
+ }
2901
+ return;
2902
+ }
2903
+ sendOnPort(id, () => postBytes(bytes));
2904
+ };
2905
+ c.queue.push({
2906
+ send,
2907
+ size
2908
+ });
2909
+ c.queued += size;
2910
+ flush(c);
2911
+ }, (reason) => {
2912
+ release(reason);
2913
+ record.markClosed(reason);
2914
+ });
2915
+ function release(reason) {
2916
+ open.delete(id);
2917
+ leaks?.unregister(record);
2918
+ if (c?.queue.length) {
2919
+ for (const item of c.queue.splice(0)) item.send();
2920
+ c.queued = 0;
2921
+ }
2922
+ sendOnPort(id, () => post({
2379
2923
  type: "close",
2380
2924
  id,
2381
2925
  reason
2926
+ }));
2927
+ releaseWaiters(c);
2928
+ }
2929
+ const { markClosed, isClosed, hasListeners } = self;
2930
+ if (c) {
2931
+ const deliver = self.deliver;
2932
+ self.deliver = (payload, size = estimateMessageSize(payload)) => {
2933
+ c.unconsumed += size;
2934
+ const done = deliver(payload);
2935
+ if (done) done.then(() => grant(id, c, size));
2936
+ else grant(id, c, size);
2937
+ return done;
2938
+ };
2939
+ setPortSendState(self.port, {
2940
+ bufferedAmount: () => c.queued,
2941
+ ready: (below = 0) => new Promise((resolve) => {
2942
+ if (c.queued <= below || isClosed()) nextTask(resolve);
2943
+ else c.waiters.push({
2944
+ below,
2945
+ resolve: () => nextTask(resolve)
2946
+ });
2947
+ })
2382
2948
  });
2383
- open.delete(id);
2384
- self.markClosed();
2949
+ }
2950
+ const record = {
2951
+ deliver: self.deliver,
2952
+ markClosed,
2953
+ isClosed,
2954
+ hasListeners,
2955
+ close: (reason) => {
2956
+ if (isClosed()) return;
2957
+ release(reason);
2958
+ markClosed(reason);
2959
+ }
2960
+ };
2961
+ leaks?.register(self.port, {
2962
+ id,
2963
+ record,
2964
+ release
2965
+ }, record);
2966
+ const now = Date.now();
2967
+ open.set(id, {
2968
+ id,
2969
+ meta,
2970
+ openedAt: now,
2971
+ lastActivity: now,
2972
+ handle: record,
2973
+ credit: c
2385
2974
  });
2386
- open.set(id, self);
2387
2975
  return self;
2388
2976
  };
2389
- const handleEnvelope = (envelope) => {
2977
+ const handleEnvelope = (envelope, event) => {
2390
2978
  if (muxClosed) return;
2979
+ heardSincePing = true;
2980
+ if (envelope.type === "ping" || envelope.type === "pong") {
2981
+ peerKeepsAlive = true;
2982
+ if (envelope.type === "ping") post({
2983
+ type: "pong",
2984
+ id: KEEPALIVE_ID
2985
+ });
2986
+ return;
2987
+ }
2391
2988
  if (envelope.type === "open") {
2392
2989
  if (open.has(envelope.id)) return;
2393
2990
  if (open.size >= maxPorts) {
@@ -2398,7 +2995,18 @@
2398
2995
  });
2399
2996
  return;
2400
2997
  }
2401
- const handle = attach(envelope.id);
2998
+ const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
2999
+ const credit = open.get(envelope.id)?.credit;
3000
+ if (credit) post(credit.exact ? {
3001
+ type: "credit",
3002
+ id: envelope.id,
3003
+ bytes: 0,
3004
+ exact: true
3005
+ } : {
3006
+ type: "credit",
3007
+ id: envelope.id,
3008
+ bytes: 0
3009
+ });
2402
3010
  let accepted = false;
2403
3011
  if (onPort) try {
2404
3012
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -2413,57 +3021,191 @@
2413
3021
  id: envelope.id,
2414
3022
  reason: "rejected"
2415
3023
  });
3024
+ return;
3025
+ }
3026
+ return;
3027
+ }
3028
+ const entry = open.get(envelope.id);
3029
+ if (envelope.type === "credit") {
3030
+ const c = entry?.credit;
3031
+ if (!c || !(envelope.bytes >= 0)) return;
3032
+ if (!c.enforced && c.opener) {
3033
+ if (c.exact && envelope.exact !== true) {
3034
+ c.exact = false;
3035
+ c.outstanding = 0;
3036
+ }
3037
+ post({
3038
+ type: "credit",
3039
+ id: envelope.id,
3040
+ bytes: 0
3041
+ });
2416
3042
  }
3043
+ c.enforced = true;
3044
+ c.outstanding = Math.max(0, c.outstanding - envelope.bytes);
3045
+ flush(c);
2417
3046
  return;
2418
3047
  }
2419
- const handle = open.get(envelope.id);
2420
3048
  if (envelope.type === "message") {
2421
- if (!handle) return;
2422
- handle.deliver(envelope.payload);
3049
+ const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
3050
+ if (!entry) return;
3051
+ touch(envelope.id);
3052
+ entry.handle.deliver(envelope.payload, entry.credit ? sizeOf(entry.credit) : void 0);
2423
3053
  return;
2424
3054
  }
2425
- if (!handle) return;
3055
+ if (envelope.type === "frame") {
3056
+ if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
3057
+ touch(envelope.id);
3058
+ const partial = entry.partial ?? {
3059
+ chunks: [],
3060
+ bytes: 0
3061
+ };
3062
+ entry.partial = partial;
3063
+ partial.chunks.push(envelope.data);
3064
+ partial.bytes += envelope.data.byteLength;
3065
+ if (!envelope.last) return;
3066
+ entry.partial = void 0;
3067
+ const whole = new Uint8Array(partial.bytes);
3068
+ let at = 0;
3069
+ for (const chunk of partial.chunks) {
3070
+ whole.set(chunk, at);
3071
+ at += chunk.byteLength;
3072
+ }
3073
+ const inner = codec.decode(whole);
3074
+ if (inner?.type !== "message" || inner.id !== envelope.id) return;
3075
+ entry.handle.deliver(inner.payload, whole.byteLength);
3076
+ return;
3077
+ }
3078
+ dropOutgoing(envelope.id);
3079
+ if (!entry) return;
2426
3080
  open.delete(envelope.id);
2427
- handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
3081
+ releaseWaiters(entry.credit);
3082
+ entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
2428
3083
  };
2429
3084
  const listener = (event) => {
2430
3085
  const envelope = codec.read(event);
2431
- if (envelope) handleEnvelope(envelope);
3086
+ if (envelope) handleEnvelope(envelope, event);
3087
+ };
3088
+ let peerKeepsAlive = false;
3089
+ let heardSincePing = true;
3090
+ let unanswered = 0;
3091
+ let pinger;
3092
+ const tick = () => {
3093
+ if (!keepalive) return;
3094
+ const backlogged = (port.bufferedAmount ?? 0) > 0;
3095
+ if (heardSincePing) unanswered = 0;
3096
+ else if (peerKeepsAlive && !backlogged) unanswered++;
3097
+ if (unanswered >= misses) {
3098
+ close(/* @__PURE__ */ new Error(`webrun-rpc: the peer did not answer ${misses} keepalive pings, ${keepalive.intervalMs} ms apart`)).catch(() => {});
3099
+ return;
3100
+ }
3101
+ heardSincePing = false;
3102
+ post({
3103
+ type: "ping",
3104
+ id: KEEPALIVE_ID
3105
+ });
3106
+ };
3107
+ const close = async (reason) => {
3108
+ if (muxClosed) return;
3109
+ clearInterval(pinger);
3110
+ muxClosed = true;
3111
+ signal?.removeEventListener("abort", onTransportAbort);
3112
+ port.removeEventListener("message", listener);
3113
+ const closedNow = /* @__PURE__ */ new Set();
3114
+ for (const [id, { handle, credit }] of [...open]) {
3115
+ closedNow.add(id);
3116
+ codecPost({
3117
+ type: "close",
3118
+ id
3119
+ });
3120
+ open.delete(id);
3121
+ releaseWaiters(credit);
3122
+ handle.markClosed(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the multiplexer was closed"));
3123
+ }
3124
+ for (const id of outgoing.keys()) if (!closedNow.has(id)) codecPost({
3125
+ type: "close",
3126
+ id
3127
+ });
3128
+ outgoing.clear();
3129
+ ring.length = 0;
3130
+ await port.close?.();
3131
+ };
3132
+ const onTransportAbort = () => {
3133
+ close(signal?.reason).catch(() => {});
2432
3134
  };
2433
3135
  port.addEventListener("message", listener);
2434
3136
  port.start?.();
3137
+ if (keepalive) {
3138
+ pinger = setInterval(tick, keepalive.intervalMs);
3139
+ pinger.unref?.();
3140
+ }
3141
+ if (signal?.aborted) close(signal.reason).catch(() => {});
3142
+ else signal?.addEventListener("abort", onTransportAbort, { once: true });
2435
3143
  return {
2436
3144
  maxMessageSize,
2437
- async openPort(meta) {
3145
+ async openPort(meta, { signal: portSignal } = {}) {
2438
3146
  if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
2439
- if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
3147
+ if (portSignal?.aborted) throw portSignal.reason;
3148
+ if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached; open ports by kind: ${describeKinds(open.values())}`);
2440
3149
  while (open.has(nextId)) nextId += 2;
2441
3150
  const id = nextId;
2442
3151
  nextId += 2;
2443
- const handle = attach(id);
2444
- post({
3152
+ const handle = attach(id, meta, window, true, exactCodec);
3153
+ post(window ? exactCodec ? {
3154
+ type: "open",
3155
+ id,
3156
+ meta,
3157
+ window,
3158
+ exact: true
3159
+ } : {
3160
+ type: "open",
3161
+ id,
3162
+ meta,
3163
+ window
3164
+ } : {
2445
3165
  type: "open",
2446
3166
  id,
2447
3167
  meta
2448
3168
  });
3169
+ if (portSignal) {
3170
+ const record = open.get(id)?.handle;
3171
+ const onAbort = () => {
3172
+ if (record && open.get(id)?.handle === record) record.close(portSignal.reason);
3173
+ };
3174
+ portSignal.addEventListener("abort", onAbort, { once: true });
3175
+ getPortCloseSignal(handle.port)?.addEventListener("abort", () => portSignal.removeEventListener("abort", onAbort), { once: true });
3176
+ }
2449
3177
  return handle.port;
2450
3178
  },
2451
- async close() {
2452
- if (muxClosed) return;
2453
- for (const [id, handle] of [...open]) {
2454
- post({
2455
- type: "close",
2456
- id
2457
- });
2458
- open.delete(id);
2459
- handle.markClosed(/* @__PURE__ */ new Error(MUX_CLOSED_MESSAGE));
2460
- }
2461
- muxClosed = true;
2462
- port.removeEventListener("message", listener);
2463
- await port.close?.();
3179
+ close,
3180
+ ports() {
3181
+ return [...open.values()].map(({ handle: _, credit, ...info }) => credit ? {
3182
+ ...info,
3183
+ ...credit.enforced ? { window: credit.window } : {},
3184
+ queued: credit.queued,
3185
+ outstanding: credit.outstanding,
3186
+ unconsumed: credit.unconsumed
3187
+ } : info);
2464
3188
  }
2465
3189
  };
2466
3190
  }
3191
+ /** The byte length of a received message's data, or `undefined` if it is not bytes. */
3192
+ function byteLengthOf(data) {
3193
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return data.byteLength;
3194
+ }
3195
+ /**
3196
+ * `"http: 1000, ws: 24"`: the three commonest `meta.kind`s among `entries`, so
3197
+ * a full table names who filled it. A port without a kind counts as `"?"`.
3198
+ */
3199
+ function describeKinds(entries) {
3200
+ const counts = /* @__PURE__ */ new Map();
3201
+ for (const { meta } of entries) {
3202
+ const kind = meta?.kind;
3203
+ const name = typeof kind === "string" ? kind : "?";
3204
+ counts.set(name, (counts.get(name) ?? 0) + 1);
3205
+ }
3206
+ if (!counts.size) return "none";
3207
+ return [...counts].sort((x, y) => y[1] - x[1]).slice(0, 3).map(([name, n]) => `${name}: ${n}`).join(", ");
3208
+ }
2467
3209
  /**
2468
3210
  * Announced with every port this pair opens. Layer 1 never reads `meta` — it is
2469
3211
  * here so a peer running something else over the same multiplexer can tell a
@@ -2485,9 +3227,22 @@
2485
3227
  maxMessageSize: mux.maxMessageSize,
2486
3228
  timeout
2487
3229
  };
2488
- const call = (input) => (async function* () {
2489
- yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
2490
- })();
3230
+ const call = (input) => {
3231
+ let stream;
3232
+ let abandoned = false;
3233
+ return returningAtOnce((async function* () {
3234
+ const port = await mux.openPort(STREAM_META);
3235
+ if (abandoned) {
3236
+ await port.close?.();
3237
+ return;
3238
+ }
3239
+ stream = duplexOverPort(port, streamOptions)(input);
3240
+ yield* stream;
3241
+ })(), () => {
3242
+ abandoned = true;
3243
+ stream?.return(void 0).catch(() => {});
3244
+ });
3245
+ };
2491
3246
  return {
2492
3247
  call,
2493
3248
  async close() {
@@ -2557,7 +3312,7 @@
2557
3312
  const candidate = value;
2558
3313
  if (typeof candidate.id !== "number") return false;
2559
3314
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
2560
- return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
3315
+ return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
2561
3316
  }
2562
3317
  /**
2563
3318
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -2578,6 +3333,27 @@
2578
3333
  }
2579
3334
  };
2580
3335
  //#endregion
3336
+ //#region src/link/async-dispose.ts
3337
+ /**
3338
+ * Makes `target` disposable with `await using`: installs `dispose` as its
3339
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
3340
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
3341
+ * this gives it back. A no-op where the symbol is missing. Kept off the
3342
+ * declared types, which would require consumers to compile with the
3343
+ * `esnext.disposable` lib.
3344
+ */
3345
+ function asyncDisposable(target, dispose) {
3346
+ const asyncDispose = Symbol.asyncDispose;
3347
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
3348
+ value: async () => {
3349
+ await dispose();
3350
+ },
3351
+ configurable: true,
3352
+ writable: true
3353
+ });
3354
+ return target;
3355
+ }
3356
+ //#endregion
2581
3357
  //#region src/link/http-link.ts
2582
3358
  /**
2583
3359
  * The version of the HTTP-over-`MessagePort` protocol this package speaks,
@@ -2607,11 +3383,12 @@
2607
3383
  */
2608
3384
  const LINK_CLOSE = "webrun-http:link-close";
2609
3385
  /** Opens the calling end of a link on `port` (one port per link). */
2610
- async function connectHttpLink(port, { streamTimeout } = {}) {
3386
+ async function connectHttpLink(port, { streamTimeout, maxPorts } = {}) {
2611
3387
  const { call, close } = await connect({
2612
3388
  mux: overPipe(port, {
2613
3389
  codec: structuredCodec,
2614
- side: "initiator"
3390
+ side: "initiator",
3391
+ maxPorts
2615
3392
  }),
2616
3393
  timeout: streamTimeout
2617
3394
  });
@@ -2627,14 +3404,15 @@
2627
3404
  * that abandons the requests still running (their handlers see the
2628
3405
  * cancellation) and releases the port.
2629
3406
  */
2630
- async function serveHttpLink(port, handler, { streamTimeout } = {}) {
3407
+ async function serveHttpLink(port, handler, { streamTimeout, maxPorts, requestIdleMs } = {}) {
2631
3408
  const teardown = await serve({
2632
3409
  mux: overPipe(port, {
2633
3410
  codec: structuredCodec,
2634
- side: "responder"
3411
+ side: "responder",
3412
+ maxPorts
2635
3413
  }),
2636
3414
  timeout: streamTimeout
2637
- }, serveFetchOverDuplex(async (request) => await handler(request)));
3415
+ }, serveFetchOverDuplex(async (request) => await handler(request), { requestIdleMs }));
2638
3416
  const onClose = (event) => {
2639
3417
  if (event.data?.type === LINK_CLOSE) close();
2640
3418
  };
@@ -2659,7 +3437,7 @@
2659
3437
  inflight--;
2660
3438
  if (inflight === 0) for (const resolve of idleWaiters.splice(0)) resolve();
2661
3439
  };
2662
- return {
3440
+ const link = {
2663
3441
  get inflight() {
2664
3442
  return inflight;
2665
3443
  },
@@ -2696,7 +3474,7 @@
2696
3474
  finish();
2697
3475
  return response;
2698
3476
  }
2699
- return new Response(guardBody(response.body, lost, () => lostReason, finish), {
3477
+ return new Response(guardBody(response.body, lost, () => lostReason, finish, request.signal), {
2700
3478
  status: response.status,
2701
3479
  statusText: response.statusText,
2702
3480
  headers: response.headers
@@ -2712,35 +3490,68 @@
2712
3490
  } catch {}
2713
3491
  }
2714
3492
  };
3493
+ return asyncDisposable(link, () => link.close());
2715
3494
  }
2716
3495
  /**
2717
- * A response body that reports its end (done, cancelled or failed) and fails
2718
- * with the link's reason when the link is lost mid-stream.
3496
+ * A response body that reports its end (done, cancelled, failed or aborted) and
3497
+ * fails with the link's reason when the link is lost mid-stream. An abort of the
3498
+ * request's signal ends it at once, read or not: the exchange is over (the layer
3499
+ * below has given its port back), so the request leaves `inflight` then.
2719
3500
  */
2720
- function guardBody(body, lost, reason, finish) {
3501
+ function guardBody(body, lost, reason, finish, signal) {
2721
3502
  const reader = body.getReader();
3503
+ let controller;
3504
+ let ended = false;
3505
+ const end = () => {
3506
+ ended = true;
3507
+ signal?.removeEventListener("abort", onAbort);
3508
+ finish();
3509
+ };
3510
+ const onAbort = () => {
3511
+ if (ended) return;
3512
+ end();
3513
+ reader.cancel().catch(() => {});
3514
+ try {
3515
+ controller.error(abortError(signal));
3516
+ } catch {}
3517
+ };
2722
3518
  return new ReadableStream({
3519
+ start(c) {
3520
+ controller = c;
3521
+ if (signal?.aborted) onAbort();
3522
+ else signal?.addEventListener("abort", onAbort, { once: true });
3523
+ },
2723
3524
  async pull(controller) {
2724
3525
  try {
2725
3526
  const { done, value } = await Promise.race([reader.read(), lost]);
3527
+ if (ended) return;
2726
3528
  if (done) {
2727
- finish();
3529
+ end();
2728
3530
  controller.close();
2729
3531
  return;
2730
3532
  }
2731
3533
  controller.enqueue(value);
2732
3534
  } catch (error) {
2733
- finish();
3535
+ if (ended) return;
3536
+ end();
2734
3537
  reader.cancel().catch(() => {});
2735
3538
  controller.error(reason() ?? error);
2736
3539
  }
2737
3540
  },
2738
3541
  async cancel(why) {
2739
- finish();
3542
+ end();
2740
3543
  await reader.cancel(why).catch(() => {});
2741
3544
  }
2742
3545
  });
2743
3546
  }
3547
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
3548
+ function abortError(signal) {
3549
+ const why = signal.reason;
3550
+ if (why instanceof Error) return why;
3551
+ const err = new Error(why === void 0 ? "Aborted" : String(why));
3552
+ err.name = "AbortError";
3553
+ return err;
3554
+ }
2744
3555
  //#endregion
2745
3556
  //#region src/link/locks.ts
2746
3557
  function lockManager() {