@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/index.js CHANGED
@@ -178,8 +178,8 @@ const TYPE_CLOSE = 6;
178
178
  const DEFAULT_MAX_STREAMS = 256;
179
179
  const DEFAULT_MTU = 65536;
180
180
  const DEFAULT_MAX_STREAM_BUFFER = 8388608;
181
- const textEncoder = new TextEncoder();
182
- const textDecoder = new TextDecoder();
181
+ const textEncoder = /* @__PURE__ */ new TextEncoder();
182
+ const textDecoder = /* @__PURE__ */ new TextDecoder();
183
183
  function emulateMux(channel, opts = {}) {
184
184
  const maxStreams = opts.maxStreams ?? DEFAULT_MAX_STREAMS;
185
185
  const mtu = opts.mtu ?? DEFAULT_MTU;
@@ -716,7 +716,7 @@ async function* newAsyncGenerator(init, skipValues = false) {
716
716
  drainQueue();
717
717
  }
718
718
  }
719
- const utf8 = new TextEncoder();
719
+ const utf8 = /* @__PURE__ */ new TextEncoder();
720
720
  /**
721
721
  * Coerce common byte-like inputs into a `Uint8Array`. Strings encode as UTF-8.
722
722
  * Blobs return a `Promise<Uint8Array>`; every other input returns synchronously.
@@ -1483,8 +1483,8 @@ var HttpParseError = class extends Error {
1483
1483
  const NEWLINE = 10;
1484
1484
  /** Mirrors the HTTP/1.1 codec's default `maxHeaderBytes`. */
1485
1485
  const MAX_ENVELOPE_BYTES = 65536;
1486
- const encoder$1 = new TextEncoder();
1487
- const decoder = new TextDecoder();
1486
+ const encoder$1 = /* @__PURE__ */ new TextEncoder();
1487
+ const decoder = /* @__PURE__ */ new TextDecoder();
1488
1488
  /**
1489
1489
  * Encode an HTTP envelope plus optional body as one continuous byte stream:
1490
1490
  *
@@ -1738,15 +1738,15 @@ function resolveFraming(headers, version) {
1738
1738
  };
1739
1739
  return { kind: "none" };
1740
1740
  }
1741
- const CRLF = new Uint8Array([13, 10]);
1742
- const LAST_CHUNK = new Uint8Array([
1741
+ const CRLF = /* @__PURE__ */ new Uint8Array([13, 10]);
1742
+ const LAST_CHUNK = /* @__PURE__ */ new Uint8Array([
1743
1743
  48,
1744
1744
  13,
1745
1745
  10,
1746
1746
  13,
1747
1747
  10
1748
1748
  ]);
1749
- const encoder = new TextEncoder();
1749
+ const encoder = /* @__PURE__ */ new TextEncoder();
1750
1750
  /**
1751
1751
  * Chunk sizes are written in HEXADECIMAL — a 21-byte chunk is `15`. Writing
1752
1752
  * them in decimal is the defect note 15 found in @libp2p/http, and it is the
@@ -2051,7 +2051,7 @@ function newHttpCodec(options = {}) {
2051
2051
  decodeResponse: (input, o) => decodeResponse(input, opts, o.method)
2052
2052
  };
2053
2053
  }
2054
- const httpCodec = newHttpCodec();
2054
+ const httpCodec = /* @__PURE__ */ newHttpCodec();
2055
2055
  /**
2056
2056
  * Dispatches on byte 0. The formats are self-identifying — a JSON envelope
2057
2057
  * always begins `{`, which is not a token character and so can never begin an
@@ -2101,10 +2101,29 @@ function newSniffingCodec(options) {
2101
2101
  * Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
2102
2102
  * no `codec` option is supplied.
2103
2103
  */
2104
- const defaultCodec = newSniffingCodec({
2104
+ const defaultCodec = /* @__PURE__ */ newSniffingCodec({
2105
2105
  write: httpCodec,
2106
2106
  accept: [httpCodec, jsonEnvelopeCodec]
2107
2107
  });
2108
+ /**
2109
+ * Makes `target` disposable with `await using`: installs `dispose` as its
2110
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
2111
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
2112
+ * this gives it back. A no-op where the symbol is missing. Kept off the
2113
+ * declared types, which would require consumers to compile with the
2114
+ * `esnext.disposable` lib.
2115
+ */
2116
+ function asyncDisposable$2(target, dispose) {
2117
+ const asyncDispose = Symbol.asyncDispose;
2118
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
2119
+ value: async () => {
2120
+ await dispose();
2121
+ },
2122
+ configurable: true,
2123
+ writable: true
2124
+ });
2125
+ return target;
2126
+ }
2108
2127
  /** Carries the serialized JS error between two webrun peers. */
2109
2128
  const PEER_ERROR_HEADER = "x-webrun-error";
2110
2129
  /** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
@@ -2182,41 +2201,105 @@ async function throwIfPeerError(result, output) {
2182
2201
  */
