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