@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.
@@ -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,7 +1061,7 @@ 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
  });
@@ -1118,41 +1115,105 @@ async function throwIfPeerError(result, output) {
1118
1115
  */
1119
1116
  async function httpFetch(call, env, body, options = {}) {
1120
1117
  const codec = options.codec ?? defaultCodec;
1118
+ const { signal } = options;
1119
+ if (signal?.aborted) throw abortReason$1(signal);
1121
1120
  const output = call(codec.encodeRequest(env, body));
1122
- const decoded = await codec.decodeResponse(output, { method: env.method });
1123
- const release = async () => {
1124
- try {
1125
- await output.return?.(void 0);
1126
- } catch {}
1121
+ let released;
1122
+ const release = () => {
1123
+ released ??= (async () => {
1124
+ try {
1125
+ await output.return?.(void 0);
1126
+ } catch {}
1127
+ })();
1128
+ return released;
1127
1129
  };
1130
+ let finished = false;
1131
+ const finish = () => {
1132
+ if (finished || released) return;
1133
+ finished = true;
1134
+ if (signal?.aborted) {
1135
+ release();
1136
+ return;
1137
+ }
1138
+ const onAbort = () => void release();
1139
+ signal?.addEventListener("abort", onAbort, { once: true });
1140
+ drain(output).finally(() => signal?.removeEventListener("abort", onAbort));
1141
+ };
1142
+ let decoded;
1143
+ try {
1144
+ decoded = await untilAborted(codec.decodeResponse(output, { method: env.method }), signal);
1145
+ } catch (error) {
1146
+ if (signal?.aborted) release();
1147
+ else await release();
1148
+ throw error;
1149
+ }
1128
1150
  const result = {
1129
1151
  envelope: decoded.envelope,
1130
- body: releasingOnExit(decoded.body, release),
1131
- release
1152
+ body: releasingOnExit(decoded.body, finish, release),
1153
+ release,
1154
+ finish
1132
1155
  };
1133
1156
  await throwIfPeerError(result, output);
1134
1157
  return result;
1135
1158
  }
1159
+ /** `promise`, rejected with the signal's reason if it aborts first. */
1160
+ function untilAborted(promise, signal) {
1161
+ if (!signal) return promise;
1162
+ return new Promise((resolve, reject) => {
1163
+ const onAbort = () => reject(abortReason$1(signal));
1164
+ if (signal.aborted) return onAbort();
1165
+ signal.addEventListener("abort", onAbort, { once: true });
1166
+ promise.then((value) => {
1167
+ signal.removeEventListener("abort", onAbort);
1168
+ resolve(value);
1169
+ }, (error) => {
1170
+ signal.removeEventListener("abort", onAbort);
1171
+ reject(error);
1172
+ });
1173
+ });
1174
+ }
1175
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
1176
+ function abortReason$1(signal) {
1177
+ const reason = signal.reason;
1178
+ if (reason instanceof Error) return reason;
1179
+ const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1180
+ err.name = "AbortError";
1181
+ return err;
1182
+ }
1136
1183
  /**
1137
- * `body`, but leaving it before its end releases the call.
1184
+ * `body`, ending the call however it is left: read to its end, or left early.
1138
1185
  *
1139
1186
  * The body the codec hands back is a generator of its own over a `ByteReader`,
1140
- * and `.return()` on it never reaches the call's `output` (see
1141
- * `throwIfPeerError` for why the codec must not do that itself). So a caller
1142
- * that stopped reading — an aborted `fetch`, a cancelled response stream —
1143
- * left the peer's producer parked forever, holding its handler open. Safe here
1144
- * for the same reason it is safe in `throwIfPeerError`: `output` is this
1145
- * call's own response-only generator, and the request was fully sent.
1146
- */
1147
- async function* releasingOnExit(body, release) {
1187
+ * and its ending never reaches the call's `output` (see `throwIfPeerError` for
1188
+ * why the codec must not do that itself). A caller that stopped reading — an
1189
+ * aborted `fetch`, a cancelled response stream — left the peer's producer
1190
+ * parked forever; and one that read the body to its end left the call itself
1191
+ * open, which on a multiplexed transport is one port per exchange until the
1192
+ * multiplexer refuses to open more (`maxPorts`).
1193
+ *
1194
+ * Left early, the call is abandoned (`.return()`): the peer's producer is
1195
+ * released. Read to its end, the call is not cut short but read to ITS end, in
1196
+ * the background: the peer ends it once its side of the exchange is over,
1197
+ * which may be later than the response — a handler that answered before it
1198
+ * read the request body is still receiving it (`HttpDataHandlerResult.settled`),
1199
+ * and abandoning the call would abort that upload.
1200
+ */
1201
+ async function* releasingOnExit(body, finish, release) {
1148
1202
  let completed = false;
1149
1203
  try {
1150
1204
  yield* body;
1151
1205
  completed = true;
1152
1206
  } finally {
1153
- if (!completed) await release();
1207
+ if (completed) finish();
1208
+ else await release();
1154
1209
  }
1155
1210
  }
1211
+ /** Reads `output` to its end, discarding what is left, ignoring a failure. */
1212
+ async function drain(output) {
1213
+ try {
1214
+ for (let r = await output.next(); !r.done; r = await output.next());
1215
+ } catch {}
1216
+ }
1156
1217
  /**
1157
1218
  * The one place that knows a runtime may not implement request body streams,
1158
1219
  * and the buffering both fallbacks need. `fetch.ts` and `http-stubs.ts` each
@@ -1306,26 +1367,6 @@ async function* readableToAsyncIterable(stream) {
1306
1367
  } catch {}
1307
1368
  }
1308
1369
  }
1309
- function asyncIterableToReadable(iter) {
1310
- const it = iter[Symbol.asyncIterator]();
1311
- return new ReadableStream({
1312
- async pull(controller) {
1313
- try {
1314
- const { done, value } = await it.next();
1315
- if (done) {
1316
- controller.close();
1317
- return;
1318
- }
1319
- controller.enqueue(value);
1320
- } catch (err) {
1321
- controller.error(err);
1322
- }
1323
- },
1324
- async cancel() {
1325
- await it.return?.();
1326
- }
1327
- });
1328
- }
1329
1370
  /**
1330
1371
  * Run a `Request` through a `Duplex` call and reconstruct the `Response` on
1331
1372
  * the other side. The request's `signal` is plumbed into the body iteration —
@@ -1344,34 +1385,94 @@ async function fetchOverDuplex(call, request, options = {}) {
1344
1385
  const buffered = new Uint8Array(await request.arrayBuffer());
1345
1386
  if (buffered.byteLength > 0) body = [buffered];
1346
1387
  }
1347
- const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
1388
+ const { envelope, body: respBody, release, finish } = await httpFetch(call, env, body, {
1389
+ ...options,
1390
+ signal: request.signal ?? void 0
1391
+ });
1348
1392
  const responseInit = {
1349
1393
  status: envelope.status,
1350
1394
  statusText: envelope.statusText,
1351
1395
  headers: forwardableHeaders(envelope.headers)
1352
1396
  };
1353
1397
  if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
1354
- await release?.();
1398
+ if (finish) finish();
1399
+ else await release?.();
1355
1400
  return new Response(null, responseInit);
1356
1401
  }
1357
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
1402
+ return new Response(responseBody(respBody, request.signal, release), responseInit);
1358
1403
  }
1359
- async function* withAbort(iter, signal) {
1360
- if (!signal) {
1361
- yield* iter;
1362
- return;
1363
- }
1364
- for await (const chunk of iter) {
1365
- if (signal.aborted) throw abortReason$1(signal);
1366
- yield chunk;
1367
- }
1368
- }
1369
- function abortReason$1(signal) {
1370
- const reason = signal.reason;
1371
- if (reason instanceof Error) return reason;
1372
- const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1373
- err.name = "AbortError";
1374
- return err;
1404
+ /**
1405
+ * How long a cancelled body waits for the read in flight before it abandons the call anyway.
1406
+ * The read's answer tells a body that has ended (the call is finished: an upload the handler
1407
+ * reads after answering goes on) from one that has not; a peer that sends nothing more never
1408
+ * answers it. A body whose end comes later than this after the cancel is abandoned too, so a
1409
+ * handler still streaming its response and still reading the upload then loses the upload.
1410
+ */
1411
+ const CANCEL_GRACE_MS = 1e3;
1412
+ /**
1413
+ * The response body as a stream that the request's signal can end at any time.
1414
+ *
1415
+ * An abort errors the stream with the abort's reason at once — a read waiting
1416
+ * for a peer that sends nothing more rejects then, not when a next chunk
1417
+ * arrives, which may be never — and abandons the call (`release`), so on a
1418
+ * multiplexed transport its port is given back whether or not anyone still
1419
+ * reads the body. The listener is removed once the body ends, however it ends:
1420
+ * read to its end, cancelled, failed or aborted. A body nobody reads or cancels
1421
+ * keeps it until the signal aborts (and keeps the call open until then anyway).
1422
+ * A cancel abandons the call too — unless the read in flight comes back with the
1423
+ * body's end, which finishes it instead (see `cancel`).
1424
+ */
1425
+ function responseBody(body, signal, release) {
1426
+ const it = body[Symbol.asyncIterator]();
1427
+ let controller;
1428
+ let ended = false;
1429
+ let reading = false;
1430
+ const end = () => {
1431
+ ended = true;
1432
+ signal?.removeEventListener("abort", onAbort);
1433
+ };
1434
+ const onAbort = () => {
1435
+ if (ended) return;
1436
+ end();
1437
+ try {
1438
+ controller.error(abortReason$1(signal));
1439
+ } catch {}
1440
+ release?.();
1441
+ };
1442
+ return new ReadableStream({
1443
+ start(c) {
1444
+ controller = c;
1445
+ if (signal?.aborted) onAbort();
1446
+ else signal?.addEventListener("abort", onAbort, { once: true });
1447
+ },
1448
+ async pull(c) {
1449
+ try {
1450
+ reading = true;
1451
+ const { done, value } = await it.next().finally(() => {
1452
+ reading = false;
1453
+ });
1454
+ if (ended) return;
1455
+ if (done) {
1456
+ end();
1457
+ c.close();
1458
+ return;
1459
+ }
1460
+ c.enqueue(value);
1461
+ } catch (err) {
1462
+ if (ended) return;
1463
+ end();
1464
+ c.error(err);
1465
+ }
1466
+ },
1467
+ cancel() {
1468
+ end();
1469
+ const returned = Promise.resolve(it.return?.()).catch(() => {});
1470
+ if (!reading) return;
1471
+ const grace = setTimeout(() => void release?.(), CANCEL_GRACE_MS);
1472
+ grace.unref?.();
1473
+ returned.finally(() => clearTimeout(grace));
1474
+ }
1475
+ });
1375
1476
  }
1376
1477
  var HttpError = class HttpError extends Error {
1377
1478
  status;
@@ -1716,8 +1817,11 @@ function listenPort(port, handler, { channelName = "", log = () => {} } = {}) {
1716
1817
  error
1717
1818
  });
1718
1819
  };
1719
- port.addEventListener("message", onMessage);
1720
- return () => port.removeEventListener("message", onMessage);
1820
+ const listener = (event) => {
1821
+ onMessage(event);
1822
+ };
1823
+ port.addEventListener("message", listener);
1824
+ return () => port.removeEventListener("message", listener);
1721
1825
  }
