@statewalker/webrun-http-browser 0.9.0 → 0.12.1

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 (59) 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 +908 -221
  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 +470 -81
  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 +470 -81
  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 +681 -141
  34. package/dist/sw.js +896 -218
  35. package/dist/worker.js +689 -115
  36. package/dist/ws/server.d.ts +50 -2
  37. package/dist/ws/server.d.ts.map +1 -1
  38. package/dist/ws/socket-core.d.ts +5 -2
  39. package/dist/ws/socket-core.d.ts.map +1 -1
  40. package/dist/ws/upgrade.d.ts +12 -1
  41. package/dist/ws/upgrade.d.ts.map +1 -1
  42. package/package.json +4 -3
  43. package/src/core/index.ts +1 -0
  44. package/src/core/service-worker-control.ts +5 -3
  45. package/src/core/sw-scope.ts +27 -0
  46. package/src/link/http-link.ts +20 -4
  47. package/src/relay/index-sw.ts +15 -2
  48. package/src/relay/index.ts +2 -2
  49. package/src/relay-worker.ts +1 -0
  50. package/src/sw/handler-endpoint.ts +17 -4
  51. package/src/sw/http-sw-dispatcher.ts +267 -67
  52. package/src/sw/index.ts +5 -0
  53. package/src/sw/protocol.ts +11 -1
  54. package/src/sw/sw-dispatcher.ts +17 -11
  55. package/src/sw/sw-http-adapter.ts +36 -10
  56. package/src/sw/worker-service.ts +11 -4
  57. package/src/ws/server.ts +166 -11
  58. package/src/ws/socket-core.ts +7 -2
  59. 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
  });
@@ -1394,7 +1395,11 @@
1394
1395
  */
