@statewalker/webrun-http-browser 0.10.0 → 0.12.2

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.
Files changed (62) hide show
  1. package/README.md +155 -10
  2. package/dist/core/index.d.ts +1 -0
  3. package/dist/core/index.d.ts.map +1 -1
  4. package/dist/core/service-worker-control.d.ts +2 -1
  5. package/dist/core/service-worker-control.d.ts.map +1 -1
  6. package/dist/core/sw-scope.d.ts +31 -0
  7. package/dist/core/sw-scope.d.ts.map +1 -0
  8. package/dist/index.js +834 -225
  9. package/dist/link/http-link.d.ts +16 -3
  10. package/dist/link/http-link.d.ts.map +1 -1
  11. package/dist/relay/index-sw.d.ts +8 -2
  12. package/dist/relay/index-sw.d.ts.map +1 -1
  13. package/dist/relay/index.d.ts +1 -1
  14. package/dist/relay/index.d.ts.map +1 -1
  15. package/dist/relay-sw.js +433 -83
  16. package/dist/relay-worker.d.ts +1 -0
  17. package/dist/relay-worker.d.ts.map +1 -1
  18. package/dist/relay-worker.js +433 -83
  19. package/dist/sw/handler-endpoint.d.ts +5 -2
  20. package/dist/sw/handler-endpoint.d.ts.map +1 -1
  21. package/dist/sw/http-sw-dispatcher.d.ts +114 -7
  22. package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
  23. package/dist/sw/index.d.ts +1 -0
  24. package/dist/sw/index.d.ts.map +1 -1
  25. package/dist/sw/protocol.d.ts +3 -0
  26. package/dist/sw/protocol.d.ts.map +1 -1
  27. package/dist/sw/sw-dispatcher.d.ts +5 -2
  28. package/dist/sw/sw-dispatcher.d.ts.map +1 -1
  29. package/dist/sw/sw-http-adapter.d.ts +19 -4
  30. package/dist/sw/sw-http-adapter.d.ts.map +1 -1
  31. package/dist/sw/worker-service.d.ts +5 -2
  32. package/dist/sw/worker-service.d.ts.map +1 -1
  33. package/dist/sw-worker.js +596 -132
  34. package/dist/sw.js +829 -223
  35. package/dist/worker.js +622 -120
  36. package/dist/ws/frames.d.ts +6 -1
  37. package/dist/ws/frames.d.ts.map +1 -1
  38. package/dist/ws/server.d.ts +50 -2
  39. package/dist/ws/server.d.ts.map +1 -1
  40. package/dist/ws/socket-core.d.ts +5 -2
  41. package/dist/ws/socket-core.d.ts.map +1 -1
  42. package/dist/ws/upgrade.d.ts +12 -1
  43. package/dist/ws/upgrade.d.ts.map +1 -1
  44. package/package.json +5 -4
  45. package/src/core/index.ts +1 -0
  46. package/src/core/service-worker-control.ts +5 -3
  47. package/src/core/sw-scope.ts +27 -0
  48. package/src/link/http-link.ts +20 -4
  49. package/src/relay/index-sw.ts +15 -2
  50. package/src/relay/index.ts +2 -2
  51. package/src/relay-worker.ts +1 -0
  52. package/src/sw/handler-endpoint.ts +17 -4
  53. package/src/sw/http-sw-dispatcher.ts +267 -67
  54. package/src/sw/index.ts +5 -0
  55. package/src/sw/protocol.ts +11 -1
  56. package/src/sw/sw-dispatcher.ts +17 -11
  57. package/src/sw/sw-http-adapter.ts +36 -10
  58. package/src/sw/worker-service.ts +11 -4
  59. package/src/ws/frames.ts +18 -12
  60. package/src/ws/server.ts +166 -11
  61. package/src/ws/socket-core.ts +7 -2
  62. package/src/ws/upgrade.ts +22 -4
package/dist/sw-worker.js CHANGED
@@ -428,8 +428,9 @@
428
428
  * Returns a function that stops answering.
429
429
  */