1722
1826
  /**
1723
1827
  * Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
@@ -1747,6 +1851,69 @@ async function* throughAbort(input, signal) {
1747
1851
  }
1748
1852
  }
1749
1853
  /**
1854
+ * Makes `target` disposable with `await using`: installs `dispose` as its
1855
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
1856
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
1857
+ * this gives it back. A no-op where the symbol is missing. Kept off the
1858
+ * declared types, which would require consumers to compile with the
1859
+ * `esnext.disposable` lib.
1860
+ */
1861
+ function asyncDisposable$1(target, dispose) {
1862
+ const asyncDispose = Symbol.asyncDispose;
1863
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
1864
+ value: async () => {
1865
+ await dispose();
1866
+ },
1867
+ configurable: true,
1868
+ writable: true
1869
+ });
1870
+ return target;
1871
+ }
1872
+ /**
1873
+ * `gen`, except that `return()` and `throw()` take effect at once.
1874
+ *
1875
+ * On an async generator both are queued behind a `next()` that is still
1876
+ * pending: a consumer that gives up on a stream while it waits for the peer's
1877
+ * next chunk — an aborted fetch whose response head has not come, say — cannot
1878
+ * end it until that chunk arrives, which for a peer that never answers is
1879
+ * never. The stream's `finally` (which closes its port and tells the peer)
1880
+ * then never runs, and on a multiplexer the port is kept for good.
1881
+ *
1882
+ * So `abandon` runs first, synchronously: it must make the pending `next()`
1883
+ * settle (abort the stream), which then resolves done, after which the
1884
+ * generator's own `return()` / `throw()` proceeds as usual. On a generator
1885
+ * suspended at a `yield` it is the same ending, a step earlier. `abandon` runs once, whichever comes first.
1886
+ */
1887
+ function returningAtOnce(gen, abandon) {
1888
+ let abandoned = false;
1889
+ const giveUp = (reason) => {
1890
+ if (abandoned) return;
1891
+ abandoned = true;
1892
+ abandon(reason);
1893
+ };
1894
+ const wrapped = {
1895
+ next: (...args) => gen.next(...args).catch((error) => {
1896
+ if (abandoned) return {
1897
+ done: true,
1898
+ value: void 0
1899
+ };
1900
+ throw error;
1901
+ }),
1902
+ return(value) {
1903
+ giveUp();
1904
+ return gen.return(value);
1905
+ },
1906
+ throw(error) {
1907
+ giveUp(error);
1908
+ return gen.throw(error);
1909
+ },
1910
+ [Symbol.asyncIterator]() {
1911
+ return wrapped;
1912
+ }
1913
+ };
1914
+ return asyncDisposable$1(wrapped, () => wrapped.return(void 0));
1915
+ }
1916
+ /**
1750
1917
  * The `type` of the out-of-band notice a side posts when it abandons a stream.
1751
1918
  *
1752
1919
  * Layer 1's `close` is not observable to layer 2 — a closed virtual port drops
@@ -1783,7 +1950,7 @@ function runCallerSide(port, input, options) {
1783
1950
  const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
1784
1951
  const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
1785
1952
  pump.catch(() => {});
1786
- return (async function* () {
1953
+ return returningAtOnce((async function* () {
1787
1954
  try {
1788
1955
  yield* inbound.stream;
1789
1956
  } finally {
@@ -1797,7 +1964,9 @@ function runCallerSide(port, input, options) {
1797
1964
  await port.close?.();
1798
1965
  } catch {}
1799
1966
  }
1800
- })();
1967
+ })(), () => {
1968
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
1969
+ });
1801
1970
  }
1802
1971
  /**
1803
1972
  * Listens for the peer's abort notice, and can post our own. The notice is a
@@ -1998,6 +2167,33 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
1998
2167
  }
1999
2168
  }
2000
2169
  /**
2170
+ * Runs `fn` on a later task, never a microtask: a producer looping on
2171
+ * `whenPortReady` must let the transport's own events run. `setImmediate`
2172
+ * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
2173
+ * are clamped to 4 ms in browsers.
2174
+ */
2175
+ const setImmediateFn = globalThis.setImmediate;
2176
+ /** @internal */
2177
+ const nextTask = setImmediateFn ? (fn) => {
2178
+ setImmediateFn(fn);
2179
+ } : (() => {
2180
+ let channel;
2181
+ const queue = [];
2182
+ return (fn) => {
2183
+ if (!channel) {
2184
+ channel = new MessageChannel();
2185
+ channel.port1.onmessage = () => queue.shift()?.();
2186
+ }
2187
+ queue.push(fn);
2188
+ channel.port2.postMessage(0);
2189
+ };
2190
+ })();
2191
+ const states = /* @__PURE__ */ new WeakMap();
2192
+ /** @internal Registered by the multiplexer for each port with a window. */
2193
+ function setPortSendState(port, state) {
2194
+ states.set(port, state);
2195
+ }
2196
+ /**
2001
2197
  * One virtual port.
2002
2198
  *
2003
2199
  * `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
@@ -2012,6 +2208,14 @@ function newVirtualPort(send, requestClose) {
2012
2208
  if (closing.signal.aborted) return;
2013
2209
  closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
2014
2210
  };
2211
+ const close = (reason) => {
2212
+ if (closed) return;
2213
+ closed = true;
2214
+ const notify = requestClose;
2215
+ listeners.clear();
2216
+ notify(reason);
2217
+ abort(reason);
2218
+ };
2015
2219
  const port = {
2016
2220
  addEventListener(_type, listener) {
2017
2221
  listeners.add(listener);
@@ -2023,24 +2227,28 @@ function newVirtualPort(send, requestClose) {
2023
2227
  if (closed) return;
2024
2228
  send(message, transfer);
2025
2229
  },
2026
- close() {
2027
- if (closed) return;
2028
- closed = true;
2029
- const notify = requestClose;
2030
- listeners.clear();
2031
- notify();
2032
- abort();
2033
- }
2230
+ close
2034
2231
  };
2035
2232
  setPortCloseSignal(port, closing.signal);
2233
+ const dispose = Symbol.dispose;
2234
+ if (dispose) Object.defineProperty(port, dispose, {
2235
+ value: () => close(),
2236
+ configurable: true
2237
+ });
2036
2238
  return {
2037
2239
  port,
2038
2240
  deliver(payload) {
2039
2241
  if (closed) return;
2040
2242
  const event = new MessageEvent("message", { data: payload });
2243
+ let pending;
2041
2244
  for (const listener of [...listeners]) try {
2042
- listener(event);
2245
+ const result = listener(event);
2246
+ if (result && typeof result.then === "function") {
2247
+ pending ??= [];
2248
+ pending.push(result);
2249
+ }
2043
2250
  } catch {}
2251
+ return pending ? Promise.allSettled(pending) : void 0;
2044
2252
  },
2045
2253
  markClosed(reason) {
2046
2254
  closed = true;
@@ -2049,53 +2257,309 @@ function newVirtualPort(send, requestClose) {
2049
2257
  },
2050
2258
  isClosed() {
2051
2259
  return closed;
2260
+ },
2261
+ hasListeners() {
2262
+ return listeners.size > 0;
2052
2263
  }
2053
2264
  };
2054
2265
  }
2055
2266
  /**
2056
- * The message of the error that fails the calls still in flight on a
2057
- * multiplexer when it is closed (`PortMux.close()`).
2267
+ * The id of keepalive's `ping` / `pong`: one no port ever takes. An older mux
2268
+ * whose codec passes unknown envelopes through reads them as a close of that
2269
+ * id, which must therefore not be a real port (0 is the initiator's first).
2058
2270
  */