2183
2202
  async function httpFetch(call, env, body, options = {}) {
2184
2203
  const codec = options.codec ?? defaultCodec;
2204
+ const { signal } = options;
2205
+ if (signal?.aborted) throw abortReason$1(signal);
2185
2206
  const output = call(codec.encodeRequest(env, body));
2186
- const decoded = await codec.decodeResponse(output, { method: env.method });
2187
- const release = async () => {
2188
- try {
2189
- await output.return?.(void 0);
2190
- } catch {}
2207
+ let released;
2208
+ const release = () => {
2209
+ released ??= (async () => {
2210
+ try {
2211
+ await output.return?.(void 0);
2212
+ } catch {}
2213
+ })();
2214
+ return released;
2191
2215
  };
2216
+ let finished = false;
2217
+ const finish = () => {
2218
+ if (finished || released) return;
2219
+ finished = true;
2220
+ if (signal?.aborted) {
2221
+ release();
2222
+ return;
2223
+ }
2224
+ const onAbort = () => void release();
2225
+ signal?.addEventListener("abort", onAbort, { once: true });
2226
+ drain(output).finally(() => signal?.removeEventListener("abort", onAbort));
2227
+ };
2228
+ let decoded;
2229
+ try {
2230
+ decoded = await untilAborted(codec.decodeResponse(output, { method: env.method }), signal);
2231
+ } catch (error) {
2232
+ if (signal?.aborted) release();
2233
+ else await release();
2234
+ throw error;
2235
+ }
2192
2236
  const result = {
2193
2237
  envelope: decoded.envelope,
2194
- body: releasingOnExit(decoded.body, release),
2195
- release
2238
+ body: releasingOnExit(decoded.body, finish, release),
2239
+ release,
2240
+ finish
2196
2241
  };
2197
2242
  await throwIfPeerError(result, output);
2198
2243
  return result;
2199
2244
  }
2245
+ /** `promise`, rejected with the signal's reason if it aborts first. */
2246
+ function untilAborted(promise, signal) {
2247
+ if (!signal) return promise;
2248
+ return new Promise((resolve, reject) => {
2249
+ const onAbort = () => reject(abortReason$1(signal));
2250
+ if (signal.aborted) return onAbort();
2251
+ signal.addEventListener("abort", onAbort, { once: true });
2252
+ promise.then((value) => {
2253
+ signal.removeEventListener("abort", onAbort);
2254
+ resolve(value);
2255
+ }, (error) => {
2256
+ signal.removeEventListener("abort", onAbort);
2257
+ reject(error);
2258
+ });
2259
+ });
2260
+ }
2261
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
2262
+ function abortReason$1(signal) {
2263
+ const reason = signal.reason;
2264
+ if (reason instanceof Error) return reason;
2265
+ const err = new Error(reason === void 0 ? "Aborted" : String(reason));
2266
+ err.name = "AbortError";
2267
+ return err;
2268
+ }
2200
2269
  /**
2201
- * `body`, but leaving it before its end releases the call.
2270
+ * `body`, ending the call however it is left: read to its end, or left early.
2202
2271
  *
2203
2272
  * The body the codec hands back is a generator of its own over a `ByteReader`,
2204
- * and `.return()` on it never reaches the call's `output` (see
2205
- * `throwIfPeerError` for why the codec must not do that itself). So a caller
2206
- * that stopped reading — an aborted `fetch`, a cancelled response stream —
2207
- * left the peer's producer parked forever, holding its handler open. Safe here
2208
- * for the same reason it is safe in `throwIfPeerError`: `output` is this
2209
- * call's own response-only generator, and the request was fully sent.
2210
- */
2211
- async function* releasingOnExit(body, release) {
2273
+ * and its ending never reaches the call's `output` (see `throwIfPeerError` for
2274
+ * why the codec must not do that itself). A caller that stopped reading — an
2275
+ * aborted `fetch`, a cancelled response stream — left the peer's producer
2276
+ * parked forever; and one that read the body to its end left the call itself
2277
+ * open, which on a multiplexed transport is one port per exchange until the
2278
+ * multiplexer refuses to open more (`maxPorts`).
2279
+ *
2280
+ * Left early, the call is abandoned (`.return()`): the peer's producer is
2281
+ * released. Read to its end, the call is not cut short but read to ITS end, in
2282
+ * the background: the peer ends it once its side of the exchange is over,
2283
+ * which may be later than the response — a handler that answered before it
2284
+ * read the request body is still receiving it (`HttpDataHandlerResult.settled`),
2285
+ * and abandoning the call would abort that upload.
2286
+ */
2287
+ async function* releasingOnExit(body, finish, release) {
2212
2288
  let completed = false;
2213
2289
  try {
2214
2290
  yield* body;
2215
2291
  completed = true;
2216
2292
  } finally {
2217
- if (!completed) await release();
2293
+ if (completed) finish();
2294
+ else await release();
2218
2295
  }
2219
2296
  }
2297
+ /** Reads `output` to its end, discarding what is left, ignoring a failure. */
2298
+ async function drain(output) {
2299
+ try {
2300
+ for (let r = await output.next(); !r.done; r = await output.next());
2301
+ } catch {}
2302
+ }
2220
2303
  /**
2221
2304
  * Wrap an HTTP handler as a `Duplex` so it can be registered with any
2222
2305
  * `webrun-streams-*` adapter's `serve`.
@@ -2244,6 +2327,7 @@ function httpServe(handler, options = {}) {
2244
2327
  try {
2245
2328
  yield* replyCodec.encodeResponse(result.envelope, result.body, { method: decoded.envelope.method });
2246
2329
  completed = true;
2330
+ await result.settled?.();
2247
2331
  } finally {
2248
2332
  if (!completed) try {
2249
2333
  await result.release?.();
@@ -2500,17 +2584,21 @@ async function fetchOverDuplex(call, request, options = {}) {
2500
2584
  const buffered = new Uint8Array(await request.arrayBuffer());
2501
2585
  if (buffered.byteLength > 0) body = [buffered];
2502
2586
  }
2503
- const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
2587
+ const { envelope, body: respBody, release, finish } = await httpFetch(call, env, body, {
2588
+ ...options,
2589
+ signal: request.signal ?? void 0
2590
+ });
2504
2591
  const responseInit = {
2505
2592
  status: envelope.status,
2506
2593
  statusText: envelope.statusText,
2507
2594
  headers: forwardableHeaders(envelope.headers)
2508
2595
  };
2509
2596
  if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
2510
- await release?.();
2597
+ if (finish) finish();
2598
+ else await release?.();
2511
2599
  return new Response(null, responseInit);
2512
2600
  }
2513
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
2601
+ return new Response(responseBody(respBody, request.signal, release), responseInit);
2514
2602
  }
2515
2603
  /**
2516
2604
  * Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
@@ -2522,13 +2610,21 @@ function serveFetchOverDuplex(handler, options = {}) {
2522
2610
  method: env.method,
2523
2611
  headers: forwardableHeaders(env.headers)
2524
2612
  };
2613
+ let upload;
2525
2614
  if (env.method !== "GET" && env.method !== "HEAD") {
2526
2615
  if (supportsRequestStreams()) {
2527
- reqInit.body = asyncIterableToReadable(body);
2616
+ upload = trackingEnd(body);
2617
+ reqInit.body = asyncIterableToReadable(upload.source);
2528
2618
  reqInit.duplex = "half";
2529
2619
  } else reqInit.body = await collectBytes$1(body);
2530
2620
  }
2531
- const response = await handler(new Request(env.url, reqInit));
2621
+ const request = new Request(env.url, reqInit);
2622
+ const response = await handler(request);
2623
+ const settled = async () => {
2624
+ if (!upload) return;
2625
+ if (!request.bodyUsed && !request.body?.locked) request.body?.cancel().catch(() => {});
2626
+ await upload.settle(options.requestIdleMs ?? 3e4);
2627
+ };
2532
2628
  const respEnv = {
2533
2629
  status: response.status,
2534
2630
  statusText: response.statusText,
@@ -2538,32 +2634,173 @@ function serveFetchOverDuplex(handler, options = {}) {
2538
2634
  await response.body?.cancel();
2539
2635
  return {
2540
2636
  envelope: respEnv,
2541
- body: void 0
2637
+ body: void 0,
2638
+ settled
2542
2639
  };
2543
2640
  }
2544
2641
  return {
2545
2642
  envelope: respEnv,
2546
2643
  body: response.body ? readableToAsyncIterable(response.body) : void 0,
2547
- release: () => response.body?.cancel()
2644
+ release: () => response.body?.cancel(),
2645
+ settled
2548
2646
  };
2549
2647
  }, options);
2550
2648
  }
2551
- async function* withAbort(iter, signal) {
2552
- if (!signal) {
2553
- yield* iter;
2554
- return;
2555
- }
2556
- for await (const chunk of iter) {
2557
- if (signal.aborted) throw abortReason$1(signal);
2558
- yield chunk;
2559
- }
2649
+ /**
2650
+ * `source`, and `settle(idleMs)`: resolves once it is done — read to its end,
2651
+ * failed, or abandoned (`return()` included before the first `next()`, which a
2652
+ * generator's `finally` would not see) — or once nobody has asked it for a
2653
+ * chunk for `idleMs`, in which case it is abandoned: later reads fail.
2654
+ *
2655
+ * Idle counts only while no `next()` is pending: a reader waiting for a slow
2656
+ * sender is still reading.
2657
+ */
2658
+ function trackingEnd(source) {
2659
+ let end = () => {};
2660
+ let isEnded = false;
2661
+ const ended = new Promise((resolve) => {
2662
+ end = () => {
2663
+ isEnded = true;
2664
+ resolve();
2665
+ };
2666
+ });
2667
+ let abandoned;
2668
+ let pending = 0;
2669
+ let idleSince = Date.now();
2670
+ const it = source[Symbol.asyncIterator]();
2671
+ const abandon = (reason) => {
2672
+ abandoned ??= reason;
2673
+ end();
2674
+ Promise.resolve(it.return?.(void 0)).catch(() => {});
2675
+ };
2676
+ const tracked = {
2677
+ [Symbol.asyncIterator]() {
2678
+ return tracked;
2679
+ },
2680
+ async next() {
2681
+ if (abandoned) throw abandoned;
2682
+ pending++;
2683
+ try {
2684
+ const r = await it.next();
2685
+ if (r.done) end();
2686
+ return r;
2687
+ } catch (error) {
2688
+ end();
2689
+ throw error;
2690
+ } finally {
2691
+ if (--pending === 0) idleSince = Date.now();
2692
+ }
2693
+ },
2694
+ async return(value) {
2695
+ end();
2696
+ try {
2697
+ await it.return?.(value);
2698
+ } catch {}
2699
+ return {
2700
+ done: true,
2701
+ value: void 0
2702
+ };
2703
+ }
2704
+ };
2705
+ asyncDisposable$2(tracked, () => tracked.return?.() ?? Promise.resolve());
2706
+ const settle = (idleMs) => {
2707
+ if (isEnded || !Number.isFinite(idleMs)) return ended;
2708
+ return new Promise((resolve) => {
2709
+ let timer;
2710
+ const check = () => {
2711
+ if (isEnded) return resolve();
2712
+ const idle = pending === 0 ? Date.now() - idleSince : 0;
2713
+ if (idle >= idleMs) {
2714
+ abandon(/* @__PURE__ */ new Error(`webrun-http-streams: the request body was left unread for ${idleMs} ms after the response; abandoned`));
2715
+ return resolve();
2716
+ }
2717
+ timer = setTimeout(check, Math.max(1, Math.min(idleMs - idle, 1e3)));
2718
+ timer.unref?.();
2719
+ };
2720
+ ended.then(() => {
2721
+ clearTimeout(timer);
2722
+ resolve();
2723
+ });
2724
+ check();
2725
+ });
2726
+ };
2727
+ return {
2728
+ source: tracked,
2729
+ settle
2730
+ };
2560
2731
  }
2561
- function abortReason$1(signal) {
2562
- const reason = signal.reason;
2563
- if (reason instanceof Error) return reason;
2564
- const err = new Error(reason === void 0 ? "Aborted" : String(reason));
2565
- err.name = "AbortError";
2566
- return err;
2732
+ /**
2733
+ * How long a cancelled body waits for the read in flight before it abandons the call anyway.
2734
+ * The read's answer tells a body that has ended (the call is finished: an upload the handler
2735
+ * reads after answering goes on) from one that has not; a peer that sends nothing more never
2736
+ * answers it. A body whose end comes later than this after the cancel is abandoned too, so a
2737
+ * handler still streaming its response and still reading the upload then loses the upload.
2738
+ */
2739
+ const CANCEL_GRACE_MS = 1e3;
2740
+ /**
2741
+ * The response body as a stream that the request's signal can end at any time.
2742
+ *
2743
+ * An abort errors the stream with the abort's reason at once — a read waiting
2744
+ * for a peer that sends nothing more rejects then, not when a next chunk
2745
+ * arrives, which may be never — and abandons the call (`release`), so on a
2746
+ * multiplexed transport its port is given back whether or not anyone still
2747
+ * reads the body. The listener is removed once the body ends, however it ends:
2748
+ * read to its end, cancelled, failed or aborted. A body nobody reads or cancels
2749
+ * keeps it until the signal aborts (and keeps the call open until then anyway).
2750
+ * A cancel abandons the call too — unless the read in flight comes back with the
2751
+ * body's end, which finishes it instead (see `cancel`).
2752
+ */
2753
+ function responseBody(body, signal, release) {
2754
+ const it = body[Symbol.asyncIterator]();
2755
+ let controller;
2756
+ let ended = false;
2757
+ let reading = false;
2758
+ const end = () => {
2759
+ ended = true;
2760
+ signal?.removeEventListener("abort", onAbort);
2761
+ };
2762
+ const onAbort = () => {
2763
+ if (ended) return;
2764
+ end();
2765
+ try {
2766
+ controller.error(abortReason$1(signal));
2767
+ } catch {}
2768
+ release?.();
2769
+ };
2770
+ return new ReadableStream({
2771
+ start(c) {
2772
+ controller = c;
2773
+ if (signal?.aborted) onAbort();
2774
+ else signal?.addEventListener("abort", onAbort, { once: true });
2775
+ },
2776
+ async pull(c) {
2777
+ try {
2778
+ reading = true;
2779
+ const { done, value } = await it.next().finally(() => {
2780
+ reading = false;
2781
+ });
2782
+ if (ended) return;
2783
+ if (done) {
2784
+ end();
2785
+ c.close();
2786
+ return;
2787
+ }
2788
+ c.enqueue(value);
2789
+ } catch (err) {
2790
+ if (ended) return;
2791
+ end();
2792
+ c.error(err);
2793
+ }
2794
+ },
2795
+ cancel() {
2796
+ end();
2797
+ const returned = Promise.resolve(it.return?.()).catch(() => {});
2798
+ if (!reading) return;
2799
+ const grace = setTimeout(() => void release?.(), CANCEL_GRACE_MS);
2800
+ grace.unref?.();
2801
+ returned.finally(() => clearTimeout(grace));
2802
+ }
2803
+ });
2567
2804
  }
2568
2805
  /**
2569
2806
  * Host a `SiteHandler` over a `Connect/Serve` pair. The `start(serve, params)`
@@ -2850,8 +3087,11 @@ function listenPort(port, handler, { channelName = "", log = () => {} } = {}) {
2850
3087
  error
2851
3088
  });
2852
3089
  };
2853
- port.addEventListener("message", onMessage);
2854
- return () => port.removeEventListener("message", onMessage);
3090
+ const listener = (event) => {
3091
+ onMessage(event);
3092
+ };
3093
+ port.addEventListener("message", listener);
3094
+ return () => port.removeEventListener("message", listener);
2855
3095
  }
2856
3096
  /**
2857
3097
  * Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
@@ -2881,6 +3121,69 @@ async function* throughAbort(input, signal) {
2881
3121
  }
2882
3122
  }
2883
3123
  /**
3124
+ * Makes `target` disposable with `await using`: installs `dispose` as its
3125
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
3126
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
3127
+ * this gives it back. A no-op where the symbol is missing. Kept off the
3128
+ * declared types, which would require consumers to compile with the
3129
+ * `esnext.disposable` lib.
3130
+ */
3131
+ function asyncDisposable$1(target, dispose) {
3132
+ const asyncDispose = Symbol.asyncDispose;
3133
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
3134
+ value: async () => {
3135
+ await dispose();
3136
+ },
3137
+ configurable: true,
3138
+ writable: true
3139
+ });
3140
+ return target;
3141
+ }
3142
+ /**
3143
+ * `gen`, except that `return()` and `throw()` take effect at once.
3144
+ *
3145
+ * On an async generator both are queued behind a `next()` that is still
3146
+ * pending: a consumer that gives up on a stream while it waits for the peer's
3147
+ * next chunk — an aborted fetch whose response head has not come, say — cannot
3148
+ * end it until that chunk arrives, which for a peer that never answers is
3149
+ * never. The stream's `finally` (which closes its port and tells the peer)
3150
+ * then never runs, and on a multiplexer the port is kept for good.
3151
+ *
3152
+ * So `abandon` runs first, synchronously: it must make the pending `next()`
3153
+ * settle (abort the stream), which then resolves done, after which the
3154
+ * generator's own `return()` / `throw()` proceeds as usual. On a generator
3155
+ * suspended at a `yield` it is the same ending, a step earlier. `abandon` runs once, whichever comes first.
3156
+ */
3157
+ function returningAtOnce(gen, abandon) {
3158
+ let abandoned = false;
3159
+ const giveUp = (reason) => {
3160
+ if (abandoned) return;
3161
+ abandoned = true;
3162
+ abandon(reason);
3163
+ };
3164
+ const wrapped = {
3165
+ next: (...args) => gen.next(...args).catch((error) => {
3166
+ if (abandoned) return {
3167
+ done: true,
3168
+ value: void 0
3169
+ };
3170
+ throw error;
3171
+ }),
3172
+ return(value) {
3173
+ giveUp();
3174
+ return gen.return(value);
3175
+ },
3176
+ throw(error) {
3177
+ giveUp(error);
3178
+ return gen.throw(error);
3179
+ },
3180
+ [Symbol.asyncIterator]() {
3181
+ return wrapped;
3182
+ }
3183
+ };
3184
+ return asyncDisposable$1(wrapped, () => wrapped.return(void 0));
3185
+ }
3186
+ /**
2884
3187
  * The `type` of the out-of-band notice a side posts when it abandons a stream.
2885
3188
  *
2886
3189
  * Layer 1's `close` is not observable to layer 2 — a closed virtual port drops
@@ -2976,7 +3279,7 @@ function runCallerSide(port, input, options) {
2976
3279
  const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
2977
3280
  const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
2978
3281
  pump.catch(() => {});
2979
- return (async function* () {
3282
+ return returningAtOnce((async function* () {
2980
3283
  try {
2981
3284
  yield* inbound.stream;
2982
3285
  } finally {
@@ -2990,7 +3293,9 @@ function runCallerSide(port, input, options) {
2990
3293
  await port.close?.();
2991
3294
  } catch {}
2992
3295
  }
2993
- })();
3296
+ })(), () => {
3297
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
3298
+ });
2994
3299
  }
2995
3300
  /**
2996
3301
  * Listens for the peer's abort notice, and can post our own. The notice is a
@@ -3191,6 +3496,33 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
3191
3496
  }
3192
3497
  }
3193
3498
  /**
3499
+ * Runs `fn` on a later task, never a microtask: a producer looping on
3500
+ * `whenPortReady` must let the transport's own events run. `setImmediate`
3501
+ * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
3502
+ * are clamped to 4 ms in browsers.
3503
+ */
3504
+ const setImmediateFn = globalThis.setImmediate;
3505
+ /** @internal */
3506
+ const nextTask = setImmediateFn ? (fn) => {
3507
+ setImmediateFn(fn);
3508
+ } : (() => {
3509
+ let channel;
3510
+ const queue = [];
3511
+ return (fn) => {
3512
+ if (!channel) {
3513
+ channel = new MessageChannel();
3514
+ channel.port1.onmessage = () => queue.shift()?.();
3515
+ }
3516
+ queue.push(fn);
3517
+ channel.port2.postMessage(0);
3518
+ };
3519
+ })();
3520
+ const states = /* @__PURE__ */ new WeakMap();
3521
+ /** @internal Registered by the multiplexer for each port with a window. */
3522
+ function setPortSendState(port, state) {
3523
+ states.set(port, state);
3524
+ }
3525
+ /**
3194
3526
  * One virtual port.
3195
3527
  *
3196
3528
  * `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
@@ -3205,6 +3537,14 @@ function newVirtualPort(send, requestClose) {
3205
3537
  if (closing.signal.aborted) return;
3206
3538
  closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
3207
3539
  };
3540
+ const close = (reason) => {
3541
+ if (closed) return;
3542
+ closed = true;
3543
+ const notify = requestClose;
3544
+ listeners.clear();
3545
+ notify(reason);
3546
+ abort(reason);
3547
+ };
3208
3548
  const port = {
3209
3549
  addEventListener(_type, listener) {
3210
3550
  listeners.add(listener);
@@ -3216,24 +3556,28 @@ function newVirtualPort(send, requestClose) {
3216
3556
  if (closed) return;
3217
3557
  send(message, transfer);
3218
3558
  },
3219
- close() {
3220
- if (closed) return;
3221
- closed = true;
3222
- const notify = requestClose;
3223
- listeners.clear();
3224
- notify();
3225
- abort();
3226
- }
3559
+ close
3227
3560
  };
3228
3561
  setPortCloseSignal(port, closing.signal);
3562
+ const dispose = Symbol.dispose;
3563
+ if (dispose) Object.defineProperty(port, dispose, {
3564
+ value: () => close(),
3565
+ configurable: true
3566
+ });
3229
3567
  return {
3230
3568
  port,
3231
3569
  deliver(payload) {
3232
3570
  if (closed) return;
3233
3571
  const event = new MessageEvent("message", { data: payload });
3572
+ let pending;
3234
3573
  for (const listener of [...listeners]) try {
3235
- listener(event);
3574
+ const result = listener(event);
3575
+ if (result && typeof result.then === "function") {
3576
+ pending ??= [];
3577
+ pending.push(result);
3578
+ }
3236
3579
  } catch {}
3580
+ return pending ? Promise.allSettled(pending) : void 0;
3237
3581
  },
3238
3582
  markClosed(reason) {
3239
3583
  closed = true;
@@ -3242,53 +3586,309 @@ function newVirtualPort(send, requestClose) {
3242
3586
  },
3243
3587
  isClosed() {
3244
3588
  return closed;
3589
+ },
3590
+ hasListeners() {
3591
+ return listeners.size > 0;
3245
3592
  }
3246
3593
  };
3247
3594
  }
3248
3595
  /**
3249
- * The message of the error that fails the calls still in flight on a
3250
- * multiplexer when it is closed (`PortMux.close()`).
3596
+ * The id of keepalive's `ping` / `pong`: one no port ever takes. An older mux
3597
+ * whose codec passes unknown envelopes through reads them as a close of that
3598
+ * id, which must therefore not be a real port (0 is the initiator's first).
3251
3599
  */