430
430
  function handleClaimRequests(self) {
431
- return handleChannelCalls(self, CLAIM_CALL, (event) => {
432
- const claimed = self.clients.claim().then(() => true);
431
+ const sw = self;
432
+ return handleChannelCalls(sw, CLAIM_CALL, (event) => {
433
+ const claimed = sw.clients.claim().then(() => true);
433
434
  event.waitUntil?.(claimed);
434
435
  return claimed;
435
436
  });
@@ -2251,6 +2252,99 @@
2251
2252
  }
2252
2253
  }
2253
2254
  /**
2255
+ * Runs `fn` on a later task, never a microtask: a producer looping on
2256
+ * `whenPortReady` must let the transport's own events run. `setImmediate`
2257
+ * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
2258
+ * are clamped to 4 ms in browsers.
2259
+ */
2260
+ const setImmediateFn = globalThis.setImmediate;
2261
+ /** @internal */
2262
+ const nextTask = setImmediateFn ? (fn) => {
2263
+ setImmediateFn(fn);
2264
+ } : (() => {
2265
+ let channel;
2266
+ const queue = [];
2267
+ return (fn) => {
2268
+ if (!channel) {
2269
+ channel = new MessageChannel();
2270
+ channel.port1.onmessage = () => queue.shift()?.();
2271
+ }
2272
+ queue.push(fn);
2273
+ channel.port2.postMessage(0);
2274
+ };
2275
+ })();
2276
+ const states = /* @__PURE__ */ new WeakMap();
2277
+ /** @internal Registered by the multiplexer for each port with a window. */
2278
+ function setPortSendState(port, state) {
2279
+ states.set(port, state);
2280
+ }
2281
+ /**
2282
+ * Bytes queued on `port` because its window is spent (0 for a port without
2283
+ * flow control). A sender that should not queue without bound waits for
2284
+ * `whenPortReady` before posting more.
2285
+ */
2286
+ function portBufferedAmount(port) {
2287
+ return states.get(port)?.bufferedAmount() ?? 0;
2288
+ }
2289
+ /**
2290
+ * Resolves once at most `below` bytes are queued on `port`, always on a later
2291
+ * task (for a port without flow control, on the next one).
2292
+ */
2293
+ function whenPortReady(port, below = 0) {
2294
+ return states.get(port)?.ready(below) ?? new Promise((resolve) => nextTask(resolve));
2295
+ }
2296
+ const agreements = /* @__PURE__ */ new WeakMap();
2297
+ /** @internal Registered by the multiplexer for each port with a window. */
2298
+ function setPortAgreement(port, agreement) {
2299
+ agreements.set(port, agreement);
2300
+ }
2301
+ /**
2302
+ * @internal What has been agreed with the peer on `port` so far; `undefined`
2303
+ * on a port without a window. Not exported from the package.
2304
+ */
2305
+ function portAgreement(port) {
2306
+ return agreements.get(port)?.();
2307
+ }
2308
+ const ends = /* @__PURE__ */ new WeakMap();
2309
+ /** @internal Registered by the multiplexer for each port with a window. */
2310
+ function setPortEndState(port, state) {
2311
+ ends.set(port, state);
2312
+ }
2313
+ /**
2314
+ * @internal End this side's direction of `port`: an `end` is queued behind
2315
+ * everything already posted on it, and anything posted afterwards is dropped.
2316
+ * `false`, and nothing changes, when `end` was not agreed (no window, the
2317
+ * handshake not answered yet, or a peer that does not speak it): the caller
2318
+ * then closes the port. Never waits on the peer. Not exported from the package.
2319
+ */
2320
+ function endPort(port) {
2321
+ return ends.get(port)?.end() ?? false;
2322
+ }
2323
+ /**
2324
+ * @internal Resolves once the peer has ended its direction of `port`. It never
2325
+ * settles otherwise: not on a port where `end` is not agreed when it is asked
2326
+ * (no window, the handshake not answered yet, a peer or codec without it), and
2327
+ * not when the port closes first, so a caller races it against the port's
2328
+ * close signal. Not exported from the package.
2329
+ */
2330
+ function whenPortEnded(port) {
2331
+ let waiting = peerEnds.get(port);
2332
+ if (!waiting) {
2333
+ let resolve = () => {};
2334
+ const promise = new Promise((r) => {
2335
+ resolve = r;
2336
+ });
2337
+ if (!ends.get(port)?.watchPeerEnd(resolve)) return promise;
2338
+ waiting = {
2339
+ promise,
2340
+ resolve
2341
+ };
2342
+ peerEnds.set(port, waiting);
2343
+ }
2344
+ return waiting.promise;
2345
+ }
2346
+ const peerEnds = /* @__PURE__ */ new WeakMap();
2347
+ /**
2254
2348
  * Makes `target` disposable with `await using`: installs `dispose` as its
2255
2349
  * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
2256
2350
  * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
@@ -2322,6 +2416,61 @@
2322
2416
  * tests and adapters assert on it.
2323
2417
  */
2324
2418
  const STREAM_ABORT = "webrun-rpc:stream-abort";
2419
+ /**
2420
+ * The `type` of a pipelined chunk: a plain port message, not a `callPort`
2421
+ * request, sent only on a port where both ends agreed `pipeline`. The window's
2422
+ * credit is its confirmation.
2423
+ */
2424
+ const CHUNK = "webrun-rpc:chunk";
2425
+ /**
2426
+ * What a pipelined chunk counts beyond its bytes, against the window, on the
2427
+ * receiving side. Both codecs' windows count more for the chunk's envelope
2428
+ * (the estimate at least 64, msgpack's keys alone more than this), so an honest
2429
+ * sender never makes the receiver count past the window; a sender of empty
2430
+ * chunks still cannot have it hold an unbounded number of them.
2431
+ */
2432
+ const CHUNK_OVERHEAD = 32;
2433
+ /**
2434
+ * The bytes of `view` as a plain `Uint8Array` that owns its whole buffer:
2435
+ * `view` itself when it already is one, else a copy. Structured clone carries
2436
+ * and keeps a view's whole `ArrayBuffer`, and a byte codec may decode into
2437
+ * views of the frame, while the window and the receiver's count see only the
2438
+ * view: a 4 KiB `subarray` of a 4 MiB buffer would cost 4 MiB per chunk.
2439
+ * Copied with the `Uint8Array` constructor, not `slice()`: a Node `Buffer`'s
2440
+ * `slice()` is another view on the same memory.
2441
+ */
2442
+ function ownBytes(view) {
2443
+ if (view instanceof Uint8Array && view.constructor === Uint8Array && view.byteOffset === 0 && view.byteLength === view.buffer.byteLength) return view;
2444
+ return new Uint8Array(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
2445
+ }
2446
+ /**
2447
+ * A producer's chunk as bytes this end may send, in either mode: any view or
2448
+ * `ArrayBuffer`, normalised by `ownBytes`. Anything else fails the stream here,
2449
+ * as the producer's own error, rather than reaching a peer that would refuse it.
2450
+ */
2451
+ /**
2452
+ * A received confirmed chunk's value, as its own bytes, put back in place of
2453
+ * the original in `chunk`: `listenPort` keeps the request until the handler
2454
+ * returns, and the original would keep its whole buffer that long. A function
2455
+ * of its own, so that no temporary of the suspended handler holds the
2456
+ * original either.
2457
+ */
2458
+ function adoptValue(chunk) {
2459
+ const original = chunk.value;
2460
+ if (ArrayBuffer.isView(original)) chunk.value = ownBytes(original);
2461
+ return chunk.value;
2462
+ }
2463
+ function chunkBytes(value) {
2464
+ if (ArrayBuffer.isView(value)) return ownBytes(value);
2465
+ if (value instanceof ArrayBuffer || Object.prototype.toString.call(value) === "[object ArrayBuffer]") return new Uint8Array(value);
2466
+ throw new TypeError(`webrun-rpc: a stream chunk must be bytes (a typed array, a DataView or an ArrayBuffer), got ${value === null ? "null" : typeof value}`);
2467
+ }
2468
+ const MIXED_MESSAGE = "webrun-rpc: peer mixed confirmed and pipelined chunks; the stream port is closed";
2469
+ /** This end may pipeline its own chunks: agreed, and the window holds them. */
2470
+ const mayPipeline = (port) => {
2471
+ const agreement = portAgreement(port);
2472
+ return !!agreement?.enforced && agreement.caps.has("pipeline");
2473
+ };
2325
2474
  /** Caller's input travels on this channel; the handler listens on it. */
2326
2475
  const CHANNEL_IN = "in";
2327
2476
  /** The handler's output travels on this channel; the caller listens on it. */
@@ -2334,7 +2483,9 @@
2334
2483
  * same way on the other channel. Within each direction the next chunk is never
2335
2484
  * sent until the previous one has been delivered *and* pulled past by the
2336
2485
  * consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
2337
- * `listenPort` withholds it until then.
2486
+ * `listenPort` withholds it until then. On a port whose window both ends
2487
+ * speak and that agreed `pipeline`, chunks go as plain messages instead, and
2488
+ * the window's credit is the confirmation: up to a window is in flight.
2338
2489
  *
2339
2490
  * A stream port carries exactly one invocation. To make several calls, open
2340
2491
  * several ports: `mux.openPort({ kind: "stream" })` per call.
@@ -2351,22 +2502,49 @@
2351
2502
  const notice = installAbortNotice(port, controller);
2352
2503
  abortOnPortClose(port, controller);
2353
2504
  const clock = installStreamTimeout(controller, options.timeout);
2354
- const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
2505
+ const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch, notice);
2355
2506
  let output;
2356
2507
  try {
2357
2508
  output = handler(inbound.stream);
2358
2509
  } catch (err) {
2359
- const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch);
2510
+ const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch, () => {});
2360
2511
  pump.catch(() => {});
2361
2512
  disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
2362
2513
  return teardownOnce(controller, notice, clock, inbound, void 0);
2363
2514
  }
2364
- const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch);
2515
+ let onSent = () => {};
2516
+ const sent = new Promise((resolve) => {
2517
+ onSent = resolve;
2518
+ });
2519
+ const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch, onSent);
2365
2520
  pump.catch(() => {});
2366
2521
  disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
2522
+ endServedStream(port, sent, inbound.over);
2367
2523
  return teardownOnce(controller, notice, clock, inbound, output);