2059
- const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
2271
+ const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
2272
+ /** The longest delay a timer holds; past it `setInterval` fires at once. */
2273
+ const MAX_TIMER_MS = 2 ** 31 - 1;
2060
2274
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
2061
2275
  const DEFAULT_MAX_PORTS = 1024;
2062
2276
  /**
2277
+ * What a message costs a port's window when the codec cannot say
2278
+ * (`PortCodec.encode`): its bytes and string lengths, found in its values up
2279
+ * to 4 levels deep (at most 256 values), and at least 64. Both ends compute it
2280
+ * from the same structured value, so they agree.
2281
+ */
2282
+ function estimateMessageSize(payload) {
2283
+ let budget = 256;
2284
+ const walk = (v, depth) => {
2285
+ if (budget-- <= 0) return 0;
2286
+ if (typeof v === "string") return v.length;
2287
+ if (v instanceof ArrayBuffer) return v.byteLength;
2288
+ if (ArrayBuffer.isView(v)) return v.byteLength;
2289
+ if (v === null || typeof v !== "object" || depth >= 4) return 8;
2290
+ let n = 16;
2291
+ if (Array.isArray(v)) for (const x of v) n += walk(x, depth + 1);
2292
+ else for (const k in v) n += k.length + walk(v[k], depth + 1);
2293
+ return n;
2294
+ };
2295
+ return Math.max(64, walk(payload, 0));
2296
+ }
2297
+ /**
2063
2298
  * The default `PortMux`: emulates multiplexing over a single port.
2064
2299
  *
2065
2300
  * A transport that already multiplexes natively supplies its own `PortMux`
2066
2301
  * instead — this implementation is for transports that offer one pipe.
2067
2302
  */