3252
- const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
3600
+ const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
3601
+ /** The longest delay a timer holds; past it `setInterval` fires at once. */
3602
+ const MAX_TIMER_MS = 2 ** 31 - 1;
3253
3603
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
3254
3604
  const DEFAULT_MAX_PORTS = 1024;
3255
3605
  /**
3606
+ * What a message costs a port's window when the codec cannot say
3607
+ * (`PortCodec.encode`): its bytes and string lengths, found in its values up
3608
+ * to 4 levels deep (at most 256 values), and at least 64. Both ends compute it
3609
+ * from the same structured value, so they agree.
3610
+ */
3611
+ function estimateMessageSize(payload) {
3612
+ let budget = 256;
3613
+ const walk = (v, depth) => {
3614
+ if (budget-- <= 0) return 0;
3615
+ if (typeof v === "string") return v.length;
3616
+ if (v instanceof ArrayBuffer) return v.byteLength;
3617
+ if (ArrayBuffer.isView(v)) return v.byteLength;
3618
+ if (v === null || typeof v !== "object" || depth >= 4) return 8;
3619
+ let n = 16;
3620
+ if (Array.isArray(v)) for (const x of v) n += walk(x, depth + 1);
3621
+ else for (const k in v) n += k.length + walk(v[k], depth + 1);
3622
+ return n;
3623
+ };
3624
+ return Math.max(64, walk(payload, 0));
3625
+ }
3626
+ /**
3256
3627
  * The default `PortMux`: emulates multiplexing over a single port.
3257
3628
  *
3258
3629
  * A transport that already multiplexes natively supplies its own `PortMux`
3259
3630
  * instead — this implementation is for transports that offer one pipe.
3260
3631
  */