2368
2524
  }
2369
2525
  /**
2526
+ * The serving side's normal finish. Once the handler's last chunk has gone
2527
+ * without an error, and no more input is coming (`over`: the caller's last
2528
+ * chunk arrived without an error and was read, or the caller ended its
2529
+ * direction behind it, for a handler that did not read all its input), this
2530
+ * side ends its direction too, where `end` was agreed. Without `end` nothing
2531
+ * happens here: the caller closes the port. A stream that never gets that far
2532
+ * was abandoned, and the caller's notice and `close` free the port; this then
2533
+ * never resumes. It needs no race against the port's close signal: nothing
2534
+ * awaits it, and what keeps it waiting is the input's watcher, a listener of
2535
+ * the port that a close drops, after which the wait is collected.
2536
+ *
2537
+ * Nothing this side still posts is lost behind its `end`. The caller ends only
2538
+ * once its own last chunk was confirmed, so no confirmation is owed to it
2539
+ * here, and what the freed port's close signal aborts has settled: the last
2540
+ * chunk has left, and input nobody reads is dropped, as on a close.
2541
+ */
2542
+ async function endServedStream(port, sent, over) {
2543
+ await sent;
2544
+ await over;
2545
+ endPort(port);
2546
+ }
2547
+ /**
2370
2548
  * A stream that completes normally has nobody left to call the returned
2371
2549
  * teardown — the caller only knows the stream ended, not that it must also
2372
2550
  * dispose the serve-side handle. Without this, the last `touch()`'s timer
@@ -2406,22 +2584,31 @@
2406
2584
  const notice = installAbortNotice(port, controller);
2407
2585
  abortOnPortClose(port, controller);
2408
2586
  const clock = installStreamTimeout(controller, options.timeout);
2409
- const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
2410
- const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
2587
+ const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch, notice);
2588
+ let sent = false;
2589
+ const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch, () => {
2590
+ sent = true;
2591
+ });
2411
2592
  pump.catch(() => {});
2412
2593
  return returningAtOnce((async function* () {
2594
+ let received = false;
2413
2595
  try {
2414
2596
  yield* inbound.stream;
2597
+ received = true;
2415
2598
  } finally {
2599
+ const ended = received && sent;
2416
2600
  if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
2417
- notice.post();
2601
+ if (!ended) notice.post();
2418
2602
  notice.stop();
2419
2603
  clock.stop();
2420
2604
  inbound.stop();
2421
2605
  await pump.catch(() => {});
2422
- try {
2423
- await port.close?.();
2424
- } catch {}
2606
+ if (!(ended && endPort(port))) {
2607
+ notice.post();
2608
+ try {
2609
+ await port.close?.();
2610
+ } catch {}
2611
+ }
2425
2612
  }
2426
2613
  })(), () => {
2427
2614
  if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
@@ -2515,7 +2702,7 @@
2515
2702
  * the local consumer has not started iterating, an inbound chunk waits for it —
2516
2703
  * which is the correct backpressure, and different from having no listener.
2517
2704
  */
2518
- function receiveChunks(port, channelName, controller, touch) {
2705
+ function receiveChunks(port, channelName, controller, touch, notice) {
2519
2706
  let deliver;
2520
2707
  const waiting = [];
2521
2708
  let finished = false;
@@ -2525,25 +2712,64 @@
2525
2712
  const ended = new Promise((resolve) => {
2526
2713
  resolveEnded = resolve;
2527
2714
  });
2715
+ let resolveOver = () => {};
2716
+ const over = new Promise((resolve) => {
2717
+ resolveOver = resolve;
2718
+ });
2528
2719
  let aborted = false;
2529
2720
  let abortReason;
2530
2721
  const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
2531
2722
  const wake = () => {
2532
2723
  for (const r of waiting.splice(0)) r();
2533
2724
  };
2534
- const off = listenPort(port, async ({ done, value, error }) => {
2725
+ const violate = (message) => {
2726
+ poison = new Error(message);
2727
+ controller.abort(poison);
2728
+ setTimeout(() => {
2729
+ try {
2730
+ port.close?.();
2731
+ } catch {}
2732
+ }, 0);
2733
+ return poison;
2734
+ };
2735
+ let lastChunkArrived = false;
2736
+ let watching = false;
2737
+ const watchPeerEnd = () => {
2738
+ if (watching) return true;
2739
+ if (!portAgreement(port)?.caps.has("end")) return false;
2740
+ watching = true;
2741
+ whenPortEnded(port).then(() => {
2742
+ port.removeEventListener("message", watcher);
2743
+ if (lastChunkArrived) {
2744
+ resolveOver();
2745
+ return;
2746
+ }
2747
+ notice.post();
2748
+ violate("webrun-rpc: the peer ended its direction without finishing it; the stream port is closed");
2749
+ });
2750
+ return true;
2751
+ };
2752
+ const watcher = (event) => {
2753
+ const data = event.data;
2754
+ if (data?.channelName !== channelName) return;
2755
+ const chunk = data.type === CHUNK ? data.chunk : data.type === "request" ? data.params : void 0;
2756
+ if (!chunk) return;
2757
+ if (!watchPeerEnd()) {
2758
+ port.removeEventListener("message", watcher);
2759
+ return;
2760
+ }
2761
+ if (chunk.done && !chunk.error) lastChunkArrived = true;
2762
+ };
2763
+ port.addEventListener("message", watcher);
2764
+ watchPeerEnd();
2765
+ let held = 0;
2766
+ const off = listenPort(port, async (params) => {
2767
+ const { done, error } = params;
2768
+ const value = adoptValue(params);
2535
2769
  touch();
2536
2770
  if (poison) throw poison;
2537
- if (outstanding) {
2538
- poison = /* @__PURE__ */ new Error("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
2539
- controller.abort(poison);
2540
- setTimeout(() => {
2541
- try {
2542
- port.close?.();
2543
- } catch {}
2544
- }, 0);
2545
- throw poison;
2546
- }
2771
+ if (held > 0) throw violate(MIXED_MESSAGE);
2772
+ if (outstanding) throw violate("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
2547
2773
  outstanding = true;
2548
2774
  try {
2549
2775
  await ready();
@@ -2553,15 +2779,60 @@
2553
2779
  value,
2554
2780
  error: error ? deserializeError(error) : void 0
2555
2781
  });
2782
+ if (done && !error) resolveOver();
2556
2783
  } finally {
2557
2784
  outstanding = false;
2558
2785
  }
2559
2786
  if (done) resolveEnded();
2560
2787
  }, { channelName });
2788
+ const onChunk = (event) => {
2789
+ const data = event.data;
2790
+ if (data?.type !== CHUNK || data.channelName !== channelName || !data.chunk) return;
2791
+ if (poison) return;
2792
+ const agreement = portAgreement(port);
2793
+ if (!agreement?.caps.has("pipeline")) {
2794
+ violate("webrun-rpc: peer sent a pipelined chunk where pipelining was not agreed");
2795
+ return;
2796
+ }
2797
+ if (outstanding) {
2798
+ violate(MIXED_MESSAGE);
2799
+ return;
2800
+ }
2801
+ if (data.chunk.value !== void 0 && !ArrayBuffer.isView(data.chunk.value)) {
2802
+ violate("webrun-rpc: peer sent a pipelined chunk whose value is not bytes");
2803
+ return;
2804
+ }
2805
+ if (held > agreement.window) {
2806
+ violate(`webrun-rpc: peer sent past the window (${agreement.window} bytes) without waiting for credit; the stream port is closed`);
2807
+ return;
2808
+ }
2809
+ touch();
2810
+ const { done, error } = data.chunk;
2811
+ const value = data.chunk.value && ownBytes(data.chunk.value);
2812
+ const size = (value?.byteLength ?? 0) + CHUNK_OVERHEAD;
2813
+ held += size;
2814
+ return (async () => {
2815
+ try {
2816
+ await ready();
2817
+ if (finished) return;
2818
+ await deliver?.({
2819
+ done,
2820
+ value,
2821
+ error: error ? deserializeError(error) : void 0
2822
+ });
2823
+ if (done && !error) resolveOver();
2824
+ if (done) resolveEnded();
2825
+ } finally {
2826
+ held -= size;
2827
+ }
2828
+ })();
2829
+ };
2830
+ port.addEventListener("message", onChunk);
2561
2831
  const detach = () => {
2562
2832
  finished = true;
2563
2833
  wake();
2564
2834
  off();
2835
+ port.removeEventListener("message", onChunk);
2565
2836
  controller.signal.removeEventListener("abort", onAbort);
2566
2837
  resolveEnded();
2567
2838
  };
@@ -2591,25 +2862,35 @@
2591
2862
  return detach;
2592
2863
  }),
2593
2864
  stop: detach,
2594
- ended
2865
+ ended,
2866
+ over
2595
2867
  };
2596
2868
  }