2068
2303
  function multiplexPort(port, options) {
2069
- const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
2304
+ const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
2305
+ const misses = keepalive?.misses ?? 3;
2306
+ 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`);
2307
+ if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
2070
2308
  const open = /* @__PURE__ */ new Map();
2309
+ const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
2310
+ const entry = open.get(id);
2311
+ if (entry?.handle !== record) return;
2312
+ const { handle: _, credit: __, partial: ___, ...info } = entry;
2313
+ if (!record.hasListeners()) {
2314
+ release("leaked");
2315
+ record.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was dropped without close()"));
2316
+ }
2317
+ try {
2318
+ onLeak?.(info);
2319
+ } catch {}
2320
+ }) : void 0;
2321
+ const touch = (id) => {
2322
+ const entry = open.get(id);
2323
+ if (entry) entry.lastActivity = Date.now();
2324
+ };
2071
2325
  let nextId = side === "initiator" ? 0 : 1;
2072
2326
  let muxClosed = false;
2327
+ /** This end counts encoded bytes and reassembles frames. */
2328
+ const exactCodec = codec.encode != null && codec.decode != null;
2073
2329
  const post = (envelope, transfer) => {
2074
2330
  if (muxClosed) return;
2331
+ codecPost(envelope, transfer);
2332
+ };
2333
+ /** Like `post`, also while closing: the mux's own close notices. */
2334
+ const codecPost = (envelope, transfer) => {
2075
2335
  try {
2076
2336
  codec.post(port, envelope, transfer);
2077
2337
  } catch {}
2078
2338
  };
2079
- const attach = (id) => {
2080
- let self;
2081
- self = newVirtualPort((payload, transfer) => post({
2082
- type: "message",
2083
- id,
2084
- payload
2085
- }, transfer), (reason) => {
2339
+ const outgoing = /* @__PURE__ */ new Map();
2340
+ const ring = [];
2341
+ let pumping = false;
2342
+ const pump = async () => {
2343
+ pumping = true;
2344
+ try {
2345
+ while (ring.length && !muxClosed) {
2346
+ if (port.whenDrained) await port.whenDrained();
2347
+ if (muxClosed) break;
2348
+ const id = ring.shift();
2349
+ const queue = outgoing.get(id);
2350
+ const item = queue?.shift();
2351
+ if (queue?.length) ring.push(id);
2352
+ else outgoing.delete(id);
2353
+ item?.();
2354
+ }
2355
+ } catch (err) {
2356
+ close(err).catch(() => {});
2357
+ } finally {
2358
+ pumping = false;
2359
+ }
2360
+ };
2361
+ const sendOnPort = (id, item) => {
2362
+ if (frameSize == null) {
2363
+ item();
2364
+ return;
2365
+ }
2366
+ let queue = outgoing.get(id);
2367
+ if (!queue) {
2368
+ queue = [];
2369
+ outgoing.set(id, queue);
2370
+ ring.push(id);
2371
+ }
2372
+ queue.push(item);
2373
+ if (!pumping) pump();
2374
+ };
2375
+ const dropOutgoing = (id) => {
2376
+ if (!outgoing.delete(id)) return;
2377
+ const at = ring.indexOf(id);
2378
+ if (at >= 0) ring.splice(at, 1);
2379
+ };
2380
+ const postBytes = (bytes) => {
2381
+ if (muxClosed) return;
2382
+ try {
2383
+ port.postMessage(bytes);
2384
+ } catch {}
2385
+ };
2386
+ const releaseWaiters = (c) => {
2387
+ for (const w of c?.waiters.splice(0) ?? []) w.resolve();
2388
+ };
2389
+ const flush = (c) => {
2390
+ while (c.queue.length) {
2391
+ const next = c.queue[0];
2392
+ if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
2393
+ c.queue.shift();
2394
+ c.queued -= next.size;
2395
+ c.outstanding += next.size;
2396
+ next.send();
2397
+ }
2398
+ for (const w of [...c.waiters]) if (c.queued <= w.below) {
2399
+ c.waiters.splice(c.waiters.indexOf(w), 1);
2400
+ w.resolve();
2401
+ }
2402
+ };
2403
+ const grant = (id, c, bytes) => {
2404
+ c.unconsumed -= bytes;
2405
+ c.toGrant += bytes;
2406
+ const send = () => {
2407
+ c.grantScheduled = false;
2408
+ if (!c.toGrant || !open.has(id)) return;
2086
2409
  post({
2410
+ type: "credit",
2411
+ id,
2412
+ bytes: c.toGrant
2413
+ });
2414
+ c.toGrant = 0;
2415
+ };
2416
+ if (c.toGrant >= c.window / 4) {
2417
+ send();
2418
+ return;
2419
+ }
2420
+ if (c.grantScheduled) return;
2421
+ c.grantScheduled = true;
2422
+ setTimeout(send, 0);
2423
+ };
2424
+ const attach = (id, meta, portWindow, opener, exact) => {
2425
+ const c = typeof portWindow === "number" && Number.isSafeInteger(portWindow) && portWindow > 0 ? {
2426
+ window: portWindow,
2427
+ enforced: false,
2428
+ opener,
2429
+ exact,
2430
+ outstanding: 0,
2431
+ queue: [],
2432
+ queued: 0,
2433
+ unconsumed: 0,
2434
+ waiters: [],
2435
+ toGrant: 0,
2436
+ grantScheduled: false
2437
+ } : void 0;
2438
+ const self = newVirtualPort((payload, transfer) => {
2439
+ touch(id);
2440
+ const envelope = {
2441
+ type: "message",
2442
+ id,
2443
+ payload
2444
+ };
2445
+ if (!c) {
2446
+ post(envelope, transfer);
2447
+ return;
2448
+ }
2449
+ const bytes = c.exact ? codec.encode?.(envelope) : void 0;
2450
+ const size = bytes?.byteLength ?? estimateMessageSize(payload);
2451
+ const waits = frameSize != null || c.queue.length > 0 || c.enforced && c.outstanding > 0 && c.outstanding + size > c.window;
2452
+ const held = !bytes && waits ? {
2453
+ ...envelope,
2454
+ payload: structuredClone(payload, { transfer })
2455
+ } : void 0;
2456
+ const send = () => {
2457
+ if (!bytes) {
2458
+ sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
2459
+ return;
2460
+ }
2461
+ if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2462
+ for (let at = 0; at < bytes.byteLength; at += frameSize) {
2463
+ const data = bytes.subarray(at, at + frameSize);
2464
+ const frame = at + frameSize >= bytes.byteLength ? {
2465
+ type: "frame",
2466
+ id,
2467
+ data,
2468
+ last: true
2469
+ } : {
2470
+ type: "frame",
2471
+ id,
2472
+ data
2473
+ };
2474
+ sendOnPort(id, () => post(frame));
2475
+ }
2476
+ return;
2477
+ }
2478
+ sendOnPort(id, () => postBytes(bytes));
2479
+ };
2480
+ c.queue.push({
2481
+ send,
2482
+ size
2483
+ });
2484
+ c.queued += size;
2485
+ flush(c);
2486
+ }, (reason) => {
2487
+ release(reason);
2488
+ record.markClosed(reason);
2489
+ });
2490
+ function release(reason) {
2491
+ open.delete(id);
2492
+ leaks?.unregister(record);
2493
+ if (c?.queue.length) {
2494
+ for (const item of c.queue.splice(0)) item.send();
2495
+ c.queued = 0;
2496
+ }
2497
+ sendOnPort(id, () => post({
2087
2498
  type: "close",
2088
2499
  id,
2089
2500
  reason
2501
+ }));
2502
+ releaseWaiters(c);
2503
+ }
2504
+ const { markClosed, isClosed, hasListeners } = self;
2505
+ if (c) {
2506
+ const deliver = self.deliver;
2507
+ self.deliver = (payload, size = estimateMessageSize(payload)) => {
2508
+ c.unconsumed += size;
2509
+ const done = deliver(payload);
2510
+ if (done) done.then(() => grant(id, c, size));
2511
+ else grant(id, c, size);
2512
+ return done;
2513
+ };
2514
+ setPortSendState(self.port, {
2515
+ bufferedAmount: () => c.queued,
2516
+ ready: (below = 0) => new Promise((resolve) => {
2517
+ if (c.queued <= below || isClosed()) nextTask(resolve);
2518
+ else c.waiters.push({
2519
+ below,
2520
+ resolve: () => nextTask(resolve)
2521
+ });
2522
+ })
2090
2523
  });
2091
- open.delete(id);
2092
- self.markClosed();
2524
+ }
2525
+ const record = {
2526
+ deliver: self.deliver,
2527
+ markClosed,
2528
+ isClosed,
2529
+ hasListeners,
2530
+ close: (reason) => {
2531
+ if (isClosed()) return;
2532
+ release(reason);
2533
+ markClosed(reason);
2534
+ }
2535
+ };
2536
+ leaks?.register(self.port, {
2537
+ id,
2538
+ record,
2539
+ release
2540
+ }, record);
2541
+ const now = Date.now();
2542
+ open.set(id, {
2543
+ id,
2544
+ meta,
2545
+ openedAt: now,
2546
+ lastActivity: now,
2547
+ handle: record,
2548
+ credit: c
2093
2549
  });
2094
- open.set(id, self);
2095
2550
  return self;
2096
2551
  };
2097
- const handleEnvelope = (envelope) => {
2552
+ const handleEnvelope = (envelope, event) => {
2098
2553
  if (muxClosed) return;
2554
+ heardSincePing = true;
2555
+ if (envelope.type === "ping" || envelope.type === "pong") {
2556
+ peerKeepsAlive = true;
2557
+ if (envelope.type === "ping") post({
2558
+ type: "pong",
2559
+ id: KEEPALIVE_ID
2560
+ });
2561
+ return;
2562
+ }
2099
2563
  if (envelope.type === "open") {
2100
2564
  if (open.has(envelope.id)) return;
2101
2565
  if (open.size >= maxPorts) {
@@ -2106,7 +2570,18 @@ function multiplexPort(port, options) {
2106
2570
  });
2107
2571
  return;
2108
2572
  }
2109
- const handle = attach(envelope.id);
2573
+ const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
2574
+ const credit = open.get(envelope.id)?.credit;
2575
+ if (credit) post(credit.exact ? {
2576
+ type: "credit",
2577
+ id: envelope.id,
2578
+ bytes: 0,
2579
+ exact: true
2580
+ } : {
2581
+ type: "credit",
2582
+ id: envelope.id,
2583
+ bytes: 0
2584
+ });
2110
2585
  let accepted = false;
2111
2586
  if (onPort) try {
2112
2587
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -2121,57 +2596,191 @@ function multiplexPort(port, options) {
2121
2596
  id: envelope.id,
2122
2597
  reason: "rejected"
2123
2598
  });
2599
+ return;
2124
2600
  }
2125
2601
  return;
2126
2602
  }
2127
- const handle = open.get(envelope.id);
2603
+ const entry = open.get(envelope.id);
2604
+ if (envelope.type === "credit") {
2605
+ const c = entry?.credit;
2606
+ if (!c || !(envelope.bytes >= 0)) return;
2607
+ if (!c.enforced && c.opener) {
2608
+ if (c.exact && envelope.exact !== true) {
2609
+ c.exact = false;
2610
+ c.outstanding = 0;
2611
+ }
2612
+ post({
2613
+ type: "credit",
2614
+ id: envelope.id,
2615
+ bytes: 0
2616
+ });
2617
+ }
2618
+ c.enforced = true;
2619
+ c.outstanding = Math.max(0, c.outstanding - envelope.bytes);
2620
+ flush(c);
2621
+ return;
2622
+ }
2128
2623
  if (envelope.type === "message") {
2129
- if (!handle) return;
2130
- handle.deliver(envelope.payload);
2624
+ const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
2625
+ if (!entry) return;
2626
+ touch(envelope.id);
2627
+ entry.handle.deliver(envelope.payload, entry.credit ? sizeOf(entry.credit) : void 0);
2628
+ return;
2629
+ }
2630
+ if (envelope.type === "frame") {
2631
+ if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
2632
+ touch(envelope.id);
2633
+ const partial = entry.partial ?? {
2634
+ chunks: [],
2635
+ bytes: 0
2636
+ };
2637
+ entry.partial = partial;
2638
+ partial.chunks.push(envelope.data);
2639
+ partial.bytes += envelope.data.byteLength;
2640
+ if (!envelope.last) return;
2641
+ entry.partial = void 0;
2642
+ const whole = new Uint8Array(partial.bytes);
2643
+ let at = 0;
2644
+ for (const chunk of partial.chunks) {
2645
+ whole.set(chunk, at);
2646
+ at += chunk.byteLength;
2647
+ }
2648
+ const inner = codec.decode(whole);
2649
+ if (inner?.type !== "message" || inner.id !== envelope.id) return;
2650
+ entry.handle.deliver(inner.payload, whole.byteLength);
2131
2651
  return;
2132
2652
  }
2133
- if (!handle) return;
2653
+ dropOutgoing(envelope.id);
2654
+ if (!entry) return;
2134
2655
  open.delete(envelope.id);
2135
- handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
2656
+ releaseWaiters(entry.credit);
2657
+ entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
2136
2658
  };
2137
2659
  const listener = (event) => {
2138
2660
  const envelope = codec.read(event);
2139
- if (envelope) handleEnvelope(envelope);
2661
+ if (envelope) handleEnvelope(envelope, event);
2662
+ };
2663
+ let peerKeepsAlive = false;
2664
+ let heardSincePing = true;
2665
+ let unanswered = 0;
2666
+ let pinger;
2667
+ const tick = () => {
2668
+ if (!keepalive) return;
2669
+ const backlogged = (port.bufferedAmount ?? 0) > 0;
2670
+ if (heardSincePing) unanswered = 0;
2671
+ else if (peerKeepsAlive && !backlogged) unanswered++;
2672
+ if (unanswered >= misses) {
2673
+ close(/* @__PURE__ */ new Error(`webrun-rpc: the peer did not answer ${misses} keepalive pings, ${keepalive.intervalMs} ms apart`)).catch(() => {});
2674
+ return;
2675
+ }
2676
+ heardSincePing = false;
2677
+ post({
2678
+ type: "ping",
2679
+ id: KEEPALIVE_ID
2680
+ });
2681
+ };
2682
+ const close = async (reason) => {
2683
+ if (muxClosed) return;
2684
+ clearInterval(pinger);
2685
+ muxClosed = true;
2686
+ signal?.removeEventListener("abort", onTransportAbort);
2687
+ port.removeEventListener("message", listener);
2688
+ const closedNow = /* @__PURE__ */ new Set();
2689
+ for (const [id, { handle, credit }] of [...open]) {
2690
+ closedNow.add(id);
2691
+ codecPost({
2692
+ type: "close",
2693
+ id
2694
+ });
2695
+ open.delete(id);
2696
+ releaseWaiters(credit);
2697
+ handle.markClosed(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the multiplexer was closed"));
2698
+ }
2699
+ for (const id of outgoing.keys()) if (!closedNow.has(id)) codecPost({
2700
+ type: "close",
2701
+ id
2702
+ });
2703
+ outgoing.clear();
2704
+ ring.length = 0;
2705
+ await port.close?.();
2706
+ };
2707
+ const onTransportAbort = () => {
2708
+ close(signal?.reason).catch(() => {});
2140
2709
  };
2141
2710
  port.addEventListener("message", listener);
2142
2711
  port.start?.();
2712
+ if (keepalive) {
2713
+ pinger = setInterval(tick, keepalive.intervalMs);
2714
+ pinger.unref?.();
2715
+ }
2716
+ if (signal?.aborted) close(signal.reason).catch(() => {});
2717
+ else signal?.addEventListener("abort", onTransportAbort, { once: true });
2143
2718
  return {
2144
2719
  maxMessageSize,
2145
- async openPort(meta) {
2720
+ async openPort(meta, { signal: portSignal } = {}) {
2146
2721
  if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
2147
- if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
2722
+ if (portSignal?.aborted) throw portSignal.reason;
2723
+ if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached; open ports by kind: ${describeKinds(open.values())}`);
2148
2724
  while (open.has(nextId)) nextId += 2;