3261
3632
  function multiplexPort(port, options) {
3262
- const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
3633
+ const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
3634
+ const misses = keepalive?.misses ?? 3;
3635
+ 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`);
3636
+ if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
3263
3637
  const open = /* @__PURE__ */ new Map();
3638
+ const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
3639
+ const entry = open.get(id);
3640
+ if (entry?.handle !== record) return;
3641
+ const { handle: _, credit: __, partial: ___, ...info } = entry;
3642
+ if (!record.hasListeners()) {
3643
+ release("leaked");
3644
+ record.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was dropped without close()"));
3645
+ }
3646
+ try {
3647
+ onLeak?.(info);
3648
+ } catch {}
3649
+ }) : void 0;
3650
+ const touch = (id) => {
3651
+ const entry = open.get(id);
3652
+ if (entry) entry.lastActivity = Date.now();
3653
+ };
3264
3654
  let nextId = side === "initiator" ? 0 : 1;
3265
3655
  let muxClosed = false;
3656
+ /** This end counts encoded bytes and reassembles frames. */
3657
+ const exactCodec = codec.encode != null && codec.decode != null;
3266
3658
  const post = (envelope, transfer) => {
3267
3659
  if (muxClosed) return;
3660
+ codecPost(envelope, transfer);
3661
+ };
3662
+ /** Like `post`, also while closing: the mux's own close notices. */
3663
+ const codecPost = (envelope, transfer) => {
3268
3664
  try {
3269
3665
  codec.post(port, envelope, transfer);
3270
3666
  } catch {}
3271
3667
  };
3272
- const attach = (id) => {
3273
- let self;
3274
- self = newVirtualPort((payload, transfer) => post({
3275
- type: "message",
3276
- id,
3277
- payload
3278
- }, transfer), (reason) => {
3668
+ const outgoing = /* @__PURE__ */ new Map();
3669
+ const ring = [];
3670
+ let pumping = false;
3671
+ const pump = async () => {
3672
+ pumping = true;
3673
+ try {
3674
+ while (ring.length && !muxClosed) {
3675
+ if (port.whenDrained) await port.whenDrained();
3676
+ if (muxClosed) break;
3677
+ const id = ring.shift();
3678
+ const queue = outgoing.get(id);
3679
+ const item = queue?.shift();
3680
+ if (queue?.length) ring.push(id);
3681
+ else outgoing.delete(id);
3682
+ item?.();
3683
+ }
3684
+ } catch (err) {
3685
+ close(err).catch(() => {});
3686
+ } finally {
3687
+ pumping = false;
3688
+ }
3689
+ };
3690
+ const sendOnPort = (id, item) => {
3691
+ if (frameSize == null) {
3692
+ item();
3693
+ return;
3694
+ }
3695
+ let queue = outgoing.get(id);
3696
+ if (!queue) {
3697
+ queue = [];
3698
+ outgoing.set(id, queue);
3699
+ ring.push(id);
3700
+ }
3701
+ queue.push(item);
3702
+ if (!pumping) pump();
3703
+ };
3704
+ const dropOutgoing = (id) => {
3705
+ if (!outgoing.delete(id)) return;
3706
+ const at = ring.indexOf(id);
3707
+ if (at >= 0) ring.splice(at, 1);
3708
+ };
3709
+ const postBytes = (bytes) => {
3710
+ if (muxClosed) return;
3711
+ try {
3712
+ port.postMessage(bytes);
3713
+ } catch {}
3714
+ };
3715
+ const releaseWaiters = (c) => {
3716
+ for (const w of c?.waiters.splice(0) ?? []) w.resolve();
3717
+ };
3718
+ const flush = (c) => {
3719
+ while (c.queue.length) {
3720
+ const next = c.queue[0];
3721
+ if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
3722
+ c.queue.shift();
3723
+ c.queued -= next.size;
3724
+ c.outstanding += next.size;
3725
+ next.send();
3726
+ }
3727
+ for (const w of [...c.waiters]) if (c.queued <= w.below) {
3728
+ c.waiters.splice(c.waiters.indexOf(w), 1);
3729
+ w.resolve();
3730
+ }
3731
+ };
3732
+ const grant = (id, c, bytes) => {
3733
+ c.unconsumed -= bytes;
3734
+ c.toGrant += bytes;
3735
+ const send = () => {
3736
+ c.grantScheduled = false;
3737
+ if (!c.toGrant || !open.has(id)) return;
3279
3738
  post({
3739
+ type: "credit",
3740
+ id,
3741
+ bytes: c.toGrant
3742
+ });
3743
+ c.toGrant = 0;
3744
+ };
3745
+ if (c.toGrant >= c.window / 4) {
3746
+ send();
3747
+ return;
3748
+ }
3749
+ if (c.grantScheduled) return;
3750
+ c.grantScheduled = true;
3751
+ setTimeout(send, 0);
3752
+ };
3753
+ const attach = (id, meta, portWindow, opener, exact) => {
3754
+ const c = typeof portWindow === "number" && Number.isSafeInteger(portWindow) && portWindow > 0 ? {
3755
+ window: portWindow,
3756
+ enforced: false,
3757
+ opener,
3758
+ exact,
3759
+ outstanding: 0,
3760
+ queue: [],
3761
+ queued: 0,
3762
+ unconsumed: 0,
3763
+ waiters: [],
3764
+ toGrant: 0,
3765
+ grantScheduled: false
3766
+ } : void 0;
3767
+ const self = newVirtualPort((payload, transfer) => {
3768
+ touch(id);
3769
+ const envelope = {
3770
+ type: "message",
3771
+ id,
3772
+ payload
3773
+ };
3774
+ if (!c) {
3775
+ post(envelope, transfer);
3776
+ return;
3777
+ }
3778
+ const bytes = c.exact ? codec.encode?.(envelope) : void 0;
3779
+ const size = bytes?.byteLength ?? estimateMessageSize(payload);
3780
+ const waits = frameSize != null || c.queue.length > 0 || c.enforced && c.outstanding > 0 && c.outstanding + size > c.window;
3781
+ const held = !bytes && waits ? {
3782
+ ...envelope,
3783
+ payload: structuredClone(payload, { transfer })
3784
+ } : void 0;
3785
+ const send = () => {
3786
+ if (!bytes) {
3787
+ sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
3788
+ return;
3789
+ }
3790
+ if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
3791
+ for (let at = 0; at < bytes.byteLength; at += frameSize) {
3792
+ const data = bytes.subarray(at, at + frameSize);
3793
+ const frame = at + frameSize >= bytes.byteLength ? {
3794
+ type: "frame",
3795
+ id,
3796
+ data,
3797
+ last: true
3798
+ } : {
3799
+ type: "frame",
3800
+ id,
3801
+ data
3802
+ };
3803
+ sendOnPort(id, () => post(frame));
3804
+ }
3805
+ return;
3806
+ }
3807
+ sendOnPort(id, () => postBytes(bytes));
3808
+ };
3809
+ c.queue.push({
3810
+ send,
3811
+ size
3812
+ });
3813
+ c.queued += size;
3814
+ flush(c);
3815
+ }, (reason) => {
3816
+ release(reason);
3817
+ record.markClosed(reason);
3818
+ });
3819
+ function release(reason) {
3820
+ open.delete(id);
3821
+ leaks?.unregister(record);
3822
+ if (c?.queue.length) {
3823
+ for (const item of c.queue.splice(0)) item.send();
3824
+ c.queued = 0;
3825
+ }
3826
+ sendOnPort(id, () => post({
3280
3827
  type: "close",
3281
3828
  id,
3282
3829
  reason
3830
+ }));
3831
+ releaseWaiters(c);
3832
+ }
3833
+ const { markClosed, isClosed, hasListeners } = self;
3834
+ if (c) {
3835
+ const deliver = self.deliver;
3836
+ self.deliver = (payload, size = estimateMessageSize(payload)) => {
3837
+ c.unconsumed += size;
3838
+ const done = deliver(payload);
3839
+ if (done) done.then(() => grant(id, c, size));
3840
+ else grant(id, c, size);
3841
+ return done;
3842
+ };
3843
+ setPortSendState(self.port, {
3844
+ bufferedAmount: () => c.queued,
3845
+ ready: (below = 0) => new Promise((resolve) => {
3846
+ if (c.queued <= below || isClosed()) nextTask(resolve);
3847
+ else c.waiters.push({
3848
+ below,
3849
+ resolve: () => nextTask(resolve)
3850
+ });
3851
+ })
3283
3852
  });
3284
- open.delete(id);
3285
- self.markClosed();
3853
+ }
3854
+ const record = {
3855
+ deliver: self.deliver,
3856
+ markClosed,
3857
+ isClosed,
3858
+ hasListeners,
3859
+ close: (reason) => {
3860
+ if (isClosed()) return;
3861
+ release(reason);
3862
+ markClosed(reason);
3863
+ }
3864
+ };
3865
+ leaks?.register(self.port, {
3866
+ id,
3867
+ record,
3868
+ release
3869
+ }, record);
3870
+ const now = Date.now();
3871
+ open.set(id, {
3872
+ id,
3873
+ meta,
3874
+ openedAt: now,
3875
+ lastActivity: now,
3876
+ handle: record,
3877
+ credit: c
3286
3878
  });
3287
- open.set(id, self);
3288
3879
  return self;
3289
3880
  };
3290
- const handleEnvelope = (envelope) => {
3881
+ const handleEnvelope = (envelope, event) => {
3291
3882
  if (muxClosed) return;
3883
+ heardSincePing = true;
3884
+ if (envelope.type === "ping" || envelope.type === "pong") {
3885
+ peerKeepsAlive = true;
3886
+ if (envelope.type === "ping") post({
3887
+ type: "pong",
3888
+ id: KEEPALIVE_ID
3889
+ });
3890
+ return;
3891
+ }
3292
3892
  if (envelope.type === "open") {
3293
3893
  if (open.has(envelope.id)) return;
3294
3894
  if (open.size >= maxPorts) {
@@ -3299,7 +3899,18 @@ function multiplexPort(port, options) {
3299
3899
  });
3300
3900
  return;
3301
3901
  }
3302
- const handle = attach(envelope.id);
3902
+ const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
3903
+ const credit = open.get(envelope.id)?.credit;
3904
+ if (credit) post(credit.exact ? {
3905
+ type: "credit",
3906
+ id: envelope.id,
3907
+ bytes: 0,
3908
+ exact: true
3909
+ } : {
3910
+ type: "credit",
3911
+ id: envelope.id,
3912
+ bytes: 0
3913
+ });
3303
3914
  let accepted = false;
3304
3915
  if (onPort) try {
3305
3916
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -3314,57 +3925,191 @@ function multiplexPort(port, options) {
3314
3925
  id: envelope.id,
3315
3926
  reason: "rejected"
3316
3927
  });
3928
+ return;
3929
+ }
3930
+ return;
3931
+ }
3932
+ const entry = open.get(envelope.id);
3933
+ if (envelope.type === "credit") {
3934
+ const c = entry?.credit;
3935
+ if (!c || !(envelope.bytes >= 0)) return;
3936
+ if (!c.enforced && c.opener) {
3937
+ if (c.exact && envelope.exact !== true) {
3938
+ c.exact = false;
3939
+ c.outstanding = 0;
3940
+ }
3941
+ post({
3942
+ type: "credit",
3943
+ id: envelope.id,
3944
+ bytes: 0
3945
+ });
3317
3946
  }
3947
+ c.enforced = true;
3948
+ c.outstanding = Math.max(0, c.outstanding - envelope.bytes);
3949
+ flush(c);
3318
3950
  return;
3319
3951
  }
3320
- const handle = open.get(envelope.id);
3321
3952
  if (envelope.type === "message") {
3322
- if (!handle) return;
3323
- handle.deliver(envelope.payload);
3953
+ const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
3954
+ if (!entry) return;
3955
+ touch(envelope.id);
3956
+ entry.handle.deliver(envelope.payload, entry.credit ? sizeOf(entry.credit) : void 0);
3324
3957
  return;
3325
3958
  }
3326
- if (!handle) return;
3959
+ if (envelope.type === "frame") {
3960
+ if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
3961
+ touch(envelope.id);
3962
+ const partial = entry.partial ?? {
3963
+ chunks: [],
3964
+ bytes: 0
3965
+ };
3966
+ entry.partial = partial;
3967
+ partial.chunks.push(envelope.data);
3968
+ partial.bytes += envelope.data.byteLength;
3969
+ if (!envelope.last) return;
3970
+ entry.partial = void 0;
3971
+ const whole = new Uint8Array(partial.bytes);
3972
+ let at = 0;
3973
+ for (const chunk of partial.chunks) {
3974
+ whole.set(chunk, at);
3975
+ at += chunk.byteLength;
3976
+ }
3977
+ const inner = codec.decode(whole);
3978
+ if (inner?.type !== "message" || inner.id !== envelope.id) return;
3979
+ entry.handle.deliver(inner.payload, whole.byteLength);
3980
+ return;
3981
+ }
3982
+ dropOutgoing(envelope.id);
3983
+ if (!entry) return;
3327
3984
  open.delete(envelope.id);
3328
- handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
3985
+ releaseWaiters(entry.credit);
3986
+ entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
3329
3987
  };
3330
3988
  const listener = (event) => {
3331
3989
  const envelope = codec.read(event);
3332
- if (envelope) handleEnvelope(envelope);
3990
+ if (envelope) handleEnvelope(envelope, event);
3991
+ };
3992
+ let peerKeepsAlive = false;
3993
+ let heardSincePing = true;
3994
+ let unanswered = 0;
3995
+ let pinger;
3996
+ const tick = () => {
3997
+ if (!keepalive) return;
3998
+ const backlogged = (port.bufferedAmount ?? 0) > 0;
3999
+ if (heardSincePing) unanswered = 0;
4000
+ else if (peerKeepsAlive && !backlogged) unanswered++;
4001
+ if (unanswered >= misses) {
4002
+ close(/* @__PURE__ */ new Error(`webrun-rpc: the peer did not answer ${misses} keepalive pings, ${keepalive.intervalMs} ms apart`)).catch(() => {});
4003
+ return;
4004
+ }
4005
+ heardSincePing = false;
4006
+ post({
4007
+ type: "ping",
4008
+ id: KEEPALIVE_ID
4009
+ });
4010
+ };
4011
+ const close = async (reason) => {
4012
+ if (muxClosed) return;
4013
+ clearInterval(pinger);
4014
+ muxClosed = true;
4015
+ signal?.removeEventListener("abort", onTransportAbort);
4016
+ port.removeEventListener("message", listener);
4017
+ const closedNow = /* @__PURE__ */ new Set();
4018
+ for (const [id, { handle, credit }] of [...open]) {
4019
+ closedNow.add(id);
4020
+ codecPost({
4021
+ type: "close",
4022
+ id
4023
+ });
4024
+ open.delete(id);
4025
+ releaseWaiters(credit);
4026
+ handle.markClosed(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the multiplexer was closed"));
4027
+ }
4028
+ for (const id of outgoing.keys()) if (!closedNow.has(id)) codecPost({
4029
+ type: "close",
4030
+ id
4031
+ });
4032
+ outgoing.clear();
4033
+ ring.length = 0;
4034
+ await port.close?.();
4035
+ };
4036
+ const onTransportAbort = () => {
4037
+ close(signal?.reason).catch(() => {});
3333
4038
  };
3334
4039
  port.addEventListener("message", listener);
3335
4040
  port.start?.();
4041
+ if (keepalive) {
4042
+ pinger = setInterval(tick, keepalive.intervalMs);
4043
+ pinger.unref?.();
4044
+ }
4045
+ if (signal?.aborted) close(signal.reason).catch(() => {});
4046
+ else signal?.addEventListener("abort", onTransportAbort, { once: true });
3336
4047
  return {
3337
4048
  maxMessageSize,
3338
- async openPort(meta) {
4049
+ async openPort(meta, { signal: portSignal } = {}) {
3339
4050
  if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
3340
- if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
4051
+ if (portSignal?.aborted) throw portSignal.reason;
4052
+ if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached; open ports by kind: ${describeKinds(open.values())}`);
3341
4053
  while (open.has(nextId)) nextId += 2;