2597
2869
  /**
2598
2870
  * The sending half of one direction: one `callPort` per chunk, one call
2599
- * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
2871
+ * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8) —
2872
+ * or, once pipelining is agreed, plain chunk messages held back by the window.
2873
+ * `onSent` runs once the end of the output has left without an error: posted
2874
+ * when pipelined, confirmed when not.
2600
2875
  */
2601
- async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
2876
+ async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch, onSent) {
2602
2877
  const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
2603
2878
  const stream = throughAbort(framed, signal);
2604
2879
  const producer = output;
2605
2880
  const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
2606
2881
  if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
2882
+ let onAborted = () => {};
2883
+ const aborted = new Promise((resolve) => {
2884
+ onAborted = () => resolve();
2885
+ });
2886
+ if (signal.aborted) onAborted();
2887
+ else signal.addEventListener("abort", onAborted, { once: true });
2607
2888
  try {
2608
2889
  await sendIterator(async ({ done, value, error }) => {
2609
2890
  if (signal.aborted) return;
2610
2891
  const chunk = {
2611
2892
  done,
2612
- value,
2893
+ value: done ? value : chunkBytes(value),
2613
2894
  error: error === void 0 ? void 0 : serializeError(error)
2614
2895
  };
2615
2896
  log?.("[duplexOverPort] send", {
@@ -2617,48 +2898,34 @@
2617
2898
  done,
2618
2899
  size: value?.byteLength
2619
2900
  });
2901
+ if (mayPipeline(port)) {
2902
+ port.postMessage({
2903
+ type: CHUNK,
2904
+ channelName,
2905
+ chunk
2906
+ });
2907
+ touch();
2908
+ if (done && !error) onSent();
2909
+ if (portBufferedAmount(port) > 0) await Promise.race([whenPortReady(port), aborted]);
2910
+ return;
2911
+ }
2620
2912
  await callPort(port, chunk, {
2621
2913
  channelName,
2622
2914
  timeout: NO_TIMEOUT,
2623
2915
  signal
2624
2916
  });
2625
2917
  touch();
2918
+ if (done && !error) onSent();
2626
2919
  }, stream);
2627
2920
  } catch (err) {
2628
2921
  if (signal.aborted) return;
2629
2922
  throw err;
2630
2923
  } finally {
2631
2924
  signal.removeEventListener("abort", returnProducer);
2925
+ signal.removeEventListener("abort", onAborted);
2632
2926
  }
2633
2927
  }
2634
2928
  /**
2635
- * Runs `fn` on a later task, never a microtask: a producer looping on
2636
- * `whenPortReady` must let the transport's own events run. `setImmediate`
2637
- * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
2638
- * are clamped to 4 ms in browsers.
2639
- */
2640
- const setImmediateFn = globalThis.setImmediate;
2641
- /** @internal */
2642
- const nextTask = setImmediateFn ? (fn) => {
2643
- setImmediateFn(fn);
2644
- } : (() => {
2645
- let channel;
2646
- const queue = [];
2647
- return (fn) => {
2648
- if (!channel) {
2649
- channel = new MessageChannel();
2650
- channel.port1.onmessage = () => queue.shift()?.();
2651
- }
2652
- queue.push(fn);
2653
- channel.port2.postMessage(0);
2654
- };
2655
- })();
2656
- const states = /* @__PURE__ */ new WeakMap();
2657
- /** @internal Registered by the multiplexer for each port with a window. */
2658
- function setPortSendState(port, state) {
2659
- states.set(port, state);
2660
- }
2661
- /**
2662
2929
  * One virtual port.
2663
2930
  *
2664
2931
  * `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
@@ -2736,6 +3003,21 @@
2736
3003
  const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
2737
3004
  /** The longest delay a timer holds; past it `setInterval` fires at once. */
2738
3005
  const MAX_TIMER_MS = 2 ** 31 - 1;
3006
+ /**
3007
+ * What a windowed port offers in its `open`: every capability this end
3008
+ * speaks. `end` only over a codec that reads it (`PortCodec.halfClose`).
3009
+ */
3010
+ function ownCaps(codec) {
3011
+ return codec.halfClose ? ["pipeline", "end"] : ["pipeline"];
3012
+ }
3013
+ /**
3014
+ * The capabilities in a peer's `caps` that this end speaks too, or
3015
+ * `undefined` when the peer listed none (it is then treated as an older peer).
3016
+ */
3017
+ function sharedCaps(own, listed) {
3018
+ if (!Array.isArray(listed)) return void 0;
3019
+ return new Set(own.filter((cap) => listed.includes(cap)));
3020
+ }
2739
3021
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
2740
3022
  const DEFAULT_MAX_PORTS = 1024;
2741
3023
  /**
@@ -2799,6 +3081,8 @@
2799
3081
  let muxClosed = false;
2800
3082
  /** This end counts encoded bytes and reassembles frames. */
2801
3083
  const exactCodec = codec.encode != null && codec.decode != null;
3084
+ /** What this end offers on a windowed port. */
3085
+ const caps = ownCaps(codec);
2802
3086
  const post = (envelope, transfer) => {
2803
3087
  if (muxClosed) return;
2804
3088
  codecPost(envelope, transfer);
@@ -2856,13 +3140,39 @@
2856
3140
  port.postMessage(bytes);
2857
3141
  } catch {}
2858
3142
  };