1395
1396
  function httpServe(handler, options = {}) {
1396
1397
  const codec = options.codec ?? defaultCodec;
1397
- return async function* httpHandlerDuplex(input) {
1398
+ return (input) => {
1399
+ const abandoned = new AbortController();
1400
+ return abortingOnReturn(exchange(input, { signal: abandoned.signal }), (reason) => abandoned.abort(reason ?? /* @__PURE__ */ new Error("webrun-http-streams: the exchange was abandoned before it was over")));
1401
+ };
1402
+ async function* exchange(input, context) {
1398
1403
  let decoded;
1399
1404
  try {
1400
1405
  decoded = await codec.decodeRequest(input);
@@ -1406,7 +1411,7 @@
1406
1411
  const replyCodec = decoded.codec ?? codec;
1407
1412
  let result;
1408
1413
  try {
1409
- result = await handler(decoded.envelope, decoded.body);
1414
+ result = await handler(decoded.envelope, decoded.body, context);
1410
1415
  } catch (error) {
1411
1416
  yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
1412
1417
  return;
@@ -1421,7 +1426,36 @@
1421
1426
  await result.release?.();
1422
1427
  } catch {}
1423
1428
  }
1429
+ }
1430
+ }
1431
+ /**
1432
+ * `gen`, except that `return()` and `throw()` from its consumer call `abandon`
1433
+ * at once — before they are queued behind a `next()` still parked inside the
1434
+ * generator (the handler's `await`) — unless the generator already finished.
1435
+ */
1436
+ function abortingOnReturn(gen, abandon) {
1437
+ let finished = false;
1438
+ const settle = (r) => {
1439
+ if (r.done) finished = true;
1440
+ return r;
1441
+ };
1442
+ const wrapped = {
1443
+ next: (...args) => gen.next(...args).then(settle),
1444
+ return(value) {
1445
+ if (!finished) abandon();
1446
+ finished = true;
1447
+ return gen.return(value);
1448
+ },
1449
+ throw(error) {
1450
+ if (!finished) abandon(error);
1451
+ finished = true;
1452
+ return gen.throw(error);
1453
+ },
1454
+ [Symbol.asyncIterator]() {
1455
+ return wrapped;
1456
+ }
1424
1457
  };
1458
+ return asyncDisposable$2(wrapped, () => wrapped.return(void 0));
1425
1459
  }
1426
1460
  /**
1427
1461
  * The one place that knows a runtime may not implement request body streams,
@@ -1660,10 +1694,11 @@
1660
1694
  * registered with any `webrun-streams-*` adapter's `serve`.
1661
1695
  */
1662
1696
  function serveFetchOverDuplex(handler, options = {}) {
1663
- return httpServe(async (env, body) => {
1697
+ return httpServe(async (env, body, exchange) => {
1664
1698
  const reqInit = {
1665
1699
  method: env.method,
1666
- headers: forwardableHeaders(env.headers)
1700
+ headers: forwardableHeaders(env.headers),
1701
+ signal: exchange?.signal
1667
1702
  };
1668
1703
  let upload;
1669
1704
  if (env.method !== "GET" && env.method !== "HEAD") {
@@ -2217,6 +2252,99 @@
2217
2252
  }
2218
2253
  }
2219
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
+ /**
2220
2348
  * Makes `target` disposable with `await using`: installs `dispose` as its
2221
2349
  * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
2222
2350
  * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
@@ -2288,6 +2416,61 @@
2288
2416
  * tests and adapters assert on it.
2289
2417
  */
2290
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
+ };
2291
2474
  /** Caller's input travels on this channel; the handler listens on it. */
2292
2475
  const CHANNEL_IN = "in";
2293
2476
  /** The handler's output travels on this channel; the caller listens on it. */
@@ -2300,7 +2483,9 @@
2300
2483
  * same way on the other channel. Within each direction the next chunk is never
2301
2484
  * sent until the previous one has been delivered *and* pulled past by the
2302
2485
  * consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
2303
- * `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.
2304
2489
  *
2305
2490
  * A stream port carries exactly one invocation. To make several calls, open
2306
2491
  * several ports: `mux.openPort({ kind: "stream" })` per call.
@@ -2317,22 +2502,49 @@
2317
2502
  const notice = installAbortNotice(port, controller);
2318
2503
  abortOnPortClose(port, controller);
2319
2504
  const clock = installStreamTimeout(controller, options.timeout);
2320
- const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
2505
+ const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch, notice);
2321
2506
  let output;
2322
2507
  try {
2323
2508
  output = handler(inbound.stream);
2324
2509
  } catch (err) {
2325
- 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, () => {});
2326
2511
  pump.catch(() => {});
2327
2512
  disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
2328
2513
  return teardownOnce(controller, notice, clock, inbound, void 0);
2329
2514
  }
2330
- 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);
2331
2520
  pump.catch(() => {});
2332
2521
  disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
2522
+ endServedStream(port, sent, inbound.over);
2333
2523
  return teardownOnce(controller, notice, clock, inbound, output);
2334
2524
  }
2335
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
+ /**
2336
2548
  * A stream that completes normally has nobody left to call the returned
2337
2549
  * teardown — the caller only knows the stream ended, not that it must also
2338
2550
  * dispose the serve-side handle. Without this, the last `touch()`'s timer
@@ -2372,22 +2584,31 @@
2372
2584
  const notice = installAbortNotice(port, controller);
2373
2585
  abortOnPortClose(port, controller);
2374
2586
  const clock = installStreamTimeout(controller, options.timeout);
2375
- const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
2376
- 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
+ });
2377
2592
  pump.catch(() => {});
2378
2593
  return returningAtOnce((async function* () {
2594
+ let received = false;
2379
2595
  try {
2380
2596
  yield* inbound.stream;
2597
+ received = true;
2381
2598
  } finally {
2599
+ const ended = received && sent;
2382
2600
  if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
2383
- notice.post();
2601
+ if (!ended) notice.post();
2384
2602
  notice.stop();
2385
2603
  clock.stop();
2386
2604
  inbound.stop();
2387
2605
  await pump.catch(() => {});
2388
- try {
2389
- await port.close?.();
2390
- } catch {}
2606
+ if (!(ended && endPort(port))) {
2607
+ notice.post();
2608
+ try {
2609
+ await port.close?.();
2610
+ } catch {}
2611
+ }
2391
2612
  }
2392
2613
  })(), () => {
2393
2614
  if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
@@ -2481,7 +2702,7 @@
2481
2702
  * the local consumer has not started iterating, an inbound chunk waits for it —
2482
2703
  * which is the correct backpressure, and different from having no listener.
2483
2704
  */
2484
- function receiveChunks(port, channelName, controller, touch) {
2705
+ function receiveChunks(port, channelName, controller, touch, notice) {
2485
2706
  let deliver;
2486
2707
  const waiting = [];
2487
2708
  let finished = false;
@@ -2491,25 +2712,64 @@
2491
2712
  const ended = new Promise((resolve) => {
2492
2713
  resolveEnded = resolve;
2493
2714
  });
2715
+ let resolveOver = () => {};
2716
+ const over = new Promise((resolve) => {
2717
+ resolveOver = resolve;
2718
+ });
2494
2719
  let aborted = false;
2495
2720
  let abortReason;
2496
2721
  const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
2497
2722
  const wake = () => {
2498
2723
  for (const r of waiting.splice(0)) r();
2499
2724
  };
2500
- 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);
2501
2769
  touch();
2502
2770
  if (poison) throw poison;
2503
- if (outstanding) {
2504
- poison = /* @__PURE__ */ new Error("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
2505
- controller.abort(poison);
2506
- setTimeout(() => {
2507
- try {
2508
- port.close?.();
2509
- } catch {}
2510
- }, 0);
2511
- throw poison;
2512
- }
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");
2513
2773
  outstanding = true;
2514
2774
  try {
2515
2775
  await ready();
@@ -2519,15 +2779,60 @@
2519
2779
  value,
2520
2780
  error: error ? deserializeError(error) : void 0
2521
2781
  });
2782
+ if (done && !error) resolveOver();
2522
2783
  } finally {
2523
2784
  outstanding = false;
2524
2785
  }
2525
2786
  if (done) resolveEnded();
2526
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);
2527
2831
  const detach = () => {
2528
2832
  finished = true;
2529
2833
  wake();
2530
2834
  off();
2835
+ port.removeEventListener("message", onChunk);
2531
2836
  controller.signal.removeEventListener("abort", onAbort);
2532
2837
  resolveEnded();
2533
2838
  };
@@ -2557,21 +2862,35 @@
2557
2862
  return detach;
2558
2863
  }),
2559
2864
  stop: detach,
2560
- ended
2865
+ ended,
2866
+ over
2561
2867
  };
2562
2868
  }
2563
2869
  /**
2564
2870
  * The sending half of one direction: one `callPort` per chunk, one call
2565
- * 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.
2566
2875
  */
2567
- async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
2568
- const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
2876
+ async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch, onSent) {
2877
+ const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
2878
+ const stream = throughAbort(framed, signal);
2879
+ const producer = output;
2880
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
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 });
2569
2888
  try {
2570
2889
  await sendIterator(async ({ done, value, error }) => {
2571
2890
  if (signal.aborted) return;
2572
2891
  const chunk = {
2573
2892
  done,
2574
- value,
2893
+ value: done ? value : chunkBytes(value),
2575
2894
  error: error === void 0 ? void 0 : serializeError(error)
2576
2895
  };
2577
2896
  log?.("[duplexOverPort] send", {
@@ -2579,46 +2898,34 @@
2579
2898
  done,
2580
2899
  size: value?.byteLength
2581
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
+ }
2582
2912
  await callPort(port, chunk, {
2583
2913
  channelName,
2584
2914
  timeout: NO_TIMEOUT,
2585
2915
  signal
2586
2916
  });
2587
2917
  touch();
2918
+ if (done && !error) onSent();
2588
2919
  }, stream);
2589
2920
  } catch (err) {
2590
2921
  if (signal.aborted) return;
2591
2922
  throw err;
2923
+ } finally {
2924
+ signal.removeEventListener("abort", returnProducer);
2925
+ signal.removeEventListener("abort", onAborted);
2592
2926
  }
2593
2927
  }
2594
2928
  /**
2595
- * Runs `fn` on a later task, never a microtask: a producer looping on
2596
- * `whenPortReady` must let the transport's own events run. `setImmediate`
2597
- * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
2598
- * are clamped to 4 ms in browsers.
2599
- */
2600
- const setImmediateFn = globalThis.setImmediate;
2601
- /** @internal */
2602
- const nextTask = setImmediateFn ? (fn) => {
2603
- setImmediateFn(fn);
2604
- } : (() => {
2605
- let channel;
2606
- const queue = [];
2607
- return (fn) => {
2608
- if (!channel) {
2609
- channel = new MessageChannel();
2610
- channel.port1.onmessage = () => queue.shift()?.();
2611
- }
2612
- queue.push(fn);
2613
- channel.port2.postMessage(0);
2614
- };
2615
- })();
2616
- const states = /* @__PURE__ */ new WeakMap();
2617
- /** @internal Registered by the multiplexer for each port with a window. */
2618
- function setPortSendState(port, state) {
2619
- states.set(port, state);
2620
- }
2621
- /**
2622
2929
  * One virtual port.
2623
2930
  *
2624
2931
  * `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
@@ -2696,6 +3003,21 @@
2696
3003
  const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
2697
3004
  /** The longest delay a timer holds; past it `setInterval` fires at once. */
2698
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
+ }
2699
3021
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
2700
3022
  const DEFAULT_MAX_PORTS = 1024;
2701
3023
  /**
@@ -2727,10 +3049,18 @@
2727
3049
  */
2728
3050
  function multiplexPort(port, options) {
2729
3051
  const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
3052
+ const maxReassembledBytes = options.maxReassembledBytes ?? 67108864;
2730
3053
  const misses = keepalive?.misses ?? 3;
2731
3054
  if (keepalive && !(Number.isFinite(keepalive.intervalMs) && keepalive.intervalMs > 0 && keepalive.intervalMs <= MAX_TIMER_MS && Number.isSafeInteger(misses) && misses > 0)) throw new RangeError(`webrun-rpc: keepalive needs an intervalMs in (0, ${MAX_TIMER_MS}] and a positive integer misses`);
2732
3055
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
3056
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) throw new RangeError(`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`);
2733
3057
  const open = /* @__PURE__ */ new Map();
3058
+ let reassembling = 0;
3059
+ const freePartial = (entry) => {
3060
+ if (!entry?.partial) return;
3061
+ reassembling -= entry.partial.charged;
3062
+ entry.partial = void 0;
3063
+ };
2734
3064
  const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
2735
3065
  const entry = open.get(id);
2736
3066
  if (entry?.handle !== record) return;
@@ -2751,6 +3081,8 @@
2751
3081
  let muxClosed = false;
2752
3082
  /** This end counts encoded bytes and reassembles frames. */
2753
3083
  const exactCodec = codec.encode != null && codec.decode != null;
3084
+ /** What this end offers on a windowed port. */
3085
+ const caps = ownCaps(codec);
2754
3086
  const post = (envelope, transfer) => {
2755
3087
  if (muxClosed) return;
2756
3088
  codecPost(envelope, transfer);
@@ -2808,13 +3140,39 @@
2808
3140
  port.postMessage(bytes);
2809
3141
  } catch {}
2810
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
+ };
2811
3169
  const releaseWaiters = (c) => {
2812
3170
  for (const w of c?.waiters.splice(0) ?? []) w.resolve();
2813
3171
  };
2814
3172
  const flush = (c) => {
2815
3173
  while (c.queue.length) {
2816
3174
  const next = c.queue[0];
2817
- 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;
2818
3176
  c.queue.shift();
2819
3177
  c.queued -= next.size;
2820
3178
  c.outstanding += next.size;
@@ -2852,15 +3210,20 @@
2852
3210
  enforced: false,
2853
3211
  opener,
2854
3212
  exact,
3213
+ caps: /* @__PURE__ */ new Set(),
2855
3214
  outstanding: 0,
2856
3215
  queue: [],
2857
3216
  queued: 0,
2858
3217
  unconsumed: 0,
2859
3218
  waiters: [],
2860
3219
  toGrant: 0,
2861
- grantScheduled: false
3220
+ grantScheduled: false,
3221
+ ending: false,
3222
+ endSent: false,
3223
+ peerEnded: false
2862
3224
  } : void 0;
2863
3225
  const self = newVirtualPort((payload, transfer) => {
3226
+ if (c?.ending) return;
2864
3227
  touch(id);
2865
3228
  const envelope = {
2866
3229
  type: "message",
@@ -2878,12 +3241,12 @@
2878
3241
  ...envelope,
2879
3242
  payload: structuredClone(payload, { transfer })
2880
3243
  } : void 0;
2881
- const send = () => {
3244
+ const send = (whole = false) => {
2882
3245
  if (!bytes) {
2883
3246
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
2884
3247
  return;
2885
3248
  }
2886
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
3249
+ if (!whole && frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2887
3250
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
2888
3251
  const data = bytes.subarray(at, at + frameSize);
2889
3252
  const frame = at + frameSize >= bytes.byteLength ? {
@@ -2913,10 +3276,11 @@
2913
3276
  record.markClosed(reason);
2914
3277
  });
2915
3278
  function release(reason) {
3279
+ freePartial(open.get(id));
2916
3280
  open.delete(id);
2917
3281
  leaks?.unregister(record);
2918
3282
  if (c?.queue.length) {
2919
- for (const item of c.queue.splice(0)) item.send();
3283
+ for (const item of c.queue.splice(0)) item.send(true);
2920
3284
  c.queued = 0;
2921
3285
  }
2922
3286
  sendOnPort(id, () => post({
@@ -2946,6 +3310,38 @@
2946
3310
  });
2947
3311
  })
2948
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
+ }));
2949
3345
  }
2950
3346
  const record = {
2951
3347
  deliver: self.deliver,
@@ -2997,16 +3393,24 @@
2997
3393
  }
2998
3394
  const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
2999
3395
  const credit = open.get(envelope.id)?.credit;
3000
- if (credit) post(credit.exact ? {
3001
- type: "credit",
3002
- id: envelope.id,
3003
- bytes: 0,
3004
- exact: true
3005
- } : {
3006
- type: "credit",
3007
- id: envelope.id,
3008
- bytes: 0
3009
- });
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
+ }
3010
3414
  let accepted = false;
3011
3415
  if (onPort) try {
3012
3416
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -3034,6 +3438,7 @@
3034
3438
  c.exact = false;
3035
3439
  c.outstanding = 0;
3036
3440
  }
3441
+ c.caps = sharedCaps(caps, envelope.caps) ?? /* @__PURE__ */ new Set();
3037
3442
  post({
3038
3443
  type: "credit",
3039
3444
  id: envelope.id,
@@ -3045,6 +3450,10 @@
3045
3450
  flush(c);
3046
3451
  return;
3047
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
+ }
3048
3457
  if (envelope.type === "message") {
3049
3458
  const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
3050
3459
  if (!entry) return;
@@ -3055,15 +3464,29 @@
3055
3464
  if (envelope.type === "frame") {
3056
3465
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
3057
3466
  touch(envelope.id);
3467
+ const c = entry.credit;
3468
+ const owed = c.unconsumed + c.toGrant;
3058
3469
  const partial = entry.partial ?? {
3059
3470
  chunks: [],
3060
- bytes: 0
3471
+ bytes: 0,
3472
+ owed,
3473
+ charged: 0,
3474
+ ...owed > 0 ? { room: c.window - owed } : {}
3061
3475
  };
3062
3476
  entry.partial = partial;
3477
+ const size = envelope.data.byteLength;
3478
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
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;
3480
+ if (overrun) {
3481
+ failPort(envelope.id, entry, /* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`), "max-reassembled-bytes");
3482
+ return;
3483
+ }
3484
+ partial.bytes += size;
3485
+ reassembling += charged - partial.charged;
3486
+ partial.charged = charged;
3063
3487
  partial.chunks.push(envelope.data);
3064
- partial.bytes += envelope.data.byteLength;
3065
3488
  if (!envelope.last) return;
3066
- entry.partial = void 0;
3489
+ freePartial(entry);
3067
3490
  const whole = new Uint8Array(partial.bytes);
3068
3491
  let at = 0;
3069
3492
  for (const chunk of partial.chunks) {
@@ -3075,8 +3498,22 @@
3075
3498
  entry.handle.deliver(inner.payload, whole.byteLength);
3076
3499
  return;
3077
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
+ }
3078
3514
  dropOutgoing(envelope.id);
3079
3515
  if (!entry) return;
3516
+ freePartial(entry);
3080
3517
  open.delete(envelope.id);
3081
3518
  releaseWaiters(entry.credit);
3082
3519
  entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
@@ -3155,12 +3592,14 @@
3155
3592
  id,
3156
3593
  meta,
3157
3594
  window,
3158
- exact: true
3595
+ exact: true,
3596
+ caps: [...caps]
3159
3597
  } : {
3160
3598
  type: "open",
3161
3599
  id,
3162
3600
  meta,
3163
- window
3601
+ window,
3602
+ caps: [...caps]
3164
3603
  } : {
3165
3604
  type: "open",
3166
3605
  id,
@@ -3267,14 +3706,17 @@
3267
3706
  const mux = await factory((port) => {
3268
3707
  let off;
3269
3708
  let finished = false;
3270
- const tracked = (input) => (async function* () {
3271
- try {
3272
- yield* handler(input);
3273
- } finally {
3274
- finished = true;
3275
- if (off) live.delete(off);
3276
- }
3277
- })();
3709
+ const tracked = (input) => {
3710
+ const output = handler(input);
3711
+ return returningAtOnce((async function* () {
3712
+ try {
3713
+ yield* output;
3714
+ } finally {
3715
+ finished = true;
3716
+ if (off) live.delete(off);
3717
+ }
3718
+ })(), () => void output.return(void 0).catch(() => {}));
3719
+ };
3278
3720
  off = serveDuplexOverPort(port, tracked, streamOptions);
3279
3721
  if (!finished) live.add(off);
3280
3722
  });
@@ -3312,7 +3754,7 @@
3312
3754
  const candidate = value;
3313
3755
  if (typeof candidate.id !== "number") return false;
3314
3756
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
3315
- 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";
3316
3758
  }
3317
3759
  /**
3318
3760
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -3324,6 +3766,7 @@
3324
3766
  * not send anything a byte codec could not also carry.
3325
3767
  */
3326
3768
  const structuredCodec = {
3769
+ halfClose: true,
3327
3770
  post(port, envelope, transfer) {
3328
3771
  if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
3329
3772
  else port.postMessage(envelope);
@@ -3361,10 +3804,15 @@
3361
3804
  * own `handleStreams` / `sendStream` (no backpressure); version 2 is
3362
3805
  * webrun-rpc's `connect` / `serve` over `overPipe` with webrun-http-streams'
3363
3806
  * `fetchOverDuplex` / `serveFetchOverDuplex`: one port per link, calls
3364
- * multiplexed on it, each call a stream with a one-chunk window in both
3365
- * 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.
3366
3812
  */
3367
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;
3368
3816
  /**
3369
3817
  * Why a request failed when its link went away under it: the far side (the
3370
3818
  * handler, or the ServiceWorker) was lost. The request may or may not have
@@ -3383,12 +3831,13 @@
3383
3831
  */
3384
3832
  const LINK_CLOSE = "webrun-http:link-close";
3385
3833
  /** Opens the calling end of a link on `port` (one port per link). */
3386
- async function connectHttpLink(port, { streamTimeout, maxPorts } = {}) {
3834
+ async function connectHttpLink(port, { streamTimeout, maxPorts, window = DEFAULT_HTTP_LINK_WINDOW } = {}) {
3387
3835
  const { call, close } = await connect({
3388
3836
  mux: overPipe(port, {
3389
3837
  codec: structuredCodec,
3390
3838
  side: "initiator",
3391
- maxPorts
3839
+ maxPorts,
3840
+ window
3392
3841
  }),
3393
3842
  timeout: streamTimeout
3394
3843
  });
@@ -3699,6 +4148,7 @@
3699
4148
  all.set("connection", "Upgrade");
3700
4149
  all.set("sec-websocket-version", "13");
3701
4150
  if (!all.has("sec-websocket-key")) all.set("sec-websocket-key", randomKey());
4151
+ all.delete("sec-websocket-protocol");
3702
4152
  if (protocols.length > 0) all.set("sec-websocket-protocol", protocols.join(", "));
3703
4153
  if (!all.has("x-webrun-ws-version")) all.set(WS_VERSION_HEADER, `2`);
3704
4154
  return withHeaders(new Request(httpUrl, {
@@ -3831,6 +4281,21 @@
3831
4281
  const NEED_LINKS_CALL = "webrun-http:need-links";
3832
4282
  //#endregion
3833
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
+ }
3834
4299
  /** One attached handler link, as the ServiceWorker holds it. */
3835
4300
  var HandlerEntry = class {
3836
4301
  key;
@@ -3870,7 +4335,8 @@
3870
4335
  * (`SwHttpAdapter.register`) or in Dedicated Workers
3871
4336
  * (`SwHttpAdapter.attachWorker` + `serveHttpInWorker`).
3872
4337
  *
3873
- * 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
3874
4340
  * `route` option picks one per request. Requests and responses stream with
3875
4341
  * backpressure in both directions, and an aborted fetch reaches the handler
3876
4342
  * as a cancellation (webrun-rpc + webrun-http-streams).
@@ -3889,6 +4355,8 @@
3889
4355
  */
3890
4356
  var SwHttpDispatcher = class {
3891
4357
  self;
4358
+ /** `self`, with the WebWorker lib's types this module compiles with. */
4359
+ _sw;
3892
4360
  log;
3893
4361
  options;
3894
4362
  table = new HandlerTable();
@@ -3911,19 +4379,22 @@
3911
4379
  constructor(options) {
3912
4380
  this.options = options;
3913
4381
  this.self = options.self;
4382
+ this._sw = options.self;
3914
4383
  this.log = options.log ?? (() => {});
3915
4384
  if (locksAvailable()) this.routerLock = newLockName("webrun-http-router");
3916
4385
  }
3917
4386
  get scope() {
3918
- return `${new URL("./", this.self.location.href)}`;
4387
+ return this._sw.registration?.scope ?? `${new URL("./", this._sw.location.href)}`;
3919
4388
  }
3920
- /** 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). */
3921
4390
  keyOf(url) {
3922
- const href = `${url}`;
3923
- const scope = this.scope;
3924
- if (!href.startsWith(scope)) return void 0;
3925
- const key = href.substring(scope.length).replace(/[?#].*$/, "").split("/")[0];
3926
- 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);
3927
4398
  }
3928
4399
  /** Whether `key` has ever been served on this worker. */
3929
4400
  isClaimedKey(key) {
@@ -3934,7 +4405,7 @@
3934
4405
  return this.claimedKeys.has(key) || this.table.all(key).length > 0;
3935
4406
  }
3936
4407
  start() {
3937
- const self = this.self;
4408
+ const self = this._sw;
3938
4409
  if (this.routerLock) this._routerHeld = holdLock(this.routerLock).then(() => {});
3939
4410
  this._restored = this._restoreClaimedKeys();
3940
4411
  const latch = () => {
@@ -3954,8 +4425,8 @@
3954
4425
  return true;
3955
4426
  }), handleChannelCalls(self, WS_CONNECT_CALL, async (event, params) => {
3956
4427
  if (!this._isOwnClient(event)) throw new Error("Only this origin's pages may open sockets");
3957
- const { url, protocols } = params;
3958
- 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"));
3959
4430
  return withTransfer(connection, [connection.port]);
3960
4431
  }));
3961
4432
  const onMessage = (event) => {
@@ -3973,10 +4444,10 @@
3973
4444
  this.log("Claim control over all clients.");
3974
4445
  event.waitUntil(self.clients.claim());
3975
4446
  };
3976
- const onFetch = (event) => this._onFetch(event);
4447
+ const onFetch = (event) => void this.handleFetch(event);
3977
4448
  self.addEventListener("install", onInstall);
3978
4449
  self.addEventListener("activate", onActivate);
3979
- self.addEventListener("fetch", onFetch);
4450
+ if (this.options.handleFetch !== false) self.addEventListener("fetch", onFetch);
3980
4451
  this._cleanup.push(() => {
3981
4452
  self.removeEventListener("install", onInstall);
3982
4453
  self.removeEventListener("activate", onActivate);
@@ -3991,37 +4462,60 @@
3991
4462
  }
3992
4463
  /**
3993
4464
  * Answers `request` as a fetch under the scope would be: through the
3994
- * handler its key routes to. A url outside the scope, or of a key this
3995
- * 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.
3996
4467
  */
3997
4468
  async dispatch(request) {
3998
4469
  const key = this.keyOf(request.url);
3999
4470
  if (key === void 0) return await fetch(request);
4000
4471
  await this._restored.catch(() => {});
4001
4472
  const entry = await this._acquire(request, key);
4002
- if (entry === TIMED_OUT) return new Response(null, {
4003
- status: 503,
4004
- statusText: "Service Unavailable (no handler attached in time)"
4005
- });
4473
+ if (entry === TIMED_OUT) return await this._failed(request, { kind: "timeout" });
4006
4474
  if (!entry) {
4007
4475
  if (!this.serves(key)) return await fetch(request);
4008
- return new Response(null, {
4009
- status: 404,
4010
- statusText: "Not Found (no active handler)"
4011
- });
4476
+ return await this._failed(request, { kind: "gone" });
4012
4477
  }
4013
4478
  try {
4014
4479
  return await entry.link.send(request);
4015
4480
  } catch (error) {
4016
4481
  this.log("[dispatch] failed", key, error);
4017
- return new Response(errorMessage(error), {
4018
- status: 502,
4019
- statusText: "Bad Gateway",
4020
- headers: { "content-type": "text/plain; charset=utf-8" }
4482
+ return await this._failed(request, {
4483
+ kind: "lost",
4484
+ error
4021
4485
  });
4022
4486
  }
4023
4487
  }
4024
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
+ /**
4025
4519
  * Opens a socket to the handler `url` routes to: the handler accepts or
4026
4520
  * refuses it like an HTTP upgrade, and the socket's port comes back. After
4027
4521
  * that the socket runs between the caller and the handler directly; this
@@ -4033,7 +4527,7 @@
4033
4527
  headers
4034
4528
  });
4035
4529
  const key = this.keyOf(request.url);
4036
- 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`);
4037
4531
  await this._restored.catch(() => {});
4038
4532
  const entry = await this._acquire(request, key);
4039
4533
  if (!entry || entry === TIMED_OUT) throw new Error(`No handler serves ${url}`);
@@ -4049,20 +4543,47 @@
4049
4543
  });
4050
4544
  return await answer;
4051
4545
  }
4052
- _onFetch(event) {
4053
- 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;
4054
4564
  const key = this.keyOf(request.url);
4055
- if (key === void 0) return;
4565
+ if (key === void 0) return false;
4056
4566
  if (this._restoreSettled) {
4057
- if (this.serves(key)) event.respondWith(this.dispatch(request));
4058
- else if (request.mode === "navigate") event.respondWith(fetch(request));
4059
- 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;
4060
4576
  }
4061
4577
  event.respondWith((async () => {
4062
4578
  await this._restored.catch(() => {});
4063
- if (!this.serves(key)) return await fetch(request);
4064
- 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);
4065
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);
4066
4587
  }
4067
4588
  async _attach(event, params, ports) {
4068
4589
  const clientId = event.source?.id;
@@ -4081,8 +4602,11 @@
4081
4602
  }
4082
4603
  const ctl = params.ctl ? rest.shift() : void 0;
4083
4604
  const up = params.up ? rest.shift() : void 0;
4084
- const streamTimeout = this.options.streamTimeout;
4085
- const link = await connectHttpLink(down, { streamTimeout });
4605
+ const { streamTimeout, window } = this.options;
4606
+ const link = await connectHttpLink(down, {
4607
+ streamTimeout,
4608
+ window
4609
+ });
4086
4610
  const entry = new HandlerEntry(params.key, params.id ?? clientId ?? crypto.randomUUID(), params.owner ?? crypto.randomUUID(), params.meta, link, ctl, clientId, params.ownerLock);
4087
4611
  if (up) entry.closeUp = await serveHttpLink(up, (request) => this.dispatch(request), { streamTimeout });
4088
4612
  if (ctl) ctl.onmessage = (message) => this._onControl(entry, message.data);
@@ -4161,20 +4685,35 @@
4161
4685
  * its key, waiting for one when there is none.
4162
4686
  */
4163
4687
  async _acquire(request, key) {
4688
+ const deadline = Date.now() + (this.options.attachTimeoutMs ?? 15e3);
4164
4689
  const picked = await this._pick(request, key);
4165
4690
  if (picked) return picked;
4166
- 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;
4167
- const vacantUntil = this._vacant.get(key) ?? 0;
4168
- if (vacantUntil > Date.now()) return await this.table.waitFor(key, vacantUntil - Date.now()) ? await this._pick(request, key) ?? TIMED_OUT : TIMED_OUT;
4169
- if (!this.serves(key)) return void 0;
4170
- await this._askForLinks(key);
4171
- 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
+ }
4172
4711
  }
4173
4712
  async _pick(request, key) {
4174
4713
  let live = this.table.live(key);
4175
4714
  for (const entry of live) {
4176
4715
  if (entry.ownerLock || !entry.clientId) continue;
4177
- 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));
4178
4717
  }
4179
4718
  live = this.table.live(key);
4180
4719
  if (live.length === 0) return void 0;
@@ -4198,7 +4737,7 @@
4198
4737
  }
4199
4738
  async _linkRound(key) {
4200
4739
  const timeout = this.options.relinkTimeoutMs ?? 3e3;
4201
- const windows = await this.self.clients.matchAll({
4740
+ const windows = await this._sw.clients.matchAll({
4202
4741
  type: "window",
4203
4742
  includeUncontrolled: true
4204
4743
  });
@@ -4236,7 +4775,7 @@
4236
4775
  entry.sockets.fail(message.id, new Error(message.message));
4237
4776
  return;
4238
4777
  case "ws-open":
4239
- 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({
4240
4779
  t: "ws-opened",
4241
4780
  id: message.id,
4242
4781
  protocol,
@@ -4253,7 +4792,7 @@
4253
4792
  const source = event.source;
4254
4793
  if (!source?.url) return false;
4255
4794
  try {
4256
- return new URL(source.url).origin === this.self.location.origin;
4795
+ return new URL(source.url).origin === this._sw.location.origin;
4257
4796
  } catch {
4258
4797
  return false;
4259
4798
  }
@@ -4277,6 +4816,7 @@
4277
4816
  * that stops it.
4278
4817
  */
4279
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()");
4280
4820
  const dispatcher = new SwHttpDispatcher(options);
4281
4821
  dispatcher.start();
4282
4822
  return () => {