3342
4054
  const id = nextId;
3343
4055
  nextId += 2;
3344
- const handle = attach(id);
3345
- post({
4056
+ const handle = attach(id, meta, window, true, exactCodec);
4057
+ post(window ? exactCodec ? {
4058
+ type: "open",
4059
+ id,
4060
+ meta,
4061
+ window,
4062
+ exact: true
4063
+ } : {
4064
+ type: "open",
4065
+ id,
4066
+ meta,
4067
+ window
4068
+ } : {
3346
4069
  type: "open",
3347
4070
  id,
3348
4071
  meta
3349
4072
  });
4073
+ if (portSignal) {
4074
+ const record = open.get(id)?.handle;
4075
+ const onAbort = () => {
4076
+ if (record && open.get(id)?.handle === record) record.close(portSignal.reason);
4077
+ };
4078
+ portSignal.addEventListener("abort", onAbort, { once: true });
4079
+ getPortCloseSignal(handle.port)?.addEventListener("abort", () => portSignal.removeEventListener("abort", onAbort), { once: true });
4080
+ }
3350
4081
  return handle.port;
3351
4082
  },
3352
- async close() {
3353
- if (muxClosed) return;
3354
- for (const [id, handle] of [...open]) {
3355
- post({
3356
- type: "close",
3357
- id
3358
- });
3359
- open.delete(id);
3360
- handle.markClosed(/* @__PURE__ */ new Error(MUX_CLOSED_MESSAGE));
3361
- }
3362
- muxClosed = true;
3363
- port.removeEventListener("message", listener);
3364
- await port.close?.();
4083
+ close,
4084
+ ports() {
4085
+ return [...open.values()].map(({ handle: _, credit, ...info }) => credit ? {
4086
+ ...info,
4087
+ ...credit.enforced ? { window: credit.window } : {},
4088
+ queued: credit.queued,
4089
+ outstanding: credit.outstanding,
4090
+ unconsumed: credit.unconsumed
4091
+ } : info);
3365
4092
  }
3366
4093
  };
3367
4094
  }