3143
+ /**
3144
+ * Both directions have ended: free the id as a close would, with no `close`
3145
+ * on the wire (each end frees it on its own once it has seen both ends).
3146
+ * Aborting the close signal is how the port's consumer learns it is gone.
3147
+ */
3148
+ const freeIfEnded = (id, c) => {
3149
+ const entry = open.get(id);
3150
+ if (!c.endSent || !c.peerEnded || entry?.credit !== c) return;
3151
+ freePartial(entry);
3152
+ open.delete(id);
3153
+ leaks?.unregister(entry.handle);
3154
+ entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port ended in both directions"));
3155
+ };
3156
+ /** A peer that broke the port's protocol: hold none of its traffic, and close. */
3157
+ const failPort = (id, entry, error, reason) => {
3158
+ freePartial(entry);
3159
+ dropOutgoing(id);
3160
+ open.delete(id);
3161
+ releaseWaiters(entry.credit);
3162
+ entry.handle.markClosed(error);
3163
+ post({
3164
+ type: "close",
3165
+ id,
3166
+ reason
3167
+ });
3168
+ };
2859
3169
  const releaseWaiters = (c) => {
2860
3170
  for (const w of c?.waiters.splice(0) ?? []) w.resolve();
2861
3171
  };
2862
3172
  const flush = (c) => {
2863
3173
  while (c.queue.length) {
2864
3174
  const next = c.queue[0];
2865
- if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
3175
+ if (next.size > 0 && c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
2866
3176
  c.queue.shift();
2867
3177
  c.queued -= next.size;
2868
3178
  c.outstanding += next.size;
@@ -2900,15 +3210,20 @@
2900
3210
  enforced: false,
2901
3211
  opener,
2902
3212
  exact,
3213
+ caps: /* @__PURE__ */ new Set(),
2903
3214
  outstanding: 0,
2904
3215
  queue: [],
2905
3216
  queued: 0,
2906
3217
  unconsumed: 0,
2907
3218
  waiters: [],
2908
3219
  toGrant: 0,
2909
- grantScheduled: false
3220
+ grantScheduled: false,
3221
+ ending: false,
3222
+ endSent: false,
3223
+ peerEnded: false
2910
3224
  } : void 0;
2911
3225
  const self = newVirtualPort((payload, transfer) => {
3226
+ if (c?.ending) return;
2912
3227
  touch(id);
2913
3228
  const envelope = {
2914
3229
  type: "message",
@@ -2995,6 +3310,38 @@
2995
3310
  });
2996
3311
  })
2997
3312
  });
3313
+ setPortEndState(self.port, {
3314
+ end: () => {
3315
+ if (isClosed() || !c.caps.has("end")) return false;
3316
+ if (c.ending) return true;
3317
+ c.ending = true;
3318
+ c.queue.push({
3319
+ size: 0,
3320
+ send: () => sendOnPort(id, () => {
3321
+ if (open.get(id)?.credit !== c) return;
3322
+ post({
3323
+ type: "end",
3324
+ id
3325
+ });
3326
+ c.endSent = true;
3327
+ freeIfEnded(id, c);
3328
+ })
3329
+ });
3330
+ flush(c);
3331
+ return true;
3332
+ },
3333
+ watchPeerEnd: (resolve) => {
3334
+ if (!c.caps.has("end")) return false;
3335
+ if (c.peerEnded) resolve();
3336
+ else c.onPeerEnd = new WeakRef(resolve);
3337
+ return true;
3338
+ }
3339
+ });
3340
+ setPortAgreement(self.port, () => ({
3341
+ window: c.window,
3342
+ caps: c.caps,
3343
+ enforced: c.enforced
3344
+ }));
2998
3345
  }
2999
3346
  const record = {
3000
3347
  deliver: self.deliver,
@@ -3046,16 +3393,24 @@
3046
3393
  }
3047
3394
  const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
3048
3395
  const credit = open.get(envelope.id)?.credit;
3049
- if (credit) post(credit.exact ? {
3050
- type: "credit",
3051
- id: envelope.id,
3052
- bytes: 0,
3053
- exact: true
3054
- } : {
3055
- type: "credit",
3056
- id: envelope.id,
3057
- bytes: 0
3058
- });
3396
+ const listed = sharedCaps(caps, envelope.caps);
3397
+ if (credit) {
3398
+ const ack = credit.exact ? {
3399
+ type: "credit",
3400
+ id: envelope.id,
3401
+ bytes: 0,
3402
+ exact: true
3403
+ } : {
3404
+ type: "credit",
3405
+ id: envelope.id,
3406
+ bytes: 0
3407
+ };
3408
+ if (listed) credit.caps = listed;
3409
+ post(listed ? {
3410
+ ...ack,
3411
+ caps: [...listed]
3412
+ } : ack);
3413
+ }
3059
3414
  let accepted = false;
3060
3415
  if (onPort) try {
3061
3416
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -3083,6 +3438,7 @@
3083
3438
  c.exact = false;
3084
3439
  c.outstanding = 0;
3085
3440
  }