2149
2725
  const id = nextId;
2150
2726
  nextId += 2;
2151
- const handle = attach(id);
2152
- post({
2727
+ const handle = attach(id, meta, window, true, exactCodec);
2728
+ post(window ? exactCodec ? {
2729
+ type: "open",
2730
+ id,
2731
+ meta,
2732
+ window,
2733
+ exact: true
2734
+ } : {
2735
+ type: "open",
2736
+ id,
2737
+ meta,
2738
+ window
2739
+ } : {
2153
2740
  type: "open",
2154
2741
  id,
2155
2742
  meta
2156
2743
  });
2744
+ if (portSignal) {
2745
+ const record = open.get(id)?.handle;
2746
+ const onAbort = () => {
2747
+ if (record && open.get(id)?.handle === record) record.close(portSignal.reason);
2748
+ };
2749
+ portSignal.addEventListener("abort", onAbort, { once: true });
2750
+ getPortCloseSignal(handle.port)?.addEventListener("abort", () => portSignal.removeEventListener("abort", onAbort), { once: true });
2751
+ }
2157
2752
  return handle.port;
2158
2753
  },
2159
- async close() {
2160
- if (muxClosed) return;
2161
- for (const [id, handle] of [...open]) {
2162
- post({
2163
- type: "close",
2164
- id
2165
- });
2166
- open.delete(id);
2167
- handle.markClosed(/* @__PURE__ */ new Error(MUX_CLOSED_MESSAGE));
2168
- }
2169
- muxClosed = true;
2170
- port.removeEventListener("message", listener);
2171
- await port.close?.();
2754
+ close,
2755
+ ports() {
2756
+ return [...open.values()].map(({ handle: _, credit, ...info }) => credit ? {
2757
+ ...info,
2758
+ ...credit.enforced ? { window: credit.window } : {},
2759
+ queued: credit.queued,
2760
+ outstanding: credit.outstanding,
2761
+ unconsumed: credit.unconsumed
2762
+ } : info);
2172
2763
  }
2173
2764
  };
2174
2765
  }