4095
+ /** The byte length of a received message's data, or `undefined` if it is not bytes. */
4096
+ function byteLengthOf(data) {
4097
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return data.byteLength;
4098
+ }
4099
+ /**
4100
+ * `"http: 1000, ws: 24"`: the three commonest `meta.kind`s among `entries`, so
4101
+ * a full table names who filled it. A port without a kind counts as `"?"`.
4102
+ */
4103
+ function describeKinds(entries) {
4104
+ const counts = /* @__PURE__ */ new Map();
4105
+ for (const { meta } of entries) {
4106
+ const kind = meta?.kind;
4107
+ const name = typeof kind === "string" ? kind : "?";
4108
+ counts.set(name, (counts.get(name) ?? 0) + 1);
4109
+ }
4110
+ if (!counts.size) return "none";
4111
+ return [...counts].sort((x, y) => y[1] - x[1]).slice(0, 3).map(([name, n]) => `${name}: ${n}`).join(", ");
4112
+ }
3368
4113
  /**
3369
4114
  * Announced with every port this pair opens. Layer 1 never reads `meta` — it is
3370
4115
  * here so a peer running something else over the same multiplexer can tell a
@@ -3386,9 +4131,22 @@ const connect = async ({ mux: factory, timeout }) => {
3386
4131
  maxMessageSize: mux.maxMessageSize,
3387
4132
  timeout
3388
4133
  };
3389
- const call = (input) => (async function* () {
3390
- yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
3391
- })();
4134
+ const call = (input) => {
4135
+ let stream;
4136
+ let abandoned = false;
4137
+ return returningAtOnce((async function* () {
4138
+ const port = await mux.openPort(STREAM_META);
4139
+ if (abandoned) {
4140
+ await port.close?.();
4141
+ return;
4142
+ }
4143
+ stream = duplexOverPort(port, streamOptions)(input);
4144
+ yield* stream;
4145
+ })(), () => {
4146
+ abandoned = true;
4147
+ stream?.return(void 0).catch(() => {});
4148
+ });
4149
+ };
3392
4150
  return {
3393
4151
  call,
3394
4152
  async close() {
@@ -3458,7 +4216,7 @@ function isEnvelope(value) {
3458
4216
  const candidate = value;
3459
4217
  if (typeof candidate.id !== "number") return false;
3460
4218
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
3461
- return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
4219
+ return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
3462
4220
  }
3463
4221
  /**
3464
4222
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -3479,6 +4237,27 @@ const structuredCodec = {
3479
4237
  }
3480
4238
  };
3481
4239
  //#endregion
4240
+ //#region src/link/async-dispose.ts
4241
+ /**
4242
+ * Makes `target` disposable with `await using`: installs `dispose` as its
4243
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
4244
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
4245
+ * this gives it back. A no-op where the symbol is missing. Kept off the
4246
+ * declared types, which would require consumers to compile with the
4247
+ * `esnext.disposable` lib.
4248
+ */
4249
+ function asyncDisposable(target, dispose) {
4250
+ const asyncDispose = Symbol.asyncDispose;
4251
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
4252
+ value: async () => {
4253
+ await dispose();
4254
+ },
4255
+ configurable: true,
4256
+ writable: true
4257
+ });
4258
+ return target;
4259
+ }
4260
+ //#endregion
3482
4261
  //#region src/link/http-link.ts