3441
+ c.caps = sharedCaps(caps, envelope.caps) ?? /* @__PURE__ */ new Set();
3086
3442
  post({
3087
3443
  type: "credit",
3088
3444
  id: envelope.id,
@@ -3094,6 +3450,10 @@
3094
3450
  flush(c);
3095
3451
  return;
3096
3452
  }
3453
+ if ((envelope.type === "message" || envelope.type === "frame") && entry?.credit?.peerEnded) {
3454
+ failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer sent on a port after its end"), "protocol");
3455
+ return;
3456
+ }
3097
3457
  if (envelope.type === "message") {
3098
3458
  const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
3099
3459
  if (!entry) return;
@@ -3118,16 +3478,7 @@
3118
3478
  const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
3119
3479
  const overrun = partial.room != null && partial.bytes + size > partial.room ? `what the window left (${partial.room} of ${c.window} bytes, ${partial.owed} still owed)` : reassembling - partial.charged + charged > maxReassembledBytes ? `maxReassembledBytes (${maxReassembledBytes} bytes past the window, across all ports)` : void 0;
3120
3480
  if (overrun) {
3121
- freePartial(entry);
3122
- dropOutgoing(envelope.id);
3123
- open.delete(envelope.id);
3124
- releaseWaiters(c);
3125
- entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
3126
- post({
3127
- type: "close",
3128
- id: envelope.id,
3129
- reason: "max-reassembled-bytes"
3130
- });
3481
+ failPort(envelope.id, entry, /* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`), "max-reassembled-bytes");
3131
3482
  return;
3132
3483
  }
3133
3484
  partial.bytes += size;
@@ -3147,6 +3498,19 @@
3147
3498
  entry.handle.deliver(inner.payload, whole.byteLength);
3148
3499
  return;
3149
3500
  }
3501
+ if (envelope.type === "end") {
3502
+ if (!entry) return;
3503
+ const c = entry.credit;
3504
+ if (!c?.caps.has("end")) {
3505
+ failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer ended a port where end was not agreed"), "protocol");
3506
+ return;
3507
+ }
3508
+ touch(envelope.id);
3509
+ c.peerEnded = true;
3510
+ c.onPeerEnd?.deref()?.();
3511
+ freeIfEnded(envelope.id, c);
3512
+ return;
3513
+ }
3150
3514
  dropOutgoing(envelope.id);
3151
3515
  if (!entry) return;
3152
3516
  freePartial(entry);
@@ -3228,12 +3592,14 @@
3228
3592
  id,
3229
3593
  meta,
3230
3594
  window,
3231
- exact: true
3595
+ exact: true,
3596
+ caps: [...caps]
3232
3597
  } : {
3233
3598
  type: "open",
3234
3599
  id,
3235
3600
  meta,
3236
- window
3601
+ window,
3602
+ caps: [...caps]
3237
3603
  } : {
3238
3604
  type: "open",
3239
3605
  id,
@@ -3388,7 +3754,7 @@
3388
3754
  const candidate = value;
3389
3755
  if (typeof candidate.id !== "number") return false;
3390
3756
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
3391
- return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
3757
+ return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong" || candidate.type === "end";
3392
3758
  }
3393
3759
  /**
3394
3760
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -3400,6 +3766,7 @@
3400
3766
  * not send anything a byte codec could not also carry.
3401
3767
  */
3402
3768
  const structuredCodec = {
3769
+ halfClose: true,
3403
3770
  post(port, envelope, transfer) {
3404
3771
  if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
3405
3772
  else port.postMessage(envelope);
@@ -3437,10 +3804,15 @@
3437
3804
  * own `handleStreams` / `sendStream` (no backpressure); version 2 is
3438
3805
  * webrun-rpc's `connect` / `serve` over `overPipe` with webrun-http-streams'
3439
3806
  * `fetchOverDuplex` / `serveFetchOverDuplex`: one port per link, calls
3440
- * multiplexed on it, each call a stream with a one-chunk window in both
3441
- * directions, and cancellation that reaches the handler.
3807
+ * multiplexed on it, each call a stream with backpressure in both
3808
+ * directions, and cancellation that reaches the handler. A call's port with a
3809
+ * window (`HttpLinkOptions.window`) pipelines its chunks where both ends speak
3810
+ * it, and goes one confirmed chunk at a time otherwise, so the window needs
3811
+ * no new version.
3442
3812
  */
3443
3813
  const HTTP_LINK_PROTOCOL = "webrun-http-link/2";
3814
+ /** The calling end's default `HttpLinkOptions.window`: 256 KiB. */
3815
+ const DEFAULT_HTTP_LINK_WINDOW = 262144;
3444
3816
  /**
3445
3817
  * Why a request failed when its link went away under it: the far side (the
3446
3818
  * handler, or the ServiceWorker) was lost. The request may or may not have
@@ -3459,12 +3831,13 @@
3459
3831
  */
3460
3832
  const LINK_CLOSE = "webrun-http:link-close";
3461
3833
  /** Opens the calling end of a link on `port` (one port per link). */
3462
- async function connectHttpLink(port, { streamTimeout, maxPorts } = {}) {
3834
+ async function connectHttpLink(port, { streamTimeout, maxPorts, window = DEFAULT_HTTP_LINK_WINDOW } = {}) {
3463
3835
  const { call, close } = await connect({
3464
3836
  mux: overPipe(port, {
3465
3837
  codec: structuredCodec,
3466
3838
  side: "initiator",
3467
- maxPorts
3839
+ maxPorts,
3840
+ window
3468
3841
  }),
3469
3842
  timeout: streamTimeout
3470
3843
  });
@@ -3775,6 +4148,7 @@
3775
4148
  all.set("connection", "Upgrade");
3776
4149
  all.set("sec-websocket-version", "13");
3777
4150
  if (!all.has("sec-websocket-key")) all.set("sec-websocket-key", randomKey());
4151
+ all.delete("sec-websocket-protocol");
3778
4152
  if (protocols.length > 0) all.set("sec-websocket-protocol", protocols.join(", "));
3779
4153
  if (!all.has("x-webrun-ws-version")) all.set(WS_VERSION_HEADER, `2`);
3780
4154
  return withHeaders(new Request(httpUrl, {
@@ -3907,6 +4281,21 @@
3907
4281
  const NEED_LINKS_CALL = "webrun-http:need-links";
3908
4282
  //#endregion
3909
4283
  //#region src/sw/http-sw-dispatcher.ts
4284
+ /**
4285
+ * The default key: the url's first path segment under `scope`, decoded; none outside it, at the
4286
+ * scope itself, or for a segment that does not decode.
4287
+ */
4288
+ function scopeKey(url, scope) {
4289
+ const href = `${url}`;
4290
+ if (!href.startsWith(scope)) return void 0;
4291
+ const key = href.substring(scope.length).replace(/[?#].*$/, "").split("/")[0];
4292
+ if (!key) return void 0;
4293
+ try {
4294
+ return decodeURIComponent(key);
4295
+ } catch {
4296
+ return;
4297
+ }
4298
+ }
3910
4299
  /** One attached handler link, as the ServiceWorker holds it. */
3911
4300
  var HandlerEntry = class {
3912
4301
  key;
@@ -3946,7 +4335,8 @@
3946
4335
  * (`SwHttpAdapter.register`) or in Dedicated Workers
3947
4336
  * (`SwHttpAdapter.attachWorker` + `serveHttpInWorker`).
3948
4337
  *
3949
- * A url `<scope>/<key>/...` belongs to the handlers of `key`. Of those, the
4338
+ * A url `<scope>/<key>/...` belongs to the handlers of `key` (the `key`
4339
+ * option maps urls to keys some other way). Of those, the
3950
4340
  * `route` option picks one per request. Requests and responses stream with
3951
4341
  * backpressure in both directions, and an aborted fetch reaches the handler
3952
4342
  * as a cancellation (webrun-rpc + webrun-http-streams).
@@ -3965,6 +4355,8 @@
3965
4355
  */
3966
4356
  var SwHttpDispatcher = class {
3967
4357
  self;
4358
+ /** `self`, with the WebWorker lib's types this module compiles with. */
4359
+ _sw;
3968
4360
  log;
3969
4361
  options;
3970
4362
  table = new HandlerTable();
@@ -3987,19 +4379,22 @@
3987
4379
  constructor(options) {
3988
4380
  this.options = options;
3989
4381
  this.self = options.self;
4382
+ this._sw = options.self;
3990
4383
  this.log = options.log ?? (() => {});
3991
4384
  if (locksAvailable()) this.routerLock = newLockName("webrun-http-router");
3992
4385
  }
3993
4386
  get scope() {
3994
- return `${new URL("./", this.self.location.href)}`;
4387
+ return this._sw.registration?.scope ?? `${new URL("./", this._sw.location.href)}`;
3995
4388
  }
3996
- /** The key `url` belongs to: its first path segment under the scope. */
4389
+ /** The key `url` belongs to (the `key` option; by default its first path segment under the scope). */
3997
4390
  keyOf(url) {
3998
- const href = `${url}`;
3999
- const scope = this.scope;
4000
- if (!href.startsWith(scope)) return void 0;
4001
- const key = href.substring(scope.length).replace(/[?#].*$/, "").split("/")[0];
4002
- return key ? decodeURIComponent(key) : void 0;
4391
+ let parsed;
4392
+ try {
4393
+ parsed = new URL(`${url}`);
4394
+ } catch {
4395
+ return;
4396
+ }
4397
+ return (this.options.key ?? scopeKey)(parsed, this.scope);
4003
4398
  }
4004
4399
  /** Whether `key` has ever been served on this worker. */
4005
4400
  isClaimedKey(key) {
@@ -4010,7 +4405,7 @@
4010
4405
  return this.claimedKeys.has(key) || this.table.all(key).length > 0;
4011
4406
  }
4012
4407
  start() {
4013
- const self = this.self;
4408
+ const self = this._sw;
4014
4409
  if (this.routerLock) this._routerHeld = holdLock(this.routerLock).then(() => {});
4015
4410
  this._restored = this._restoreClaimedKeys();
4016
4411
  const latch = () => {
@@ -4030,8 +4425,8 @@
4030
4425
  return true;
4031
4426
  }), handleChannelCalls(self, WS_CONNECT_CALL, async (event, params) => {
4032
4427
  if (!this._isOwnClient(event)) throw new Error("Only this origin's pages may open sockets");
4033
- const { url, protocols } = params;
4034
- const connection = await this.connectWebSocket(url, protocols ?? []);
4428
+ const { url, protocols, headers } = params;
4429
+ const connection = await this.connectWebSocket(url, protocols ?? [], this._socketHeaders(headers, "page"));
4035
4430
  return withTransfer(connection, [connection.port]);
4036
4431
  }));
4037
4432
  const onMessage = (event) => {
@@ -4049,10 +4444,10 @@
4049
4444
  this.log("Claim control over all clients.");
4050
4445
  event.waitUntil(self.clients.claim());
4051
4446
  };
4052
- const onFetch = (event) => this._onFetch(event);
4447
+ const onFetch = (event) => void this.handleFetch(event);
4053
4448
  self.addEventListener("install", onInstall);
4054
4449
  self.addEventListener("activate", onActivate);
4055
- self.addEventListener("fetch", onFetch);
4450
+ if (this.options.handleFetch !== false) self.addEventListener("fetch", onFetch);
4056
4451
  this._cleanup.push(() => {
4057
4452
  self.removeEventListener("install", onInstall);
4058
4453
  self.removeEventListener("activate", onActivate);
@@ -4067,37 +4462,60 @@
4067
4462
  }
4068
4463
  /**
4069
4464
  * Answers `request` as a fetch under the scope would be: through the
4070
- * handler its key routes to. A url outside the scope, or of a key this
4071
- * worker never served, goes to the network.
4465
+ * handler its key routes to. A url with no key (by default, one outside the
4466
+ * scope), or of a key this worker never served, goes to the network.
4072
4467
  */
4073
4468
  async dispatch(request) {
4074
4469
  const key = this.keyOf(request.url);
4075
4470
  if (key === void 0) return await fetch(request);
4076
4471
  await this._restored.catch(() => {});
4077
4472
  const entry = await this._acquire(request, key);
4078
- if (entry === TIMED_OUT) return new Response(null, {
4079
- status: 503,
4080
- statusText: "Service Unavailable (no handler attached in time)"
4081
- });
4473
+ if (entry === TIMED_OUT) return await this._failed(request, { kind: "timeout" });
4082
4474
  if (!entry) {
4083
4475
  if (!this.serves(key)) return await fetch(request);
4084
- return new Response(null, {
4085
- status: 404,
4086
- statusText: "Not Found (no active handler)"
4087
- });
4476
+ return await this._failed(request, { kind: "gone" });
4088
4477
  }
4089
4478
  try {
4090
4479
  return await entry.link.send(request);
4091
4480
  } catch (error) {
4092
4481
  this.log("[dispatch] failed", key, error);
4093
- return new Response(errorMessage(error), {
4094
- status: 502,
4095
- statusText: "Bad Gateway",
4096
- headers: { "content-type": "text/plain; charset=utf-8" }
4482
+ return await this._failed(request, {
4483
+ kind: "lost",
4484
+ error
4097
4485
  });
4098
4486
  }
4099
4487
  }
4100
4488
  /**
4489
+ * After `start()`: settles once the keys this worker served before a restart are read back
4490
+ * (whatever the outcome). Until then `serves()` may answer `false` for a key it will serve.
4491
+ */
4492
+ get ready() {
4493
+ return this._restored.then(() => {}, () => {});
4494
+ }
4495
+ _socketHeaders(headers, from) {
4496
+ return this.options.socketHeaders ? this.options.socketHeaders(headers, from) : headers;
4497
+ }
4498
+ async _failed(request, failure) {
4499
+ if (this.options.failure) try {
4500
+ return await this.options.failure(request, failure);
4501
+ } catch (error) {
4502
+ this.log("[dispatch] the failure option threw; answering by default", error);
4503
+ }
4504
+ if (failure.kind === "timeout") return new Response(null, {
4505
+ status: 503,
4506
+ statusText: "Service Unavailable (no handler attached in time)"
4507
+ });
4508
+ if (failure.kind === "gone") return new Response(null, {
4509
+ status: 404,
4510
+ statusText: "Not Found (no active handler)"
4511
+ });
4512
+ return new Response(errorMessage(failure.error), {
4513
+ status: 502,
4514
+ statusText: "Bad Gateway",
4515
+ headers: { "content-type": "text/plain; charset=utf-8" }
4516
+ });
4517
+ }
4518
+ /**
4101
4519
  * Opens a socket to the handler `url` routes to: the handler accepts or
4102
4520
  * refuses it like an HTTP upgrade, and the socket's port comes back. After
4103
4521
  * that the socket runs between the caller and the handler directly; this
@@ -4109,7 +4527,7 @@
4109
4527
  headers
4110
4528
  });
4111
4529
  const key = this.keyOf(request.url);
4112
- if (key === void 0) throw new Error(`${url} is outside this ServiceWorker's scope`);
4530
+ if (key === void 0) throw new Error(`${url} belongs to no key of this ServiceWorker`);
4113
4531
  await this._restored.catch(() => {});
4114
4532
  const entry = await this._acquire(request, key);
4115
4533
  if (!entry || entry === TIMED_OUT) throw new Error(`No handler serves ${url}`);
@@ -4125,20 +4543,47 @@
4125
4543
  });
4126
4544
  return await answer;
4127
4545
  }
4128
- _onFetch(event) {
4129
- const request = event.request;
4546
+ /**
4547
+ * Answers `event` if it is the dispatcher's, with `request` (default `event.request`; a host
4548
+ * may pass a rewritten one, with headers it adds). Returns whether it called `respondWith`;
4549
+ * when it did not, the event is the caller's to answer or to leave to the network. For a host
4550
+ * that listens to `fetch` itself (`handleFetch: false`); call it synchronously in the event's
4551
+ * listener, as `respondWith` must be.
4552
+ *
4553
+ * It takes a same-origin request whose key (the `key` option) a handler serves, and also:
4554
+ * - a navigation under the scope whose key nobody serves (answered by `unserved`);
4555
+ * - before the stored keys are read (the first requests after a restart), every request with
4556
+ * a key, since only then is it known whose it is: one nobody serves gets what it would have
4557
+ * got after, `unserved` for a navigation and the network for anything else.
4558
+ * So a host with a page of its own for urls nobody serves gives it as `unserved`, rather than
4559
+ * deciding before calling this.
4560
+ */
4561
+ handleFetch(event, request = event.request) {
4562
+ const navigation = event.request.mode === "navigate";
4563
+ if (new URL(request.url).origin !== this._sw.location.origin) return false;
4130
4564
  const key = this.keyOf(request.url);
4131
- if (key === void 0) return;
4565
+ if (key === void 0) return false;
4132
4566
  if (this._restoreSettled) {
4133
- if (this.serves(key)) event.respondWith(this.dispatch(request));
4134
- else if (request.mode === "navigate") event.respondWith(fetch(request));
4135
- return;
4567
+ if (this.serves(key)) {
4568
+ event.respondWith(this.dispatch(request));
4569
+ return true;
4570
+ }
4571
+ if (navigation) {
4572
+ event.respondWith(this._unserved(request));
4573
+ return true;
4574
+ }
4575
+ return false;
4136
4576
  }
4137
4577
  event.respondWith((async () => {
4138
4578
  await this._restored.catch(() => {});
4139
- if (!this.serves(key)) return await fetch(request);
4140
- return await this.dispatch(request);
4579
+ if (this.serves(key)) return await this.dispatch(request);
4580
+ return navigation ? await this._unserved(request) : await fetch(request);
4141
4581
  })());
4582
+ return true;
4583
+ }
4584
+ /** What a request the dispatcher took, and no handler serves, gets: `unserved`, or the network. */
4585
+ async _unserved(request) {
4586
+ return await (this.options.unserved ?? fetch)(request);
4142
4587
  }
4143
4588
  async _attach(event, params, ports) {
4144
4589
  const clientId = event.source?.id;
@@ -4157,8 +4602,11 @@
4157
4602
  }
4158
4603
  const ctl = params.ctl ? rest.shift() : void 0;
4159
4604
  const up = params.up ? rest.shift() : void 0;
4160
- const streamTimeout = this.options.streamTimeout;
4161
- const link = await connectHttpLink(down, { streamTimeout });
4605
+ const { streamTimeout, window } = this.options;
4606
+ const link = await connectHttpLink(down, {
4607
+ streamTimeout,
4608
+ window
4609
+ });
4162
4610
  const entry = new HandlerEntry(params.key, params.id ?? clientId ?? crypto.randomUUID(), params.owner ?? crypto.randomUUID(), params.meta, link, ctl, clientId, params.ownerLock);
4163
4611
  if (up) entry.closeUp = await serveHttpLink(up, (request) => this.dispatch(request), { streamTimeout });
4164
4612
  if (ctl) ctl.onmessage = (message) => this._onControl(entry, message.data);
@@ -4237,20 +4685,35 @@
4237
4685
  * its key, waiting for one when there is none.
4238
4686
  */
4239
4687
  async _acquire(request, key) {
4688
+ const deadline = Date.now() + (this.options.attachTimeoutMs ?? 15e3);
4240
4689
  const picked = await this._pick(request, key);
4241
4690
  if (picked) return picked;
4242
- if (this.table.all(key).length > 0) return await this.table.waitFor(key, this.options.attachTimeoutMs ?? 15e3, this.table.live(key).length > 0) ? await this._pick(request, key) ?? TIMED_OUT : TIMED_OUT;
4243
- const vacantUntil = this._vacant.get(key) ?? 0;
4244
- if (vacantUntil > Date.now()) return await this.table.waitFor(key, vacantUntil - Date.now()) ? await this._pick(request, key) ?? TIMED_OUT : TIMED_OUT;
4245
- if (!this.serves(key)) return void 0;
4246
- await this._askForLinks(key);
4247
- return await this._pick(request, key);
4691
+ if (this.table.all(key).length === 0) {
4692
+ const vacantUntil = this._vacant.get(key) ?? 0;
4693
+ if (vacantUntil > Date.now()) return await this._waitToPick(request, key, vacantUntil);
4694
+ if (!this.serves(key)) return void 0;
4695
+ await this._askForLinks(key);
4696
+ const relinked = await this._pick(request, key);
4697
+ if (relinked) return relinked;
4698
+ if (this.table.all(key).length === 0) return void 0;
4699
+ }
4700
+ return await this._waitToPick(request, key, deadline);
4701
+ }
4702
+ /** Waits for attaches until `route` accepts a handler, or `until` passes. */
4703
+ async _waitToPick(request, key, until) {
4704
+ for (;;) {
4705
+ const left = until - Date.now();
4706
+ if (left <= 0) return TIMED_OUT;
4707
+ await this.table.waitFor(key, Math.min(left, 1e3), this.table.live(key).length > 0);
4708
+ const picked = await this._pick(request, key);
4709
+ if (picked) return picked;
4710
+ }
4248
4711
  }
4249
4712
  async _pick(request, key) {
4250
4713
  let live = this.table.live(key);
4251
4714
  for (const entry of live) {
4252
4715
  if (entry.ownerLock || !entry.clientId) continue;
4253
- if (!await this.self.clients.get(entry.clientId)) this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
4716
+ if (!await this._sw.clients.get(entry.clientId)) this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
4254
4717
  }
4255
4718
  live = this.table.live(key);
4256
4719
  if (live.length === 0) return void 0;
@@ -4274,7 +4737,7 @@
4274
4737
  }
4275
4738
  async _linkRound(key) {
4276
4739
  const timeout = this.options.relinkTimeoutMs ?? 3e3;
4277
- const windows = await this.self.clients.matchAll({
4740
+ const windows = await this._sw.clients.matchAll({
4278
4741
  type: "window",
4279
4742
  includeUncontrolled: true
4280
4743
  });
@@ -4312,7 +4775,7 @@
4312
4775
  entry.sockets.fail(message.id, new Error(message.message));
4313
4776
  return;
4314
4777
  case "ws-open":
4315
- this.connectWebSocket(message.url, message.protocols).then(({ port, protocol }) => entry.ctl?.postMessage({
4778
+ this.connectWebSocket(message.url, message.protocols, this._socketHeaders(message.headers, "handler")).then(({ port, protocol }) => entry.ctl?.postMessage({
4316
4779
  t: "ws-opened",
4317
4780
  id: message.id,
4318
4781
  protocol,
@@ -4329,7 +4792,7 @@
4329
4792
  const source = event.source;
4330
4793
  if (!source?.url) return false;
4331
4794
  try {
4332
- return new URL(source.url).origin === this.self.location.origin;
4795
+ return new URL(source.url).origin === this._sw.location.origin;
4333
4796
  } catch {
4334
4797
  return false;
4335
4798
  }
@@ -4353,6 +4816,7 @@
4353
4816
  * that stops it.
4354
4817
  */
4355
4818
  function startHttpDispatcher(options) {
4819
+ if (options.handleFetch === false) throw new Error("startHttpDispatcher: handleFetch: false needs a dispatcher to call handleFetch on; construct it instead: new SwHttpDispatcher({ self, handleFetch: false }).start()");
4356
4820
  const dispatcher = new SwHttpDispatcher(options);
4357
4821
  dispatcher.start();
4358
4822
  return () => {