2766
+ /** The byte length of a received message's data, or `undefined` if it is not bytes. */
2767
+ function byteLengthOf(data) {
2768
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return data.byteLength;
2769
+ }
2770
+ /**
2771
+ * `"http: 1000, ws: 24"`: the three commonest `meta.kind`s among `entries`, so
2772
+ * a full table names who filled it. A port without a kind counts as `"?"`.
2773
+ */
2774
+ function describeKinds(entries) {
2775
+ const counts = /* @__PURE__ */ new Map();
2776
+ for (const { meta } of entries) {
2777
+ const kind = meta?.kind;
2778
+ const name = typeof kind === "string" ? kind : "?";
2779
+ counts.set(name, (counts.get(name) ?? 0) + 1);
2780
+ }
2781
+ if (!counts.size) return "none";
2782
+ return [...counts].sort((x, y) => y[1] - x[1]).slice(0, 3).map(([name, n]) => `${name}: ${n}`).join(", ");
2783
+ }
2175
2784
  /**
2176
2785
  * Announced with every port this pair opens. Layer 1 never reads `meta` — it is
2177
2786
  * here so a peer running something else over the same multiplexer can tell a
@@ -2193,9 +2802,22 @@ const connect = async ({ mux: factory, timeout }) => {
2193
2802
  maxMessageSize: mux.maxMessageSize,
2194
2803
  timeout
2195
2804
  };
2196
- const call = (input) => (async function* () {
2197
- yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
2198
- })();
2805
+ const call = (input) => {
2806
+ let stream;
2807
+ let abandoned = false;
2808
+ return returningAtOnce((async function* () {
2809
+ const port = await mux.openPort(STREAM_META);
2810
+ if (abandoned) {
2811
+ await port.close?.();
2812
+ return;
2813
+ }
2814
+ stream = duplexOverPort(port, streamOptions)(input);
2815
+ yield* stream;
2816
+ })(), () => {
2817
+ abandoned = true;
2818
+ stream?.return(void 0).catch(() => {});
2819
+ });
2820
+ };
2199
2821
  return {
2200
2822
  call,
2201
2823
  async close() {
@@ -2220,7 +2842,7 @@ function isEnvelope(value) {
2220
2842
  const candidate = value;
2221
2843
  if (typeof candidate.id !== "number") return false;
2222
2844
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
2223
- return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
2845
+ return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
2224
2846
  }
2225
2847
  /**
2226
2848
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -2241,6 +2863,27 @@ const structuredCodec = {
2241
2863
  }
2242
2864
  };
2243
2865
  //#endregion
2866
+ //#region src/link/async-dispose.ts
2867
+ /**
2868
+ * Makes `target` disposable with `await using`: installs `dispose` as its
2869
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
2870
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
2871
+ * this gives it back. A no-op where the symbol is missing. Kept off the
2872
+ * declared types, which would require consumers to compile with the
2873
+ * `esnext.disposable` lib.
2874
+ */
2875
+ function asyncDisposable(target, dispose) {
2876
+ const asyncDispose = Symbol.asyncDispose;
2877
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
2878
+ value: async () => {
2879
+ await dispose();
2880
+ },
2881
+ configurable: true,
2882
+ writable: true
2883
+ });
2884
+ return target;
2885
+ }
2886
+ //#endregion
2244
2887
  //#region src/link/http-link.ts
