@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
@@ -1688,8 +1688,9 @@ const CLAIM_CALL = "CLAIM";
1688
1688
  * Returns a function that stops answering.
1689
1689
  */
1690
1690
  function handleClaimRequests(self) {
1691
- return handleChannelCalls(self, CLAIM_CALL, (event) => {
1692
- const claimed = self.clients.claim().then(() => true);
1691
+ const sw = self;
1692
+ return handleChannelCalls(sw, CLAIM_CALL, (event) => {
1693
+ const claimed = sw.clients.claim().then(() => true);
1693
1694
  event.waitUntil?.(claimed);
1694
1695
  return claimed;
1695
1696
  });
@@ -1851,6 +1852,99 @@ async function* throughAbort(input, signal) {
1851
1852
  }
1852
1853
  }
1853
1854
  /**
1855
+ * Runs `fn` on a later task, never a microtask: a producer looping on
1856
+ * `whenPortReady` must let the transport's own events run. `setImmediate`
1857
+ * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
1858
+ * are clamped to 4 ms in browsers.
1859
+ */
1860
+ const setImmediateFn = globalThis.setImmediate;
1861
+ /** @internal */
1862
+ const nextTask = setImmediateFn ? (fn) => {
1863
+ setImmediateFn(fn);
1864
+ } : (() => {
1865
+ let channel;
1866
+ const queue = [];
1867
+ return (fn) => {
1868
+ if (!channel) {
1869
+ channel = new MessageChannel();
1870
+ channel.port1.onmessage = () => queue.shift()?.();
1871
+ }
1872
+ queue.push(fn);
1873
+ channel.port2.postMessage(0);
1874
+ };
1875
+ })();
1876
+ const states = /* @__PURE__ */ new WeakMap();
1877
+ /** @internal Registered by the multiplexer for each port with a window. */
1878
+ function setPortSendState(port, state) {
1879
+ states.set(port, state);
1880
+ }
1881
+ /**
1882
+ * Bytes queued on `port` because its window is spent (0 for a port without
1883
+ * flow control). A sender that should not queue without bound waits for
1884
+ * `whenPortReady` before posting more.
1885
+ */
1886
+ function portBufferedAmount(port) {
1887
+ return states.get(port)?.bufferedAmount() ?? 0;
1888
+ }
1889
+ /**
1890
+ * Resolves once at most `below` bytes are queued on `port`, always on a later
1891
+ * task (for a port without flow control, on the next one).
1892
+ */
1893
+ function whenPortReady(port, below = 0) {
1894
+ return states.get(port)?.ready(below) ?? new Promise((resolve) => nextTask(resolve));
1895
+ }
1896
+ const agreements = /* @__PURE__ */ new WeakMap();
1897
+ /** @internal Registered by the multiplexer for each port with a window. */
1898
+ function setPortAgreement(port, agreement) {
1899
+ agreements.set(port, agreement);
1900
+ }
1901
+ /**
1902
+ * @internal What has been agreed with the peer on `port` so far; `undefined`
1903
+ * on a port without a window. Not exported from the package.
1904
+ */
1905
+ function portAgreement(port) {
1906
+ return agreements.get(port)?.();
1907
+ }
1908
+ const ends = /* @__PURE__ */ new WeakMap();
1909
+ /** @internal Registered by the multiplexer for each port with a window. */
1910
+ function setPortEndState(port, state) {
1911
+ ends.set(port, state);
1912
+ }
1913
+ /**
1914
+ * @internal End this side's direction of `port`: an `end` is queued behind
1915
+ * everything already posted on it, and anything posted afterwards is dropped.
1916
+ * `false`, and nothing changes, when `end` was not agreed (no window, the
1917
+ * handshake not answered yet, or a peer that does not speak it): the caller
1918
+ * then closes the port. Never waits on the peer. Not exported from the package.
1919
+ */
1920
+ function endPort(port) {
1921
+ return ends.get(port)?.end() ?? false;
1922
+ }
1923
+ /**
1924
+ * @internal Resolves once the peer has ended its direction of `port`. It never
1925
+ * settles otherwise: not on a port where `end` is not agreed when it is asked
1926
+ * (no window, the handshake not answered yet, a peer or codec without it), and
1927
+ * not when the port closes first, so a caller races it against the port's
1928
+ * close signal. Not exported from the package.
1929
+ */
1930
+ function whenPortEnded(port) {
1931
+ let waiting = peerEnds.get(port);
1932
+ if (!waiting) {
1933
+ let resolve = () => {};
1934
+ const promise = new Promise((r) => {
1935
+ resolve = r;
1936
+ });
1937
+ if (!ends.get(port)?.watchPeerEnd(resolve)) return promise;
1938
+ waiting = {
1939
+ promise,
1940
+ resolve
1941
+ };
1942
+ peerEnds.set(port, waiting);
1943
+ }
1944
+ return waiting.promise;
1945
+ }
1946
+ const peerEnds = /* @__PURE__ */ new WeakMap();
1947
+ /**
1854
1948
  * Makes `target` disposable with `await using`: installs `dispose` as its
1855
1949
  * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
1856
1950
  * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
@@ -1922,6 +2016,61 @@ function returningAtOnce(gen, abandon) {
1922
2016
  * tests and adapters assert on it.
1923
2017
  */
1924
2018
  const STREAM_ABORT = "webrun-rpc:stream-abort";
2019
+ /**
2020
+ * The `type` of a pipelined chunk: a plain port message, not a `callPort`
2021
+ * request, sent only on a port where both ends agreed `pipeline`. The window's
2022
+ * credit is its confirmation.
2023
+ */
2024
+ const CHUNK = "webrun-rpc:chunk";
2025
+ /**
2026
+ * What a pipelined chunk counts beyond its bytes, against the window, on the
2027
+ * receiving side. Both codecs' windows count more for the chunk's envelope
2028
+ * (the estimate at least 64, msgpack's keys alone more than this), so an honest
2029
+ * sender never makes the receiver count past the window; a sender of empty
2030
+ * chunks still cannot have it hold an unbounded number of them.
2031
+ */
2032
+ const CHUNK_OVERHEAD = 32;
2033
+ /**
2034
+ * The bytes of `view` as a plain `Uint8Array` that owns its whole buffer:
2035
+ * `view` itself when it already is one, else a copy. Structured clone carries
2036
+ * and keeps a view's whole `ArrayBuffer`, and a byte codec may decode into
2037
+ * views of the frame, while the window and the receiver's count see only the
2038
+ * view: a 4 KiB `subarray` of a 4 MiB buffer would cost 4 MiB per chunk.
2039
+ * Copied with the `Uint8Array` constructor, not `slice()`: a Node `Buffer`'s
2040
+ * `slice()` is another view on the same memory.
2041
+ */
2042
+ function ownBytes(view) {
2043
+ if (view instanceof Uint8Array && view.constructor === Uint8Array && view.byteOffset === 0 && view.byteLength === view.buffer.byteLength) return view;
2044
+ return new Uint8Array(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
2045
+ }
2046
+ /**
2047
+ * A producer's chunk as bytes this end may send, in either mode: any view or
2048
+ * `ArrayBuffer`, normalised by `ownBytes`. Anything else fails the stream here,
2049
+ * as the producer's own error, rather than reaching a peer that would refuse it.
2050
+ */
2051
+ /**
2052
+ * A received confirmed chunk's value, as its own bytes, put back in place of
2053
+ * the original in `chunk`: `listenPort` keeps the request until the handler
2054
+ * returns, and the original would keep its whole buffer that long. A function
2055
+ * of its own, so that no temporary of the suspended handler holds the
2056
+ * original either.
2057
+ */
2058
+ function adoptValue(chunk) {
2059
+ const original = chunk.value;
2060
+ if (ArrayBuffer.isView(original)) chunk.value = ownBytes(original);
2061
+ return chunk.value;
2062
+ }
2063
+ function chunkBytes(value) {
2064
+ if (ArrayBuffer.isView(value)) return ownBytes(value);
2065
+ if (value instanceof ArrayBuffer || Object.prototype.toString.call(value) === "[object ArrayBuffer]") return new Uint8Array(value);
2066
+ 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}`);
2067
+ }
2068
+ const MIXED_MESSAGE = "webrun-rpc: peer mixed confirmed and pipelined chunks; the stream port is closed";
2069
+ /** This end may pipeline its own chunks: agreed, and the window holds them. */
2070
+ const mayPipeline = (port) => {
2071
+ const agreement = portAgreement(port);
2072
+ return !!agreement?.enforced && agreement.caps.has("pipeline");
2073
+ };
1925
2074
  /** Caller's input travels on this channel; the handler listens on it. */
1926
2075
  const CHANNEL_IN = "in";
1927
2076
  /** The handler's output travels on this channel; the caller listens on it. */
@@ -1934,7 +2083,9 @@ const CHANNEL_OUT = "out";
1934
2083
  * same way on the other channel. Within each direction the next chunk is never
1935
2084
  * sent until the previous one has been delivered *and* pulled past by the
1936
2085
  * consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
1937
- * `listenPort` withholds it until then.
2086
+ * `listenPort` withholds it until then. On a port whose window both ends
2087
+ * speak and that agreed `pipeline`, chunks go as plain messages instead, and
2088
+ * the window's credit is the confirmation: up to a window is in flight.
1938
2089
  *
1939
2090
  * A stream port carries exactly one invocation. To make several calls, open
1940
2091
  * several ports: `mux.openPort({ kind: "stream" })` per call.
@@ -1947,22 +2098,31 @@ function runCallerSide(port, input, options) {
1947
2098
  const notice = installAbortNotice(port, controller);
1948
2099
  abortOnPortClose(port, controller);
1949
2100
  const clock = installStreamTimeout(controller, options.timeout);
1950
- const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
1951
- const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
2101
+ const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch, notice);
2102
+ let sent = false;
2103
+ const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch, () => {
2104
+ sent = true;
2105
+ });
1952
2106
  pump.catch(() => {});
1953
2107
  return returningAtOnce((async function* () {
2108
+ let received = false;
1954
2109
  try {
1955
2110
  yield* inbound.stream;
2111
+ received = true;
1956
2112
  } finally {
2113
+ const ended = received && sent;
1957
2114
  if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
1958
- notice.post();
2115
+ if (!ended) notice.post();
1959
2116
  notice.stop();
1960
2117
  clock.stop();
1961
2118
  inbound.stop();
1962
2119
  await pump.catch(() => {});
1963
- try {
1964
- await port.close?.();
1965
- } catch {}
2120
+ if (!(ended && endPort(port))) {
2121
+ notice.post();
2122
+ try {
2123
+ await port.close?.();
2124
+ } catch {}
2125
+ }
1966
2126
  }
1967
2127
  })(), () => {
1968
2128
  if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
@@ -2056,7 +2216,7 @@ function installStreamTimeout(controller, timeout) {
2056
2216
  * the local consumer has not started iterating, an inbound chunk waits for it —
2057
2217
  * which is the correct backpressure, and different from having no listener.
2058
2218
  */
2059
- function receiveChunks(port, channelName, controller, touch) {
2219
+ function receiveChunks(port, channelName, controller, touch, notice) {
2060
2220
  let deliver;
2061
2221
  const waiting = [];
2062
2222
  let finished = false;
@@ -2066,25 +2226,64 @@ function receiveChunks(port, channelName, controller, touch) {
2066
2226
  const ended = new Promise((resolve) => {
2067
2227
  resolveEnded = resolve;
2068
2228
  });
2229
+ let resolveOver = () => {};
2230
+ const over = new Promise((resolve) => {
2231
+ resolveOver = resolve;
2232
+ });
2069
2233
  let aborted = false;
2070
2234
  let abortReason;
2071
2235
  const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
2072
2236
  const wake = () => {
2073
2237
  for (const r of waiting.splice(0)) r();
2074
2238
  };
2075
- const off = listenPort(port, async ({ done, value, error }) => {
2239
+ const violate = (message) => {
2240
+ poison = new Error(message);
2241
+ controller.abort(poison);
2242
+ setTimeout(() => {
2243
+ try {
2244
+ port.close?.();
2245
+ } catch {}
2246
+ }, 0);
2247
+ return poison;
2248
+ };
2249
+ let lastChunkArrived = false;
2250
+ let watching = false;
2251
+ const watchPeerEnd = () => {
2252
+ if (watching) return true;
2253
+ if (!portAgreement(port)?.caps.has("end")) return false;
2254
+ watching = true;
2255
+ whenPortEnded(port).then(() => {
2256
+ port.removeEventListener("message", watcher);
2257
+ if (lastChunkArrived) {
2258
+ resolveOver();
2259
+ return;
2260
+ }
2261
+ notice.post();
2262
+ violate("webrun-rpc: the peer ended its direction without finishing it; the stream port is closed");
2263
+ });
2264
+ return true;
2265
+ };
2266
+ const watcher = (event) => {
2267
+ const data = event.data;
2268
+ if (data?.channelName !== channelName) return;
2269
+ const chunk = data.type === CHUNK ? data.chunk : data.type === "request" ? data.params : void 0;
2270
+ if (!chunk) return;
2271
+ if (!watchPeerEnd()) {
2272
+ port.removeEventListener("message", watcher);
2273
+ return;
2274
+ }
2275
+ if (chunk.done && !chunk.error) lastChunkArrived = true;
2276
+ };
2277
+ port.addEventListener("message", watcher);
2278
+ watchPeerEnd();
2279
+ let held = 0;
2280
+ const off = listenPort(port, async (params) => {
2281
+ const { done, error } = params;
2282
+ const value = adoptValue(params);
2076
2283
  touch();
2077
2284
  if (poison) throw poison;
2078
- if (outstanding) {
2079
- poison = /* @__PURE__ */ new Error("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
2080
- controller.abort(poison);
2081
- setTimeout(() => {
2082
- try {
2083
- port.close?.();
2084
- } catch {}
2085
- }, 0);
2086
- throw poison;
2087
- }
2285
+ if (held > 0) throw violate(MIXED_MESSAGE);
2286
+ if (outstanding) throw violate("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
2088
2287
  outstanding = true;
2089
2288
  try {
2090
2289
  await ready();
@@ -2094,15 +2293,60 @@ function receiveChunks(port, channelName, controller, touch) {
2094
2293
  value,
2095
2294
  error: error ? deserializeError(error) : void 0
2096
2295
  });
2296
+ if (done && !error) resolveOver();
2097
2297
  } finally {
2098
2298
  outstanding = false;
2099
2299
  }
2100
2300
  if (done) resolveEnded();
2101
2301
  }, { channelName });
2302
+ const onChunk = (event) => {
2303
+ const data = event.data;
2304
+ if (data?.type !== CHUNK || data.channelName !== channelName || !data.chunk) return;
2305
+ if (poison) return;
2306
+ const agreement = portAgreement(port);
2307
+ if (!agreement?.caps.has("pipeline")) {
2308
+ violate("webrun-rpc: peer sent a pipelined chunk where pipelining was not agreed");
2309
+ return;
2310
+ }
2311
+ if (outstanding) {
2312
+ violate(MIXED_MESSAGE);
2313
+ return;
2314
+ }
2315
+ if (data.chunk.value !== void 0 && !ArrayBuffer.isView(data.chunk.value)) {
2316
+ violate("webrun-rpc: peer sent a pipelined chunk whose value is not bytes");
2317
+ return;
2318
+ }
2319
+ if (held > agreement.window) {
2320
+ violate(`webrun-rpc: peer sent past the window (${agreement.window} bytes) without waiting for credit; the stream port is closed`);
2321
+ return;
2322
+ }
2323
+ touch();
2324
+ const { done, error } = data.chunk;
2325
+ const value = data.chunk.value && ownBytes(data.chunk.value);
2326
+ const size = (value?.byteLength ?? 0) + CHUNK_OVERHEAD;
2327
+ held += size;
2328
+ return (async () => {
2329
+ try {
2330
+ await ready();
2331
+ if (finished) return;
2332
+ await deliver?.({
2333
+ done,
2334
+ value,
2335
+ error: error ? deserializeError(error) : void 0
2336
+ });
2337
+ if (done && !error) resolveOver();
2338
+ if (done) resolveEnded();
2339
+ } finally {
2340
+ held -= size;
2341
+ }
2342
+ })();
2343
+ };
2344
+ port.addEventListener("message", onChunk);
2102
2345
  const detach = () => {
2103
2346
  finished = true;
2104
2347
  wake();
2105
2348
  off();
2349
+ port.removeEventListener("message", onChunk);
2106
2350
  controller.signal.removeEventListener("abort", onAbort);
2107
2351
  resolveEnded();
2108
2352
  };
@@ -2132,25 +2376,35 @@ function receiveChunks(port, channelName, controller, touch) {
2132
2376
  return detach;
2133
2377
  }),
2134
2378
  stop: detach,
2135
- ended
2379
+ ended,
2380
+ over
2136
2381
  };
2137
2382
  }
2138
2383
  /**
2139
2384
  * The sending half of one direction: one `callPort` per chunk, one call
2140
- * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
2385
+ * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8) —
2386
+ * or, once pipelining is agreed, plain chunk messages held back by the window.
2387
+ * `onSent` runs once the end of the output has left without an error: posted
2388
+ * when pipelined, confirmed when not.
2141
2389
  */
2142
- async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
2390
+ async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch, onSent) {
2143
2391
  const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
2144
2392
  const stream = throughAbort(framed, signal);
2145
2393
  const producer = output;
2146
2394
  const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
2147
2395
  if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
2396
+ let onAborted = () => {};
2397
+ const aborted = new Promise((resolve) => {
2398
+ onAborted = () => resolve();
2399
+ });
2400
+ if (signal.aborted) onAborted();
2401
+ else signal.addEventListener("abort", onAborted, { once: true });
2148
2402
  try {
2149
2403
  await sendIterator(async ({ done, value, error }) => {
2150
2404
  if (signal.aborted) return;
2151
2405
  const chunk = {
2152
2406
  done,
2153
- value,
2407
+ value: done ? value : chunkBytes(value),
2154
2408
  error: error === void 0 ? void 0 : serializeError(error)
2155
2409
  };
2156
2410
  log?.("[duplexOverPort] send", {
@@ -2158,48 +2412,34 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
2158
2412
  done,
2159
2413
  size: value?.byteLength
2160
2414
  });
2415
+ if (mayPipeline(port)) {
2416
+ port.postMessage({
2417
+ type: CHUNK,
2418
+ channelName,
2419
+ chunk
2420
+ });
2421
+ touch();
2422
+ if (done && !error) onSent();
2423
+ if (portBufferedAmount(port) > 0) await Promise.race([whenPortReady(port), aborted]);
2424
+ return;
2425
+ }
2161
2426
  await callPort(port, chunk, {
2162
2427
  channelName,
2163
2428
  timeout: NO_TIMEOUT,
2164
2429
  signal
2165
2430
  });
2166
2431
  touch();
2432
+ if (done && !error) onSent();
2167
2433
  }, stream);
2168
2434
  } catch (err) {
2169
2435
  if (signal.aborted) return;
2170
2436
  throw err;
2171
2437
  } finally {
2172
2438
  signal.removeEventListener("abort", returnProducer);
2439
+ signal.removeEventListener("abort", onAborted);
2173
2440
  }
2174
2441
  }
2175
2442
  /**
2176
- * Runs `fn` on a later task, never a microtask: a producer looping on
2177
- * `whenPortReady` must let the transport's own events run. `setImmediate`
2178
- * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
2179
- * are clamped to 4 ms in browsers.
2180
- */
2181
- const setImmediateFn = globalThis.setImmediate;
2182
- /** @internal */
2183
- const nextTask = setImmediateFn ? (fn) => {
2184
- setImmediateFn(fn);
2185
- } : (() => {
2186
- let channel;
2187
- const queue = [];
2188
- return (fn) => {
2189
- if (!channel) {
2190
- channel = new MessageChannel();
2191
- channel.port1.onmessage = () => queue.shift()?.();
2192
- }
2193
- queue.push(fn);
2194
- channel.port2.postMessage(0);
2195
- };
2196
- })();
2197
- const states = /* @__PURE__ */ new WeakMap();
2198
- /** @internal Registered by the multiplexer for each port with a window. */
2199
- function setPortSendState(port, state) {
2200
- states.set(port, state);
2201
- }
2202
- /**
2203
2443
  * One virtual port.
2204
2444
  *
2205
2445
  * `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
@@ -2277,6 +2517,21 @@ function newVirtualPort(send, requestClose) {
2277
2517
  const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
2278
2518
  /** The longest delay a timer holds; past it `setInterval` fires at once. */
2279
2519
  const MAX_TIMER_MS = 2 ** 31 - 1;
2520
+ /**
2521
+ * What a windowed port offers in its `open`: every capability this end
2522
+ * speaks. `end` only over a codec that reads it (`PortCodec.halfClose`).
2523
+ */
2524
+ function ownCaps(codec) {
2525
+ return codec.halfClose ? ["pipeline", "end"] : ["pipeline"];
2526
+ }
2527
+ /**
2528
+ * The capabilities in a peer's `caps` that this end speaks too, or
2529
+ * `undefined` when the peer listed none (it is then treated as an older peer).
2530
+ */
2531
+ function sharedCaps(own, listed) {
2532
+ if (!Array.isArray(listed)) return void 0;
2533
+ return new Set(own.filter((cap) => listed.includes(cap)));
2534
+ }
2280
2535
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
2281
2536
  const DEFAULT_MAX_PORTS = 1024;
2282
2537
  /**
@@ -2340,6 +2595,8 @@ function multiplexPort(port, options) {
2340
2595
  let muxClosed = false;
2341
2596
  /** This end counts encoded bytes and reassembles frames. */
2342
2597
  const exactCodec = codec.encode != null && codec.decode != null;
2598
+ /** What this end offers on a windowed port. */
2599
+ const caps = ownCaps(codec);
2343
2600
  const post = (envelope, transfer) => {
2344
2601
  if (muxClosed) return;
2345
2602
  codecPost(envelope, transfer);
@@ -2397,13 +2654,39 @@ function multiplexPort(port, options) {
2397
2654
  port.postMessage(bytes);
2398
2655
  } catch {}
2399
2656
  };
2657
+ /**
2658
+ * Both directions have ended: free the id as a close would, with no `close`
2659
+ * on the wire (each end frees it on its own once it has seen both ends).
2660
+ * Aborting the close signal is how the port's consumer learns it is gone.
2661
+ */
2662
+ const freeIfEnded = (id, c) => {
2663
+ const entry = open.get(id);
2664
+ if (!c.endSent || !c.peerEnded || entry?.credit !== c) return;
2665
+ freePartial(entry);
2666
+ open.delete(id);
2667
+ leaks?.unregister(entry.handle);
2668
+ entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port ended in both directions"));
2669
+ };
2670
+ /** A peer that broke the port's protocol: hold none of its traffic, and close. */
2671
+ const failPort = (id, entry, error, reason) => {
2672
+ freePartial(entry);
2673
+ dropOutgoing(id);
2674
+ open.delete(id);
2675
+ releaseWaiters(entry.credit);
2676
+ entry.handle.markClosed(error);
2677
+ post({
2678
+ type: "close",
2679
+ id,
2680
+ reason
2681
+ });
2682
+ };
2400
2683
  const releaseWaiters = (c) => {
2401
2684
  for (const w of c?.waiters.splice(0) ?? []) w.resolve();
2402
2685
  };
2403
2686
  const flush = (c) => {
2404
2687
  while (c.queue.length) {
2405
2688
  const next = c.queue[0];
2406
- if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
2689
+ if (next.size > 0 && c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
2407
2690
  c.queue.shift();
2408
2691
  c.queued -= next.size;
2409
2692
  c.outstanding += next.size;
@@ -2441,15 +2724,20 @@ function multiplexPort(port, options) {
2441
2724
  enforced: false,
2442
2725
  opener,
2443
2726
  exact,
2727
+ caps: /* @__PURE__ */ new Set(),
2444
2728
  outstanding: 0,
2445
2729
  queue: [],
2446
2730
  queued: 0,
2447
2731
  unconsumed: 0,
2448
2732
  waiters: [],
2449
2733
  toGrant: 0,
2450
- grantScheduled: false
2734
+ grantScheduled: false,
2735
+ ending: false,
2736
+ endSent: false,
2737
+ peerEnded: false
2451
2738
  } : void 0;
2452
2739
  const self = newVirtualPort((payload, transfer) => {
2740
+ if (c?.ending) return;
2453
2741
  touch(id);
2454
2742
  const envelope = {
2455
2743
  type: "message",
@@ -2536,6 +2824,38 @@ function multiplexPort(port, options) {
2536
2824
  });
2537
2825
  })
2538
2826
  });
2827
+ setPortEndState(self.port, {
2828
+ end: () => {
2829
+ if (isClosed() || !c.caps.has("end")) return false;
2830
+ if (c.ending) return true;
2831
+ c.ending = true;
2832
+ c.queue.push({
2833
+ size: 0,
2834
+ send: () => sendOnPort(id, () => {
2835
+ if (open.get(id)?.credit !== c) return;
2836
+ post({
2837
+ type: "end",
2838
+ id
2839
+ });
2840
+ c.endSent = true;
2841
+ freeIfEnded(id, c);
2842
+ })
2843
+ });
2844
+ flush(c);
2845
+ return true;
2846
+ },
2847
+ watchPeerEnd: (resolve) => {
2848
+ if (!c.caps.has("end")) return false;
2849
+ if (c.peerEnded) resolve();
2850
+ else c.onPeerEnd = new WeakRef(resolve);
2851
+ return true;
2852
+ }
2853
+ });
2854
+ setPortAgreement(self.port, () => ({
2855
+ window: c.window,
2856
+ caps: c.caps,
2857
+ enforced: c.enforced
2858
+ }));
2539
2859
  }
2540
2860
  const record = {
2541
2861
  deliver: self.deliver,
@@ -2587,16 +2907,24 @@ function multiplexPort(port, options) {
2587
2907
  }
2588
2908
  const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
2589
2909
  const credit = open.get(envelope.id)?.credit;
2590
- if (credit) post(credit.exact ? {
2591
- type: "credit",
2592
- id: envelope.id,
2593
- bytes: 0,
2594
- exact: true
2595
- } : {
2596
- type: "credit",
2597
- id: envelope.id,
2598
- bytes: 0
2599
- });
2910
+ const listed = sharedCaps(caps, envelope.caps);
2911
+ if (credit) {
2912
+ const ack = credit.exact ? {
2913
+ type: "credit",
2914
+ id: envelope.id,
2915
+ bytes: 0,
2916
+ exact: true
2917
+ } : {
2918
+ type: "credit",
2919
+ id: envelope.id,
2920
+ bytes: 0
2921
+ };
2922
+ if (listed) credit.caps = listed;
2923
+ post(listed ? {
2924
+ ...ack,
2925
+ caps: [...listed]
2926
+ } : ack);
2927
+ }
2600
2928
  let accepted = false;
2601
2929
  if (onPort) try {
2602
2930
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -2624,6 +2952,7 @@ function multiplexPort(port, options) {
2624
2952
  c.exact = false;
2625
2953
  c.outstanding = 0;
2626
2954
  }
2955
+ c.caps = sharedCaps(caps, envelope.caps) ?? /* @__PURE__ */ new Set();
2627
2956
  post({
2628
2957
  type: "credit",
2629
2958
  id: envelope.id,
@@ -2635,6 +2964,10 @@ function multiplexPort(port, options) {
2635
2964
  flush(c);
2636
2965
  return;
2637
2966
  }
2967
+ if ((envelope.type === "message" || envelope.type === "frame") && entry?.credit?.peerEnded) {
2968
+ failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer sent on a port after its end"), "protocol");
2969
+ return;
2970
+ }
2638
2971
  if (envelope.type === "message") {
2639
2972
  const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
2640
2973
  if (!entry) return;
@@ -2659,16 +2992,7 @@ function multiplexPort(port, options) {
2659
2992
  const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
2660
2993
  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;
2661
2994
  if (overrun) {
2662
- freePartial(entry);
2663
- dropOutgoing(envelope.id);
2664
- open.delete(envelope.id);
2665
- releaseWaiters(c);
2666
- entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
2667
- post({
2668
- type: "close",
2669
- id: envelope.id,
2670
- reason: "max-reassembled-bytes"
2671
- });
2995
+ failPort(envelope.id, entry, /* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`), "max-reassembled-bytes");
2672
2996
  return;
2673
2997
  }
2674
2998
  partial.bytes += size;
@@ -2688,6 +3012,19 @@ function multiplexPort(port, options) {
2688
3012
  entry.handle.deliver(inner.payload, whole.byteLength);
2689
3013
  return;
2690
3014
  }
3015
+ if (envelope.type === "end") {
3016
+ if (!entry) return;
3017
+ const c = entry.credit;
3018
+ if (!c?.caps.has("end")) {
3019
+ failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer ended a port where end was not agreed"), "protocol");
3020
+ return;
3021
+ }
3022
+ touch(envelope.id);
3023
+ c.peerEnded = true;
3024
+ c.onPeerEnd?.deref()?.();
3025
+ freeIfEnded(envelope.id, c);
3026
+ return;
3027
+ }
2691
3028
  dropOutgoing(envelope.id);
2692
3029
  if (!entry) return;
2693
3030
  freePartial(entry);
@@ -2769,12 +3106,14 @@ function multiplexPort(port, options) {
2769
3106
  id,
2770
3107
  meta,
2771
3108
  window,
2772
- exact: true
3109
+ exact: true,
3110
+ caps: [...caps]
2773
3111
  } : {
2774
3112
  type: "open",
2775
3113
  id,
2776
3114
  meta,
2777
- window
3115
+ window,
3116
+ caps: [...caps]
2778
3117
  } : {
2779
3118
  type: "open",
2780
3119
  id,
@@ -2881,7 +3220,7 @@ function isEnvelope(value) {
2881
3220
  const candidate = value;
2882
3221
  if (typeof candidate.id !== "number") return false;
2883
3222
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
2884
- return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
3223
+ 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";
2885
3224
  }
2886
3225
  /**
2887
3226
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -2893,6 +3232,7 @@ function isEnvelope(value) {
2893
3232
  * not send anything a byte codec could not also carry.
2894
3233
  */
2895
3234
  const structuredCodec = {
3235
+ halfClose: true,
2896
3236
  post(port, envelope, transfer) {
2897
3237
  if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
2898
3238
  else port.postMessage(envelope);
@@ -2930,10 +3270,15 @@ function asyncDisposable(target, dispose) {
2930
3270
  * own `handleStreams` / `sendStream` (no backpressure); version 2 is
2931
3271
  * webrun-rpc's `connect` / `serve` over `overPipe` with webrun-http-streams'
2932
3272
  * `fetchOverDuplex` / `serveFetchOverDuplex`: one port per link, calls
2933
- * multiplexed on it, each call a stream with a one-chunk window in both
2934
- * directions, and cancellation that reaches the handler.
3273
+ * multiplexed on it, each call a stream with backpressure in both
3274
+ * directions, and cancellation that reaches the handler. A call's port with a
3275
+ * window (`HttpLinkOptions.window`) pipelines its chunks where both ends speak
3276
+ * it, and goes one confirmed chunk at a time otherwise, so the window needs
3277
+ * no new version.
2935
3278
  */
2936
3279
  const HTTP_LINK_PROTOCOL = "webrun-http-link/2";
3280
+ /** The calling end's default `HttpLinkOptions.window`: 256 KiB. */
3281
+ const DEFAULT_HTTP_LINK_WINDOW = 262144;
2937
3282
  /**
2938
3283
  * Why a request failed when its link went away under it: the far side (the
2939
3284
  * handler, or the ServiceWorker) was lost. The request may or may not have
@@ -2952,12 +3297,13 @@ var LinkLostError = class extends Error {
2952
3297
  */
2953
3298
  const LINK_CLOSE = "webrun-http:link-close";
2954
3299
  /** Opens the calling end of a link on `port` (one port per link). */
2955
- async function connectHttpLink(port, { streamTimeout, maxPorts } = {}) {
3300
+ async function connectHttpLink(port, { streamTimeout, maxPorts, window = DEFAULT_HTTP_LINK_WINDOW } = {}) {
2956
3301
  const { call, close } = await connect({
2957
3302
  mux: overPipe(port, {
2958
3303
  codec: structuredCodec,
2959
3304
  side: "initiator",
2960
- maxPorts
3305
+ maxPorts,
3306
+ window
2961
3307
  }),
2962
3308
  timeout: streamTimeout
2963
3309
  });
@@ -3521,7 +3867,8 @@ function mayRegister(args) {
3521
3867
  * the client that registered `key`, and exposes REGISTER/UNREGISTER/CONNECT
3522
3868
  * channel calls used by the page-side relay client.
3523
3869
  */
3524
- function startRelayServiceWorker(self, options = {}) {
3870
+ function startRelayServiceWorker(scope, options = {}) {
3871
+ const self = scope;
3525
3872
  const [register, clear] = newRegistry();
3526
3873
  const mounts = newMountTable({ exclude: options.exclude });
3527
3874
  const hostKeys = /* @__PURE__ */ new Set();
@@ -3620,7 +3967,10 @@ function startRelayServiceWorker(self, options = {}) {
3620
3967
  wire: HTTP_LINK_PROTOCOL
3621
3968
  }, channel.port2);
3622
3969
  if (!reply) throw HttpError.errorForbidden(params);
3623
- const response = await sendOverConnection(channel.port1, reply, request, { streamTimeout: options.streamTimeout });
3970
+ const response = await sendOverConnection(channel.port1, reply, request, {
3971
+ streamTimeout: options.streamTimeout,
3972
+ window: options.window
3973
+ });
3624
3974
  return options.decorateResponse?.(response, request) ?? response;
3625
3975
  } catch (error) {
3626
3976
  const httpError = HttpError.fromError(error);