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