2245
2888
  /**
2246
2889
  * The version of the HTTP-over-`MessagePort` protocol this package speaks,
@@ -2270,11 +2913,12 @@ var LinkLostError = class extends Error {
2270
2913
  */
2271
2914
  const LINK_CLOSE = "webrun-http:link-close";
2272
2915
  /** Opens the calling end of a link on `port` (one port per link). */
2273
- async function connectHttpLink(port, { streamTimeout } = {}) {
2916
+ async function connectHttpLink(port, { streamTimeout, maxPorts } = {}) {
2274
2917
  const { call, close } = await connect({
2275
2918
  mux: overPipe(port, {
2276
2919
  codec: structuredCodec,
2277
- side: "initiator"
2920
+ side: "initiator",
2921
+ maxPorts
2278
2922
  }),
2279
2923
  timeout: streamTimeout
2280
2924
  });
@@ -2299,7 +2943,7 @@ function trackedLink(send, release) {
2299
2943
  inflight--;
2300
2944
  if (inflight === 0) for (const resolve of idleWaiters.splice(0)) resolve();
2301
2945
  };
2302
- return {
2946
+ const link = {
2303
2947
  get inflight() {
2304
2948
  return inflight;
2305
2949
  },
@@ -2336,7 +2980,7 @@ function trackedLink(send, release) {
2336
2980
  finish();
2337
2981
  return response;
2338
2982
  }
2339
- return new Response(guardBody(response.body, lost, () => lostReason, finish), {
2983
+ return new Response(guardBody(response.body, lost, () => lostReason, finish, request.signal), {
2340
2984
  status: response.status,
2341
2985
  statusText: response.statusText,
2342
2986
  headers: response.headers
@@ -2352,35 +2996,68 @@ function trackedLink(send, release) {
2352
2996
  } catch {}
2353
2997
  }
2354
2998
  };
2999
+ return asyncDisposable(link, () => link.close());
2355
3000
  }
2356
3001
  /**
2357
- * A response body that reports its end (done, cancelled or failed) and fails
2358
- * with the link's reason when the link is lost mid-stream.
3002
+ * A response body that reports its end (done, cancelled, failed or aborted) and
3003
+ * fails with the link's reason when the link is lost mid-stream. An abort of the
3004
+ * request's signal ends it at once, read or not: the exchange is over (the layer
3005
+ * below has given its port back), so the request leaves `inflight` then.
2359
3006
  */
2360
- function guardBody(body, lost, reason, finish) {
3007
+ function guardBody(body, lost, reason, finish, signal) {
2361
3008
  const reader = body.getReader();
3009
+ let controller;
3010
+ let ended = false;
3011
+ const end = () => {
3012
+ ended = true;
3013
+ signal?.removeEventListener("abort", onAbort);
3014
+ finish();
3015
+ };
3016
+ const onAbort = () => {
3017
+ if (ended) return;
3018
+ end();
3019
+ reader.cancel().catch(() => {});
3020
+ try {
3021
+ controller.error(abortError(signal));
3022
+ } catch {}
3023
+ };
2362
3024
  return new ReadableStream({
3025
+ start(c) {
3026
+ controller = c;
3027
+ if (signal?.aborted) onAbort();
3028
+ else signal?.addEventListener("abort", onAbort, { once: true });
3029
+ },
2363
3030
  async pull(controller) {
2364
3031
  try {
2365
3032
  const { done, value } = await Promise.race([reader.read(), lost]);
3033
+ if (ended) return;
2366
3034
  if (done) {
2367
- finish();
3035
+ end();
2368
3036
  controller.close();
2369
3037
  return;
2370
3038
  }
2371
3039
  controller.enqueue(value);
2372
3040
  } catch (error) {
2373
- finish();
3041
+ if (ended) return;
3042
+ end();
2374
3043
  reader.cancel().catch(() => {});
2375
3044
  controller.error(reason() ?? error);
2376
3045
  }
2377
3046
  },
2378
3047
  async cancel(why) {
2379
- finish();
3048
+ end();
2380
3049
  await reader.cancel(why).catch(() => {});
2381
3050
  }
2382
3051
  });
2383
3052
  }
3053
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
3054
+ function abortError(signal) {
3055
+ const why = signal.reason;
3056
+ if (why instanceof Error) return why;
3057
+ const err = new Error(why === void 0 ? "Aborted" : String(why));
3058
+ err.name = "AbortError";
3059
+ return err;
3060
+ }
2384
3061
  //#endregion
2385
3062
  //#region src/sw/protocol.ts
2386
3063
  /** Page → ServiceWorker: open a WebSocket to a handler. */