3483
4262
  /**
3484
4263
  * The version of the HTTP-over-`MessagePort` protocol this package speaks,
@@ -3508,11 +4287,12 @@ var LinkLostError = class extends Error {
3508
4287
  */
3509
4288
  const LINK_CLOSE = "webrun-http:link-close";
3510
4289
  /** Opens the calling end of a link on `port` (one port per link). */
3511
- async function connectHttpLink(port, { streamTimeout } = {}) {
4290
+ async function connectHttpLink(port, { streamTimeout, maxPorts } = {}) {
3512
4291
  const { call, close } = await connect({
3513
4292
  mux: overPipe(port, {
3514
4293
  codec: structuredCodec,
3515
- side: "initiator"
4294
+ side: "initiator",
4295
+ maxPorts
3516
4296
  }),
3517
4297
  timeout: streamTimeout
3518
4298
  });
@@ -3528,14 +4308,15 @@ async function connectHttpLink(port, { streamTimeout } = {}) {
3528
4308
  * that abandons the requests still running (their handlers see the
3529
4309
  * cancellation) and releases the port.
3530
4310
  */
3531
- async function serveHttpLink(port, handler, { streamTimeout } = {}) {
4311
+ async function serveHttpLink(port, handler, { streamTimeout, maxPorts, requestIdleMs } = {}) {
3532
4312
  const teardown = await serve({
3533
4313
  mux: overPipe(port, {
3534
4314
  codec: structuredCodec,
3535
- side: "responder"
4315
+ side: "responder",
4316
+ maxPorts
3536
4317
  }),
3537
4318
  timeout: streamTimeout
3538
- }, serveFetchOverDuplex(async (request) => await handler(request)));
4319
+ }, serveFetchOverDuplex(async (request) => await handler(request), { requestIdleMs }));
3539
4320
  const onClose = (event) => {
3540
4321
  if (event.data?.type === LINK_CLOSE) close();
3541
4322
  };
@@ -3560,7 +4341,7 @@ function trackedLink(send, release) {
3560
4341
  inflight--;
3561
4342
  if (inflight === 0) for (const resolve of idleWaiters.splice(0)) resolve();
3562
4343
  };
3563
- return {
4344
+ const link = {
3564
4345
  get inflight() {
3565
4346
  return inflight;
3566
4347
  },
@@ -3597,7 +4378,7 @@ function trackedLink(send, release) {
3597
4378
  finish();
3598
4379
  return response;
3599
4380
  }
3600
- return new Response(guardBody(response.body, lost, () => lostReason, finish), {
4381
+ return new Response(guardBody(response.body, lost, () => lostReason, finish, request.signal), {
3601
4382
  status: response.status,
3602
4383
  statusText: response.statusText,
3603
4384
  headers: response.headers
@@ -3613,35 +4394,68 @@ function trackedLink(send, release) {
3613
4394
  } catch {}
3614
4395
  }
3615
4396
  };
4397
+ return asyncDisposable(link, () => link.close());
3616
4398
  }
3617
4399
  /**
3618
- * A response body that reports its end (done, cancelled or failed) and fails
3619
- * with the link's reason when the link is lost mid-stream.
4400
+ * A response body that reports its end (done, cancelled, failed or aborted) and
4401
+ * fails with the link's reason when the link is lost mid-stream. An abort of the
4402
+ * request's signal ends it at once, read or not: the exchange is over (the layer
4403
+ * below has given its port back), so the request leaves `inflight` then.
3620
4404
  */
3621
- function guardBody(body, lost, reason, finish) {
4405
+ function guardBody(body, lost, reason, finish, signal) {
3622
4406
  const reader = body.getReader();
4407
+ let controller;
4408
+ let ended = false;
4409
+ const end = () => {
4410
+ ended = true;
4411
+ signal?.removeEventListener("abort", onAbort);
4412
+ finish();
4413
+ };
4414
+ const onAbort = () => {
4415
+ if (ended) return;
4416
+ end();
4417
+ reader.cancel().catch(() => {});
4418
+ try {
4419
+ controller.error(abortError(signal));
4420
+ } catch {}
4421
+ };
3623
4422
  return new ReadableStream({
4423
+ start(c) {
4424
+ controller = c;
4425
+ if (signal?.aborted) onAbort();
4426
+ else signal?.addEventListener("abort", onAbort, { once: true });
4427
+ },
3624
4428
  async pull(controller) {
3625
4429
  try {
3626
4430
  const { done, value } = await Promise.race([reader.read(), lost]);
4431
+ if (ended) return;
3627
4432
  if (done) {
3628
- finish();
4433
+ end();
3629
4434
  controller.close();
3630
4435
  return;
3631
4436
  }
3632
4437
  controller.enqueue(value);
3633
4438
  } catch (error) {
3634
- finish();
4439
+ if (ended) return;
4440
+ end();
3635
4441
  reader.cancel().catch(() => {});
3636
4442
  controller.error(reason() ?? error);
3637
4443
  }
3638
4444
  },
3639
4445
  async cancel(why) {
3640
- finish();
4446
+ end();
3641
4447
  await reader.cancel(why).catch(() => {});
3642
4448
  }
3643
4449
  });
3644
4450
  }
4451
+ /** The signal's reason as an `Error` (`AbortError` when it gave none). */
4452
+ function abortError(signal) {
4453
+ const why = signal.reason;
4454
+ if (why instanceof Error) return why;
4455
+ const err = new Error(why === void 0 ? "Aborted" : String(why));
4456
+ err.name = "AbortError";
4457
+ return err;
4458
+ }
3645
4459
  //#endregion
3646
4460
  //#region src/link/locks.ts
3647
4461
  function lockManager() {
@@ -6362,6 +7176,8 @@ var WorkerLinkImpl = class {
6362
7176
  _ownerLock;
6363
7177
  _attaching;
6364
7178
  _detached = false;
7179
+ /** The `seq` of the last link sent to the worker. */
7180
+ _sent = 0;
6365
7181
  constructor(_adapter, _control, init) {
6366
7182
  this._adapter = _adapter;
6367
7183
  this._control = _control;
@@ -6373,6 +7189,7 @@ var WorkerLinkImpl = class {
6373
7189
  _control.onmessage = (event) => {
6374
7190
  const message = event.data;
6375
7191
  if (message?.type === "relink") {
7192
+ if (message.seen !== void 0 && message.seen < this._sent) return;
6376
7193
  this._adapter._poke(WAKE_MESSAGE);
6377
7194
  this.attach().catch(() => {});
6378
7195
  } else if (message?.type === "keepalive") this._adapter._poke(KEEPALIVE_MESSAGE);
@@ -6419,7 +7236,8 @@ var WorkerLinkImpl = class {
6419
7236
  this._control.postMessage({
6420
7237
  type: "link",
6421
7238
  key: this.key,
6422
- routerLock: reply.routerLock
7239
+ routerLock: reply.routerLock,
7240
+ seq: ++this._sent
6423
7241
  }, [
6424
7242
  down.port2,
6425
7243
  ctl.port2,
@@ -6520,12 +7338,18 @@ function serveHttpInWorker(handler, options = {}) {
6520
7338
  const ownerLock = newLockName("webrun-http-owner");
6521
7339
  const held = holdLock(ownerLock);
6522
7340
  const controls = /* @__PURE__ */ new Set();
7341
+ const seen = /* @__PURE__ */ new Map();
6523
7342
  const tellPages = (message) => {
6524
7343
  for (const control of controls) control.postMessage(message);
6525
7344
  };
6526
7345
  const endpoint = new HandlerEndpoint(handler, {
6527
7346
  ...options,
6528
- requestRelink: () => tellPages({ type: "relink" }),
7347
+ requestRelink: () => {
7348
+ for (const control of controls) control.postMessage({
7349
+ type: "relink",
7350
+ seen: seen.get(control) ?? 0
7351
+ });
7352
+ },
6529
7353
  keepAlive: () => tellPages({ type: "keepalive" })
6530
7354
  });
6531
7355
  const onMessage = (event) => {
@@ -6537,6 +7361,7 @@ function serveHttpInWorker(handler, options = {}) {
6537
7361
  control.onmessage = (message) => {
6538
7362
  const msg = message.data;
6539
7363
  if (msg?.type === "link") {
7364
+ if (msg.seq !== void 0) seen.set(control, msg.seq);
6540
7365
  const [down, ctl, up] = message.ports;
6541
7366
  if (down) endpoint.link(msg.key, {
6542
7367
  down,
@@ -6577,6 +7402,7 @@ function serveHttpInWorker(handler, options = {}) {
6577
7402
  await endpoint.close();
6578
7403
  for (const control of controls) control.close();
6579
7404
  controls.clear();
7405
+ seen.clear();
6580
7406
  (await held)();
6581
7407
  }
6582
7408
  };