@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/worker.js CHANGED
@@ -1207,7 +1207,11 @@ async function drain(output) {
1207
1207
  */
1208
1208
  function httpServe(handler, options = {}) {
1209
1209
  const codec = options.codec ?? defaultCodec;
1210
- return async function* httpHandlerDuplex(input) {
1210
+ return (input) => {
1211
+ const abandoned = new AbortController();
1212
+ 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")));
1213
+ };
1214
+ async function* exchange(input, context) {
1211
1215
  let decoded;
1212
1216
  try {
1213
1217
  decoded = await codec.decodeRequest(input);
@@ -1219,7 +1223,7 @@ function httpServe(handler, options = {}) {
1219
1223
  const replyCodec = decoded.codec ?? codec;
1220
1224
  let result;
1221
1225
  try {
1222
- result = await handler(decoded.envelope, decoded.body);
1226
+ result = await handler(decoded.envelope, decoded.body, context);
1223
1227
  } catch (error) {
1224
1228
  yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
1225
1229
  return;
@@ -1234,7 +1238,36 @@ function httpServe(handler, options = {}) {
1234
1238
  await result.release?.();
1235
1239
  } catch {}
1236
1240
  }
1241
+ }
1242
+ }
1243
+ /**
1244
+ * `gen`, except that `return()` and `throw()` from its consumer call `abandon`
1245
+ * at once — before they are queued behind a `next()` still parked inside the
1246
+ * generator (the handler's `await`) — unless the generator already finished.
1247
+ */
1248
+ function abortingOnReturn(gen, abandon) {
1249
+ let finished = false;
1250
+ const settle = (r) => {
1251
+ if (r.done) finished = true;
1252
+ return r;
1253
+ };
1254
+ const wrapped = {
1255
+ next: (...args) => gen.next(...args).then(settle),
1256
+ return(value) {
1257
+ if (!finished) abandon();
1258
+ finished = true;
1259
+ return gen.return(value);
1260
+ },
1261
+ throw(error) {
1262
+ if (!finished) abandon(error);
1263
+ finished = true;
1264
+ return gen.throw(error);
1265
+ },
1266
+ [Symbol.asyncIterator]() {
1267
+ return wrapped;
1268
+ }
1237
1269
  };
1270
+ return asyncDisposable$2(wrapped, () => wrapped.return(void 0));
1238
1271
  }
1239
1272
  /**
1240
1273
  * The one place that knows a runtime may not implement request body streams,
@@ -1418,10 +1451,11 @@ async function fetchOverDuplex(call, request, options = {}) {
1418
1451
  * registered with any `webrun-streams-*` adapter's `serve`.
1419
1452
  */
1420
1453
  function serveFetchOverDuplex(handler, options = {}) {
1421
- return httpServe(async (env, body) => {
1454
+ return httpServe(async (env, body, exchange) => {
1422
1455
  const reqInit = {
1423
1456
  method: env.method,
1424
- headers: forwardableHeaders(env.headers)
1457
+ headers: forwardableHeaders(env.headers),
1458
+ signal: exchange?.signal
1425
1459
  };
1426
1460
  let upload;
1427
1461
  if (env.method !== "GET" && env.method !== "HEAD") {
@@ -1772,6 +1806,99 @@ async function* throughAbort(input, signal) {
1772
1806
  }
1773
1807
  }
1774
1808
  /**
1809
+ * Runs `fn` on a later task, never a microtask: a producer looping on
1810
+ * `whenPortReady` must let the transport's own events run. `setImmediate`
1811
+ * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
1812
+ * are clamped to 4 ms in browsers.
1813
+ */
1814
+ const setImmediateFn = globalThis.setImmediate;
1815
+ /** @internal */
1816
+ const nextTask = setImmediateFn ? (fn) => {
1817
+ setImmediateFn(fn);
1818
+ } : (() => {
1819
+ let channel;
1820
+ const queue = [];
1821
+ return (fn) => {
1822
+ if (!channel) {
1823
+ channel = new MessageChannel();
1824
+ channel.port1.onmessage = () => queue.shift()?.();
1825
+ }
1826
+ queue.push(fn);
1827
+ channel.port2.postMessage(0);
1828
+ };
1829
+ })();
1830
+ const states = /* @__PURE__ */ new WeakMap();
1831
+ /** @internal Registered by the multiplexer for each port with a window. */
1832
+ function setPortSendState(port, state) {
1833
+ states.set(port, state);
1834
+ }
1835
+ /**
1836
+ * Bytes queued on `port` because its window is spent (0 for a port without
1837
+ * flow control). A sender that should not queue without bound waits for
1838
+ * `whenPortReady` before posting more.
1839
+ */
1840
+ function portBufferedAmount(port) {
1841
+ return states.get(port)?.bufferedAmount() ?? 0;
1842
+ }
1843
+ /**
1844
+ * Resolves once at most `below` bytes are queued on `port`, always on a later
1845
+ * task (for a port without flow control, on the next one).
1846
+ */
1847
+ function whenPortReady(port, below = 0) {
1848
+ return states.get(port)?.ready(below) ?? new Promise((resolve) => nextTask(resolve));
1849
+ }
1850
+ const agreements = /* @__PURE__ */ new WeakMap();
1851
+ /** @internal Registered by the multiplexer for each port with a window. */
1852
+ function setPortAgreement(port, agreement) {
1853
+ agreements.set(port, agreement);
1854
+ }
1855
+ /**
1856
+ * @internal What has been agreed with the peer on `port` so far; `undefined`
1857
+ * on a port without a window. Not exported from the package.
1858
+ */
1859
+ function portAgreement(port) {
1860
+ return agreements.get(port)?.();
1861
+ }
1862
+ const ends = /* @__PURE__ */ new WeakMap();
1863
+ /** @internal Registered by the multiplexer for each port with a window. */
1864
+ function setPortEndState(port, state) {
1865
+ ends.set(port, state);
1866
+ }
1867
+ /**
1868
+ * @internal End this side's direction of `port`: an `end` is queued behind
1869
+ * everything already posted on it, and anything posted afterwards is dropped.
1870
+ * `false`, and nothing changes, when `end` was not agreed (no window, the
1871
+ * handshake not answered yet, or a peer that does not speak it): the caller
1872
+ * then closes the port. Never waits on the peer. Not exported from the package.
1873
+ */
1874
+ function endPort(port) {
1875
+ return ends.get(port)?.end() ?? false;
1876
+ }
1877
+ /**
1878
+ * @internal Resolves once the peer has ended its direction of `port`. It never
1879
+ * settles otherwise: not on a port where `end` is not agreed when it is asked
1880
+ * (no window, the handshake not answered yet, a peer or codec without it), and
1881
+ * not when the port closes first, so a caller races it against the port's
1882
+ * close signal. Not exported from the package.
1883
+ */
1884
+ function whenPortEnded(port) {
1885
+ let waiting = peerEnds.get(port);
1886
+ if (!waiting) {
1887
+ let resolve = () => {};
1888
+ const promise = new Promise((r) => {
1889
+ resolve = r;
1890
+ });
1891
+ if (!ends.get(port)?.watchPeerEnd(resolve)) return promise;
1892
+ waiting = {
1893
+ promise,
1894
+ resolve
1895
+ };
1896
+ peerEnds.set(port, waiting);
1897
+ }
1898
+ return waiting.promise;
1899
+ }
1900
+ const peerEnds = /* @__PURE__ */ new WeakMap();
1901
+ /**
1775
1902
  * Makes `target` disposable with `await using`: installs `dispose` as its
1776
1903
  * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
1777
1904
  * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
@@ -1843,6 +1970,61 @@ function returningAtOnce(gen, abandon) {
1843
1970
  * tests and adapters assert on it.
1844
1971
  */
1845
1972
  const STREAM_ABORT = "webrun-rpc:stream-abort";
1973
+ /**
1974
+ * The `type` of a pipelined chunk: a plain port message, not a `callPort`
1975
+ * request, sent only on a port where both ends agreed `pipeline`. The window's
1976
+ * credit is its confirmation.
1977
+ */
1978
+ const CHUNK = "webrun-rpc:chunk";
1979
+ /**
1980
+ * What a pipelined chunk counts beyond its bytes, against the window, on the
1981
+ * receiving side. Both codecs' windows count more for the chunk's envelope
1982
+ * (the estimate at least 64, msgpack's keys alone more than this), so an honest
1983
+ * sender never makes the receiver count past the window; a sender of empty
1984
+ * chunks still cannot have it hold an unbounded number of them.
1985
+ */
1986
+ const CHUNK_OVERHEAD = 32;
1987
+ /**
1988
+ * The bytes of `view` as a plain `Uint8Array` that owns its whole buffer:
1989
+ * `view` itself when it already is one, else a copy. Structured clone carries
1990
+ * and keeps a view's whole `ArrayBuffer`, and a byte codec may decode into
1991
+ * views of the frame, while the window and the receiver's count see only the
1992
+ * view: a 4 KiB `subarray` of a 4 MiB buffer would cost 4 MiB per chunk.
1993
+ * Copied with the `Uint8Array` constructor, not `slice()`: a Node `Buffer`'s
1994
+ * `slice()` is another view on the same memory.
1995
+ */
1996
+ function ownBytes(view) {
1997
+ if (view instanceof Uint8Array && view.constructor === Uint8Array && view.byteOffset === 0 && view.byteLength === view.buffer.byteLength) return view;
1998
+ return new Uint8Array(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
1999
+ }
2000
+ /**
2001
+ * A producer's chunk as bytes this end may send, in either mode: any view or
2002
+ * `ArrayBuffer`, normalised by `ownBytes`. Anything else fails the stream here,
2003
+ * as the producer's own error, rather than reaching a peer that would refuse it.
2004
+ */
2005
+ /**
2006
+ * A received confirmed chunk's value, as its own bytes, put back in place of
2007
+ * the original in `chunk`: `listenPort` keeps the request until the handler
2008
+ * returns, and the original would keep its whole buffer that long. A function
2009
+ * of its own, so that no temporary of the suspended handler holds the
2010
+ * original either.
2011
+ */
2012
+ function adoptValue(chunk) {
2013
+ const original = chunk.value;
2014
+ if (ArrayBuffer.isView(original)) chunk.value = ownBytes(original);
2015
+ return chunk.value;
2016
+ }
2017
+ function chunkBytes(value) {
2018
+ if (ArrayBuffer.isView(value)) return ownBytes(value);
2019
+ if (value instanceof ArrayBuffer || Object.prototype.toString.call(value) === "[object ArrayBuffer]") return new Uint8Array(value);
2020
+ 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}`);
2021
+ }
2022
+ const MIXED_MESSAGE = "webrun-rpc: peer mixed confirmed and pipelined chunks; the stream port is closed";
2023
+ /** This end may pipeline its own chunks: agreed, and the window holds them. */
2024
+ const mayPipeline = (port) => {
2025
+ const agreement = portAgreement(port);
2026
+ return !!agreement?.enforced && agreement.caps.has("pipeline");
2027
+ };
1846
2028
  /** Caller's input travels on this channel; the handler listens on it. */
1847
2029
  const CHANNEL_IN = "in";
1848
2030
  /** The handler's output travels on this channel; the caller listens on it. */
@@ -1855,7 +2037,9 @@ const CHANNEL_OUT = "out";
1855
2037
  * same way on the other channel. Within each direction the next chunk is never
1856
2038
  * sent until the previous one has been delivered *and* pulled past by the
1857
2039
  * consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
1858
- * `listenPort` withholds it until then.
2040
+ * `listenPort` withholds it until then. On a port whose window both ends
2041
+ * speak and that agreed `pipeline`, chunks go as plain messages instead, and
2042
+ * the window's credit is the confirmation: up to a window is in flight.
1859
2043
  *
1860
2044
  * A stream port carries exactly one invocation. To make several calls, open
1861
2045
  * several ports: `mux.openPort({ kind: "stream" })` per call.
@@ -1872,22 +2056,49 @@ function serveDuplexOverPort(port, handler, options = {}) {
1872
2056
  const notice = installAbortNotice(port, controller);
1873
2057
  abortOnPortClose(port, controller);
1874
2058
  const clock = installStreamTimeout(controller, options.timeout);
1875
- const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
2059
+ const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch, notice);
1876
2060
  let output;
1877
2061
  try {
1878
2062
  output = handler(inbound.stream);
1879
2063
  } catch (err) {
1880
- const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch);
2064
+ const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch, () => {});
1881
2065
  pump.catch(() => {});
1882
2066
  disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
1883
2067
  return teardownOnce(controller, notice, clock, inbound, void 0);
1884
2068
  }
1885
- const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch);
2069
+ let onSent = () => {};
2070
+ const sent = new Promise((resolve) => {
2071
+ onSent = resolve;
2072
+ });
2073
+ const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch, onSent);
1886
2074
  pump.catch(() => {});
1887
2075
  disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
2076
+ endServedStream(port, sent, inbound.over);
1888
2077
  return teardownOnce(controller, notice, clock, inbound, output);
1889
2078
  }
1890
2079
  /**
2080
+ * The serving side's normal finish. Once the handler's last chunk has gone
2081
+ * without an error, and no more input is coming (`over`: the caller's last
2082
+ * chunk arrived without an error and was read, or the caller ended its
2083
+ * direction behind it, for a handler that did not read all its input), this
2084
+ * side ends its direction too, where `end` was agreed. Without `end` nothing
2085
+ * happens here: the caller closes the port. A stream that never gets that far
2086
+ * was abandoned, and the caller's notice and `close` free the port; this then
2087
+ * never resumes. It needs no race against the port's close signal: nothing
2088
+ * awaits it, and what keeps it waiting is the input's watcher, a listener of
2089
+ * the port that a close drops, after which the wait is collected.
2090
+ *
2091
+ * Nothing this side still posts is lost behind its `end`. The caller ends only
2092
+ * once its own last chunk was confirmed, so no confirmation is owed to it
2093
+ * here, and what the freed port's close signal aborts has settled: the last
2094
+ * chunk has left, and input nobody reads is dropped, as on a close.
2095
+ */
2096
+ async function endServedStream(port, sent, over) {
2097
+ await sent;
2098
+ await over;
2099
+ endPort(port);
2100
+ }
2101
+ /**
1891
2102
  * A stream that completes normally has nobody left to call the returned
1892
2103
  * teardown — the caller only knows the stream ended, not that it must also
1893
2104
  * dispose the serve-side handle. Without this, the last `touch()`'s timer
@@ -1927,22 +2138,31 @@ function runCallerSide(port, input, options) {
1927
2138
  const notice = installAbortNotice(port, controller);
1928
2139
  abortOnPortClose(port, controller);
1929
2140
  const clock = installStreamTimeout(controller, options.timeout);
1930
- const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
1931
- const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
2141
+ const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch, notice);
2142
+ let sent = false;
2143
+ const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch, () => {
2144
+ sent = true;
2145
+ });
1932
2146
  pump.catch(() => {});
1933
2147
  return returningAtOnce((async function* () {
2148
+ let received = false;
1934
2149
  try {
1935
2150
  yield* inbound.stream;
2151
+ received = true;
1936
2152
  } finally {
2153
+ const ended = received && sent;
1937
2154
  if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
1938
- notice.post();
2155
+ if (!ended) notice.post();
1939
2156
  notice.stop();
1940
2157
  clock.stop();
1941
2158
  inbound.stop();
1942
2159
  await pump.catch(() => {});
1943
- try {
1944
- await port.close?.();
1945
- } catch {}
2160
+ if (!(ended && endPort(port))) {
2161
+ notice.post();
2162
+ try {
2163
+ await port.close?.();
2164
+ } catch {}
2165
+ }
1946
2166
  }
1947
2167
  })(), () => {
1948
2168
  if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
@@ -2036,7 +2256,7 @@ function installStreamTimeout(controller, timeout) {
2036
2256
  * the local consumer has not started iterating, an inbound chunk waits for it —
2037
2257
  * which is the correct backpressure, and different from having no listener.
2038
2258
  */
2039
- function receiveChunks(port, channelName, controller, touch) {
2259
+ function receiveChunks(port, channelName, controller, touch, notice) {
2040
2260
  let deliver;
2041
2261
  const waiting = [];
2042
2262
  let finished = false;
@@ -2046,25 +2266,64 @@ function receiveChunks(port, channelName, controller, touch) {
2046
2266
  const ended = new Promise((resolve) => {
2047
2267
  resolveEnded = resolve;
2048
2268
  });
2269
+ let resolveOver = () => {};
2270
+ const over = new Promise((resolve) => {
2271
+ resolveOver = resolve;
2272
+ });
2049
2273
  let aborted = false;
2050
2274
  let abortReason;
2051
2275
  const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
2052
2276
  const wake = () => {
2053
2277
  for (const r of waiting.splice(0)) r();
2054
2278
  };
2055
- const off = listenPort(port, async ({ done, value, error }) => {
2279
+ const violate = (message) => {
2280
+ poison = new Error(message);
2281
+ controller.abort(poison);
2282
+ setTimeout(() => {
2283
+ try {
2284
+ port.close?.();
2285
+ } catch {}
2286
+ }, 0);
2287
+ return poison;
2288
+ };
2289
+ let lastChunkArrived = false;
2290
+ let watching = false;
2291
+ const watchPeerEnd = () => {
2292
+ if (watching) return true;
2293
+ if (!portAgreement(port)?.caps.has("end")) return false;
2294
+ watching = true;
2295
+ whenPortEnded(port).then(() => {
2296
+ port.removeEventListener("message", watcher);
2297
+ if (lastChunkArrived) {
2298
+ resolveOver();
2299
+ return;
2300
+ }
2301
+ notice.post();
2302
+ violate("webrun-rpc: the peer ended its direction without finishing it; the stream port is closed");
2303
+ });
2304
+ return true;
2305
+ };
2306
+ const watcher = (event) => {
2307
+ const data = event.data;
2308
+ if (data?.channelName !== channelName) return;
2309
+ const chunk = data.type === CHUNK ? data.chunk : data.type === "request" ? data.params : void 0;
2310
+ if (!chunk) return;
2311
+ if (!watchPeerEnd()) {
2312
+ port.removeEventListener("message", watcher);
2313
+ return;
2314
+ }
2315
+ if (chunk.done && !chunk.error) lastChunkArrived = true;
2316
+ };
2317
+ port.addEventListener("message", watcher);
2318
+ watchPeerEnd();
2319
+ let held = 0;
2320
+ const off = listenPort(port, async (params) => {
2321
+ const { done, error } = params;
2322
+ const value = adoptValue(params);
2056
2323
  touch();
2057
2324
  if (poison) throw poison;
2058
- if (outstanding) {
2059
- poison = /* @__PURE__ */ new Error("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
2060
- controller.abort(poison);
2061
- setTimeout(() => {
2062
- try {
2063
- port.close?.();
2064
- } catch {}
2065
- }, 0);
2066
- throw poison;
2067
- }
2325
+ if (held > 0) throw violate(MIXED_MESSAGE);
2326
+ if (outstanding) throw violate("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
2068
2327
  outstanding = true;
2069
2328
  try {
2070
2329
  await ready();
@@ -2074,15 +2333,60 @@ function receiveChunks(port, channelName, controller, touch) {
2074
2333
  value,
2075
2334
  error: error ? deserializeError(error) : void 0
2076
2335
  });
2336
+ if (done && !error) resolveOver();
2077
2337
  } finally {
2078
2338
  outstanding = false;
2079
2339
  }
2080
2340
  if (done) resolveEnded();
2081
2341
  }, { channelName });
2342
+ const onChunk = (event) => {
2343
+ const data = event.data;
2344
+ if (data?.type !== CHUNK || data.channelName !== channelName || !data.chunk) return;
2345
+ if (poison) return;
2346
+ const agreement = portAgreement(port);
2347
+ if (!agreement?.caps.has("pipeline")) {
2348
+ violate("webrun-rpc: peer sent a pipelined chunk where pipelining was not agreed");
2349
+ return;
2350
+ }
2351
+ if (outstanding) {
2352
+ violate(MIXED_MESSAGE);
2353
+ return;
2354
+ }
2355
+ if (data.chunk.value !== void 0 && !ArrayBuffer.isView(data.chunk.value)) {
2356
+ violate("webrun-rpc: peer sent a pipelined chunk whose value is not bytes");
2357
+ return;
2358
+ }
2359
+ if (held > agreement.window) {
2360
+ violate(`webrun-rpc: peer sent past the window (${agreement.window} bytes) without waiting for credit; the stream port is closed`);
2361
+ return;
2362
+ }
2363
+ touch();
2364
+ const { done, error } = data.chunk;
2365
+ const value = data.chunk.value && ownBytes(data.chunk.value);
2366
+ const size = (value?.byteLength ?? 0) + CHUNK_OVERHEAD;
2367
+ held += size;
2368
+ return (async () => {
2369
+ try {
2370
+ await ready();
2371
+ if (finished) return;
2372
+ await deliver?.({
2373
+ done,
2374
+ value,
2375
+ error: error ? deserializeError(error) : void 0
2376
+ });
2377
+ if (done && !error) resolveOver();
2378
+ if (done) resolveEnded();
2379
+ } finally {
2380
+ held -= size;
2381
+ }
2382
+ })();
2383
+ };
2384
+ port.addEventListener("message", onChunk);
2082
2385
  const detach = () => {
2083
2386
  finished = true;
2084
2387
  wake();
2085
2388
  off();
2389
+ port.removeEventListener("message", onChunk);
2086
2390
  controller.signal.removeEventListener("abort", onAbort);
2087
2391
  resolveEnded();
2088
2392
  };
@@ -2112,21 +2416,35 @@ function receiveChunks(port, channelName, controller, touch) {
2112
2416
  return detach;
2113
2417
  }),
2114
2418
  stop: detach,
2115
- ended
2419
+ ended,
2420
+ over
2116
2421
  };
2117
2422
  }
2118
2423
  /**
2119
2424
  * The sending half of one direction: one `callPort` per chunk, one call
2120
- * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
2425
+ * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8) —
2426
+ * or, once pipelining is agreed, plain chunk messages held back by the window.
2427
+ * `onSent` runs once the end of the output has left without an error: posted
2428
+ * when pipelined, confirmed when not.
2121
2429
  */
2122
- async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
2123
- const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
2430
+ async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch, onSent) {
2431
+ const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
2432
+ const stream = throughAbort(framed, signal);
2433
+ const producer = output;
2434
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
2435
+ if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
2436
+ let onAborted = () => {};
2437
+ const aborted = new Promise((resolve) => {
2438
+ onAborted = () => resolve();
2439
+ });
2440
+ if (signal.aborted) onAborted();
2441
+ else signal.addEventListener("abort", onAborted, { once: true });
2124
2442
  try {
2125
2443
  await sendIterator(async ({ done, value, error }) => {
2126
2444
  if (signal.aborted) return;
2127
2445
  const chunk = {
2128
2446
  done,
2129
- value,
2447
+ value: done ? value : chunkBytes(value),
2130
2448
  error: error === void 0 ? void 0 : serializeError(error)
2131
2449
  };
2132
2450
  log?.("[duplexOverPort] send", {
@@ -2134,46 +2452,34 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
2134
2452
  done,
2135
2453
  size: value?.byteLength
2136
2454
  });
2455
+ if (mayPipeline(port)) {
2456
+ port.postMessage({
2457
+ type: CHUNK,
2458
+ channelName,
2459
+ chunk
2460
+ });
2461
+ touch();
2462
+ if (done && !error) onSent();
2463
+ if (portBufferedAmount(port) > 0) await Promise.race([whenPortReady(port), aborted]);
2464
+ return;
2465
+ }
2137
2466
  await callPort(port, chunk, {
2138
2467
  channelName,
2139
2468
  timeout: NO_TIMEOUT,
2140
2469
  signal
2141
2470
  });
2142
2471
  touch();
2472
+ if (done && !error) onSent();
2143
2473
  }, stream);
2144
2474
  } catch (err) {
2145
2475
  if (signal.aborted) return;
2146
2476
  throw err;
2477
+ } finally {
2478
+ signal.removeEventListener("abort", returnProducer);
2479
+ signal.removeEventListener("abort", onAborted);
2147
2480
  }
2148
2481
  }
2149
2482
  /**
2150
- * Runs `fn` on a later task, never a microtask: a producer looping on
2151
- * `whenPortReady` must let the transport's own events run. `setImmediate`
2152
- * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
2153
- * are clamped to 4 ms in browsers.
2154
- */
2155
- const setImmediateFn = globalThis.setImmediate;
2156
- /** @internal */
2157
- const nextTask = setImmediateFn ? (fn) => {
2158
- setImmediateFn(fn);
2159
- } : (() => {
2160
- let channel;
2161
- const queue = [];
2162
- return (fn) => {
2163
- if (!channel) {
2164
- channel = new MessageChannel();
2165
- channel.port1.onmessage = () => queue.shift()?.();
2166
- }
2167
- queue.push(fn);
2168
- channel.port2.postMessage(0);
2169
- };
2170
- })();
2171
- const states = /* @__PURE__ */ new WeakMap();
2172
- /** @internal Registered by the multiplexer for each port with a window. */
2173
- function setPortSendState(port, state) {
2174
- states.set(port, state);
2175
- }
2176
- /**
2177
2483
  * One virtual port.
2178
2484
  *
2179
2485
  * `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
@@ -2251,6 +2557,21 @@ function newVirtualPort(send, requestClose) {
2251
2557
  const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
2252
2558
  /** The longest delay a timer holds; past it `setInterval` fires at once. */
2253
2559
  const MAX_TIMER_MS = 2 ** 31 - 1;
2560
+ /**
2561
+ * What a windowed port offers in its `open`: every capability this end
2562
+ * speaks. `end` only over a codec that reads it (`PortCodec.halfClose`).
2563
+ */
2564
+ function ownCaps(codec) {
2565
+ return codec.halfClose ? ["pipeline", "end"] : ["pipeline"];
2566
+ }
2567
+ /**
2568
+ * The capabilities in a peer's `caps` that this end speaks too, or
2569
+ * `undefined` when the peer listed none (it is then treated as an older peer).
2570
+ */
2571
+ function sharedCaps(own, listed) {
2572
+ if (!Array.isArray(listed)) return void 0;
2573
+ return new Set(own.filter((cap) => listed.includes(cap)));
2574
+ }
2254
2575
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
2255
2576
  const DEFAULT_MAX_PORTS = 1024;
2256
2577
  /**
@@ -2282,10 +2603,18 @@ function estimateMessageSize(payload) {
2282
2603
  */
2283
2604
  function multiplexPort(port, options) {
2284
2605
  const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
2606
+ const maxReassembledBytes = options.maxReassembledBytes ?? 67108864;
2285
2607
  const misses = keepalive?.misses ?? 3;
2286
2608
  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`);
2287
2609
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
2610
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) throw new RangeError(`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`);
2288
2611
  const open = /* @__PURE__ */ new Map();
2612
+ let reassembling = 0;
2613
+ const freePartial = (entry) => {
2614
+ if (!entry?.partial) return;
2615
+ reassembling -= entry.partial.charged;
2616
+ entry.partial = void 0;
2617
+ };
2289
2618
  const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
2290
2619
  const entry = open.get(id);
2291
2620
  if (entry?.handle !== record) return;
@@ -2306,6 +2635,8 @@ function multiplexPort(port, options) {
2306
2635
  let muxClosed = false;
2307
2636
  /** This end counts encoded bytes and reassembles frames. */
2308
2637
  const exactCodec = codec.encode != null && codec.decode != null;
2638
+ /** What this end offers on a windowed port. */
2639
+ const caps = ownCaps(codec);
2309
2640
  const post = (envelope, transfer) => {
2310
2641
  if (muxClosed) return;
2311
2642
  codecPost(envelope, transfer);
@@ -2363,13 +2694,39 @@ function multiplexPort(port, options) {
2363
2694
  port.postMessage(bytes);
2364
2695
  } catch {}
2365
2696
  };
2697
+ /**
2698
+ * Both directions have ended: free the id as a close would, with no `close`
2699
+ * on the wire (each end frees it on its own once it has seen both ends).
2700
+ * Aborting the close signal is how the port's consumer learns it is gone.
2701
+ */
2702
+ const freeIfEnded = (id, c) => {
2703
+ const entry = open.get(id);
2704
+ if (!c.endSent || !c.peerEnded || entry?.credit !== c) return;
2705
+ freePartial(entry);
2706
+ open.delete(id);
2707
+ leaks?.unregister(entry.handle);
2708
+ entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port ended in both directions"));
2709
+ };
2710
+ /** A peer that broke the port's protocol: hold none of its traffic, and close. */
2711
+ const failPort = (id, entry, error, reason) => {
2712
+ freePartial(entry);
2713
+ dropOutgoing(id);
2714
+ open.delete(id);
2715
+ releaseWaiters(entry.credit);
2716
+ entry.handle.markClosed(error);
2717
+ post({
2718
+ type: "close",
2719
+ id,
2720
+ reason
2721
+ });
2722
+ };
2366
2723
  const releaseWaiters = (c) => {
2367
2724
  for (const w of c?.waiters.splice(0) ?? []) w.resolve();
2368
2725
  };
2369
2726
  const flush = (c) => {
2370
2727
  while (c.queue.length) {
2371
2728
  const next = c.queue[0];
2372
- if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
2729
+ if (next.size > 0 && c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
2373
2730
  c.queue.shift();
2374
2731
  c.queued -= next.size;
2375
2732
  c.outstanding += next.size;
@@ -2407,15 +2764,20 @@ function multiplexPort(port, options) {
2407
2764
  enforced: false,
2408
2765
  opener,
2409
2766
  exact,
2767
+ caps: /* @__PURE__ */ new Set(),
2410
2768
  outstanding: 0,
2411
2769
  queue: [],
2412
2770
  queued: 0,
2413
2771
  unconsumed: 0,
2414
2772
  waiters: [],
2415
2773
  toGrant: 0,
2416
- grantScheduled: false
2774
+ grantScheduled: false,
2775
+ ending: false,
2776
+ endSent: false,
2777
+ peerEnded: false
2417
2778
  } : void 0;
2418
2779
  const self = newVirtualPort((payload, transfer) => {
2780
+ if (c?.ending) return;
2419
2781
  touch(id);
2420
2782
  const envelope = {
2421
2783
  type: "message",
@@ -2433,12 +2795,12 @@ function multiplexPort(port, options) {
2433
2795
  ...envelope,
2434
2796
  payload: structuredClone(payload, { transfer })
2435
2797
  } : void 0;
2436
- const send = () => {
2798
+ const send = (whole = false) => {
2437
2799
  if (!bytes) {
2438
2800
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
2439
2801
  return;
2440
2802
  }
2441
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2803
+ if (!whole && frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2442
2804
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
2443
2805
  const data = bytes.subarray(at, at + frameSize);
2444
2806
  const frame = at + frameSize >= bytes.byteLength ? {
@@ -2468,10 +2830,11 @@ function multiplexPort(port, options) {
2468
2830
  record.markClosed(reason);
2469
2831
  });
2470
2832
  function release(reason) {
2833
+ freePartial(open.get(id));
2471
2834
  open.delete(id);
2472
2835
  leaks?.unregister(record);
2473
2836
  if (c?.queue.length) {
2474
- for (const item of c.queue.splice(0)) item.send();
2837
+ for (const item of c.queue.splice(0)) item.send(true);
2475
2838
  c.queued = 0;
2476
2839
  }
2477
2840
  sendOnPort(id, () => post({
@@ -2501,6 +2864,38 @@ function multiplexPort(port, options) {
2501
2864
  });
2502
2865
  })
2503
2866
  });
2867
+ setPortEndState(self.port, {
2868
+ end: () => {
2869
+ if (isClosed() || !c.caps.has("end")) return false;
2870
+ if (c.ending) return true;
2871
+ c.ending = true;
2872
+ c.queue.push({
2873
+ size: 0,
2874
+ send: () => sendOnPort(id, () => {
2875
+ if (open.get(id)?.credit !== c) return;
2876
+ post({
2877
+ type: "end",
2878
+ id
2879
+ });
2880
+ c.endSent = true;
2881
+ freeIfEnded(id, c);
2882
+ })
2883
+ });
2884
+ flush(c);
2885
+ return true;
2886
+ },
2887
+ watchPeerEnd: (resolve) => {
2888
+ if (!c.caps.has("end")) return false;
2889
+ if (c.peerEnded) resolve();
2890
+ else c.onPeerEnd = new WeakRef(resolve);
2891
+ return true;
2892
+ }
2893
+ });
2894
+ setPortAgreement(self.port, () => ({
2895
+ window: c.window,
2896
+ caps: c.caps,
2897
+ enforced: c.enforced
2898
+ }));
2504
2899
  }
2505
2900
  const record = {
2506
2901
  deliver: self.deliver,
@@ -2552,16 +2947,24 @@ function multiplexPort(port, options) {
2552
2947
  }
2553
2948
  const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
2554
2949
  const credit = open.get(envelope.id)?.credit;
2555
- if (credit) post(credit.exact ? {
2556
- type: "credit",
2557
- id: envelope.id,
2558
- bytes: 0,
2559
- exact: true
2560
- } : {
2561
- type: "credit",
2562
- id: envelope.id,
2563
- bytes: 0
2564
- });
2950
+ const listed = sharedCaps(caps, envelope.caps);
2951
+ if (credit) {
2952
+ const ack = credit.exact ? {
2953
+ type: "credit",
2954
+ id: envelope.id,
2955
+ bytes: 0,
2956
+ exact: true
2957
+ } : {
2958
+ type: "credit",
2959
+ id: envelope.id,
2960
+ bytes: 0
2961
+ };
2962
+ if (listed) credit.caps = listed;
2963
+ post(listed ? {
2964
+ ...ack,
2965
+ caps: [...listed]
2966
+ } : ack);
2967
+ }
2565
2968
  let accepted = false;
2566
2969
  if (onPort) try {
2567
2970
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -2589,6 +2992,7 @@ function multiplexPort(port, options) {
2589
2992
  c.exact = false;
2590
2993
  c.outstanding = 0;
2591
2994
  }
2995
+ c.caps = sharedCaps(caps, envelope.caps) ?? /* @__PURE__ */ new Set();
2592
2996
  post({
2593
2997
  type: "credit",
2594
2998
  id: envelope.id,
@@ -2600,6 +3004,10 @@ function multiplexPort(port, options) {
2600
3004
  flush(c);
2601
3005
  return;
2602
3006
  }
3007
+ if ((envelope.type === "message" || envelope.type === "frame") && entry?.credit?.peerEnded) {
3008
+ failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer sent on a port after its end"), "protocol");
3009
+ return;
3010
+ }
2603
3011
  if (envelope.type === "message") {
2604
3012
  const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
2605
3013
  if (!entry) return;
@@ -2610,15 +3018,29 @@ function multiplexPort(port, options) {
2610
3018
  if (envelope.type === "frame") {
2611
3019
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
2612
3020
  touch(envelope.id);
3021
+ const c = entry.credit;
3022
+ const owed = c.unconsumed + c.toGrant;
2613
3023
  const partial = entry.partial ?? {
2614
3024
  chunks: [],
2615
- bytes: 0
3025
+ bytes: 0,
3026
+ owed,
3027
+ charged: 0,
3028
+ ...owed > 0 ? { room: c.window - owed } : {}
2616
3029
  };
2617
3030
  entry.partial = partial;
3031
+ const size = envelope.data.byteLength;
3032
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
3033
+ 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;
3034
+ if (overrun) {
3035
+ failPort(envelope.id, entry, /* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`), "max-reassembled-bytes");
3036
+ return;
3037
+ }
3038
+ partial.bytes += size;
3039
+ reassembling += charged - partial.charged;
3040
+ partial.charged = charged;
2618
3041
  partial.chunks.push(envelope.data);
2619
- partial.bytes += envelope.data.byteLength;
2620
3042
  if (!envelope.last) return;
2621
- entry.partial = void 0;
3043
+ freePartial(entry);
2622
3044
  const whole = new Uint8Array(partial.bytes);
2623
3045
  let at = 0;
2624
3046
  for (const chunk of partial.chunks) {
@@ -2630,8 +3052,22 @@ function multiplexPort(port, options) {
2630
3052
  entry.handle.deliver(inner.payload, whole.byteLength);
2631
3053
  return;
2632
3054
  }
3055
+ if (envelope.type === "end") {
3056
+ if (!entry) return;
3057
+ const c = entry.credit;
3058
+ if (!c?.caps.has("end")) {
3059
+ failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer ended a port where end was not agreed"), "protocol");
3060
+ return;
3061
+ }
3062
+ touch(envelope.id);
3063
+ c.peerEnded = true;
3064
+ c.onPeerEnd?.deref()?.();
3065
+ freeIfEnded(envelope.id, c);
3066
+ return;
3067
+ }
2633
3068
  dropOutgoing(envelope.id);
2634
3069
  if (!entry) return;
3070
+ freePartial(entry);
2635
3071
  open.delete(envelope.id);
2636
3072
  releaseWaiters(entry.credit);
2637
3073
  entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
@@ -2710,12 +3146,14 @@ function multiplexPort(port, options) {
2710
3146
  id,
2711
3147
  meta,
2712
3148
  window,
2713
- exact: true
3149
+ exact: true,
3150
+ caps: [...caps]
2714
3151
  } : {
2715
3152
  type: "open",
2716
3153
  id,
2717
3154
  meta,
2718
- window
3155
+ window,
3156
+ caps: [...caps]
2719
3157
  } : {
2720
3158
  type: "open",
2721
3159
  id,
@@ -2822,14 +3260,17 @@ const serve = async ({ mux: factory, timeout }, handler) => {
2822
3260
  const mux = await factory((port) => {
2823
3261
  let off;
2824
3262
  let finished = false;
2825
- const tracked = (input) => (async function* () {
2826
- try {
2827
- yield* handler(input);
2828
- } finally {
2829
- finished = true;
2830
- if (off) live.delete(off);
2831
- }
2832
- })();
3263
+ const tracked = (input) => {
3264
+ const output = handler(input);
3265
+ return returningAtOnce((async function* () {
3266
+ try {
3267
+ yield* output;
3268
+ } finally {
3269
+ finished = true;
3270
+ if (off) live.delete(off);
3271
+ }
3272
+ })(), () => void output.return(void 0).catch(() => {}));
3273
+ };
2833
3274
  off = serveDuplexOverPort(port, tracked, streamOptions);
2834
3275
  if (!finished) live.add(off);
2835
3276
  });
@@ -2867,7 +3308,7 @@ function isEnvelope(value) {
2867
3308
  const candidate = value;
2868
3309
  if (typeof candidate.id !== "number") return false;
2869
3310
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
2870
- return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
3311
+ 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";
2871
3312
  }
2872
3313
  /**
2873
3314
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -2879,6 +3320,7 @@ function isEnvelope(value) {
2879
3320
  * not send anything a byte codec could not also carry.
2880
3321
  */
2881
3322
  const structuredCodec = {
3323
+ halfClose: true,
2882
3324
  post(port, envelope, transfer) {
2883
3325
  if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
2884
3326
  else port.postMessage(envelope);
@@ -2916,10 +3358,15 @@ function asyncDisposable(target, dispose) {
2916
3358
  * own `handleStreams` / `sendStream` (no backpressure); version 2 is
2917
3359
  * webrun-rpc's `connect` / `serve` over `overPipe` with webrun-http-streams'
2918
3360
  * `fetchOverDuplex` / `serveFetchOverDuplex`: one port per link, calls
2919
- * multiplexed on it, each call a stream with a one-chunk window in both
2920
- * directions, and cancellation that reaches the handler.
3361
+ * multiplexed on it, each call a stream with backpressure in both
3362
+ * directions, and cancellation that reaches the handler. A call's port with a
3363
+ * window (`HttpLinkOptions.window`) pipelines its chunks where both ends speak
3364
+ * it, and goes one confirmed chunk at a time otherwise, so the window needs
3365
+ * no new version.
2921
3366
  */
2922
3367
  const HTTP_LINK_PROTOCOL = "webrun-http-link/2";
3368
+ /** The calling end's default `HttpLinkOptions.window`: 256 KiB. */
3369
+ const DEFAULT_HTTP_LINK_WINDOW = 262144;
2923
3370
  /**
2924
3371
  * Why a request failed when its link went away under it: the far side (the
2925
3372
  * handler, or the ServiceWorker) was lost. The request may or may not have
@@ -2938,12 +3385,13 @@ var LinkLostError = class extends Error {
2938
3385
  */
2939
3386
  const LINK_CLOSE = "webrun-http:link-close";
2940
3387
  /** Opens the calling end of a link on `port` (one port per link). */
2941
- async function connectHttpLink(port, { streamTimeout, maxPorts } = {}) {
3388
+ async function connectHttpLink(port, { streamTimeout, maxPorts, window = DEFAULT_HTTP_LINK_WINDOW } = {}) {
2942
3389
  const { call, close } = await connect({
2943
3390
  mux: overPipe(port, {
2944
3391
  codec: structuredCodec,
2945
3392
  side: "initiator",
2946
- maxPorts
3393
+ maxPorts,
3394
+ window
2947
3395
  }),
2948
3396
  timeout: streamTimeout
2949
3397
  });
@@ -3399,6 +3847,7 @@ var PortSocketCore = class extends EventTarget {
3399
3847
  code,
3400
3848
  reason
3401
3849
  });
3850
+ if (!Number.isFinite(this._closeTimeoutMs)) return;
3402
3851
  this._closeTimer = this._timers.setTimeout(() => this._finish(1006, "", false), this._closeTimeoutMs);
3403
3852
  }
3404
3853
  _onCloseFrame(code, reason) {
@@ -3764,7 +4213,8 @@ var PortRendezvous = class {
3764
4213
  * `whenDrained(limit)` waits for it to fall: the backpressure a producer
3765
4214
  * applies.
3766
4215
  * - When the client's pings stop for three heartbeat periods (a closed tab, a
3767
- * terminated worker), the socket closes with 1006.
4216
+ * terminated worker), the socket closes with 1006. With `watch: "first-ping"`
4217
+ * the watch starts only once the client pings.
3768
4218
  */
3769
4219
  var ServerWebSocket = class extends PortSocketCore {
3770
4220
  static CONNECTING = 0;
@@ -3773,55 +4223,150 @@ var ServerWebSocket = class extends PortSocketCore {
3773
4223
  static CLOSED = 3;
3774
4224
  url;
3775
4225
  protocol;
4226
+ _binaryType = "arraybuffer";
4227
+ _options;
3776
4228
  _accepted = false;
4229
+ _detached = false;
3777
4230
  _queue = [];
3778
4231
  _heartbeatMs = DEFAULT_HEARTBEAT_MS;
3779
4232
  _lastPing = Date.now();
4233
+ /** Whether a ping (with a period a timer takes) arrived already. */
4234
+ _pinged = false;
3780
4235
  _watch;
3781
4236
  constructor(port, options = {}) {
3782
4237
  super({
3783
4238
  timers: options.timers,
3784
4239
  closeTimeoutMs: options.closeTimeoutMs
3785
4240
  });
4241
+ this._options = options;
3786
4242
  this.url = options.url ?? "";
3787
4243
  this.protocol = options.protocol ?? "";
3788
4244
  this._attach(port);
3789
4245
  this._state = 1;
3790
- this._watchHeartbeat();
4246
+ if (options.watch !== "first-ping") this._watchHeartbeat();
3791
4247
  }
3792
4248
  /** Whether `accept()` was called. */
3793
4249
  get accepted() {
3794
4250
  return this._accepted;
3795
4251
  }
4252
+ /** How binary messages arrive: `ArrayBuffer`s (the default) or `Blob`s. Other values are ignored. */
4253
+ get binaryType() {
4254
+ return this._binaryType;
4255
+ }
4256
+ set binaryType(value) {
4257
+ if (value === "blob" || value === "arraybuffer") this._binaryType = value;
4258
+ }
4259
+ /**
4260
+ * Hands this end's transport to another holder before it was accepted: the port to give the
4261
+ * client (a pair's end returned in a 101, say), with the frames that reached this end first
4262
+ * delivered ahead of the rest. This end is closed then, with no event, and answers nothing.
4263
+ */
4264
+ detach() {
4265
+ const port = this._port;
4266
+ if (!port || this._accepted || this._state !== 1) throw new DOMException("Only an open end that was not accepted can be detached.", "InvalidStateError");
4267
+ this._detached = true;
4268
+ this._port = void 0;
4269
+ port.onmessage = null;
4270
+ let owed = this.bufferedAmount;
4271
+ this._state = 3;
4272
+ this._onClosed();
4273
+ this._wakeDrainWaiters();
4274
+ const queued = this._queue.splice(0);
4275
+ if (queued.length === 0 && owed === 0) return port;
4276
+ const relay = new MessageChannel();
4277
+ const holder = relay.port2;
4278
+ const closing = /* @__PURE__ */ new Set();
4279
+ const end = () => {
4280
+ port.onmessage = null;
4281
+ holder.onmessage = null;
4282
+ port.close();
4283
+ holder.close();
4284
+ };
4285
+ const sawClose = (way) => {
4286
+ closing.add(way);
4287
+ if (closing.size === 2) end();
4288
+ };
4289
+ for (const frame of queued) {
4290
+ postFrame(holder, frame);
4291
+ if (frame.t === "c") closing.add("in");
4292
+ }
4293
+ holder.onmessage = (event) => {
4294
+ port.postMessage(event.data, transferables(event.data));
4295
+ if (event.data?.t === "c") sawClose("out");
4296
+ };
4297
+ port.onmessage = (event) => {
4298
+ const data = event.data;
4299
+ if (data?.t === "a" && typeof data.n === "number" && owed > 0) {
4300
+ const absorbed = Math.min(owed, data.n);
4301
+ owed -= absorbed;
4302
+ if (data.n > absorbed) holder.postMessage({
4303
+ t: "a",
4304
+ n: data.n - absorbed
4305
+ });
4306
+ return;
4307
+ }
4308
+ holder.postMessage(event.data, transferables(event.data));
4309
+ if (data?.t === "c") sawClose("in");
4310
+ };
4311
+ port.addEventListener?.("close", end);
4312
+ holder.addEventListener?.("close", end);
4313
+ return relay.port1;
4314
+ }
3796
4315
  /** Starts delivering events, the queued ones first. */
3797
4316
  accept() {
4317
+ if (this._detached) throw new DOMException("This end was detached; it cannot be accepted.", "InvalidStateError");
3798
4318
  if (this._accepted) return;
3799
4319
  this._accepted = true;
3800
- for (const frame of this._queue.splice(0)) super._deliver(frame);
4320
+ for (const frame of this._queue.splice(0)) this._deliver(frame);
3801
4321
  }
3802
4322
  send(data) {
3803
- if (!this._accepted) throw new DOMException("accept() the socket before sending on it.", "InvalidStateError");
4323
+ if (this._detached) throw new DOMException("This end was detached; it cannot send.", "InvalidStateError");
4324
+ if (!this._accepted && !this._options.sendBeforeAccept) throw new DOMException("accept() the socket before sending on it.", "InvalidStateError");
3804
4325
  this._sendData(isBinary(data) ? toArrayBuffer(data) : String(data));
3805
4326
  }
3806
- close(code = 1e3, reason = "") {
3807
- if (!isSendableCloseCode(code)) throw new DOMException(`${code} is not a close code an endpoint may send.`, "InvalidAccessError");
4327
+ close(code, reason = "") {
4328
+ this.checkClose(code, reason);
4329
+ this._startClose(code ?? this._options.defaultCloseCode ?? 1e3, reason);
4330
+ }
4331
+ /**
4332
+ * Refuses a `close()` the endpoint may not make: by default a code outside RFC 6455 §7.4's
4333
+ * sendable ones, or a reason over 123 bytes. A subclass with another platform's rules
4334
+ * overrides it.
4335
+ */
4336
+ checkClose(code, reason) {
4337
+ if (code !== void 0 && !isSendableCloseCode(code)) throw new DOMException(`${code} is not a close code an endpoint may send.`, "InvalidAccessError");
3808
4338
  if (utf8Length(reason) > 123) throw new DOMException("The close reason is longer than 123 bytes.", "SyntaxError");
3809
- this._startClose(code, reason);
3810
4339
  }
3811
4340
  _deliver(frame) {
3812
4341
  if (!this._accepted) {
3813
4342
  this._queue.push(frame);
3814
4343
  return;
3815
4344
  }
4345
+ if (frame.t === "m" && this._state === 2 && this._options.deliverWhileClosing) {
4346
+ this._dispatchMessage(frame.d);
4347
+ this._consumed(messageSize(frame.d));
4348
+ return;
4349
+ }
3816
4350
  super._deliver(frame);
3817
4351
  }
3818
4352
  _dispatchMessage(data) {
3819
- this.dispatchEvent(new MessageEvent("message", { data }));
4353
+ const delivered = typeof data !== "string" && this._binaryType === "blob" ? new Blob([data]) : data;
4354
+ this.dispatchEvent(new MessageEvent("message", { data: delivered }));
3820
4355
  }
3821
4356
  _onPing(hb) {
3822
4357
  this._post({ t: "q" });
3823
4358
  this._lastPing = Date.now();
3824
- if (hb !== this._heartbeatMs) {
4359
+ if (this._state === 3) return;
4360
+ const keep = this._options.heartbeatPeriod === "first" && this._pinged;
4361
+ if (!(hb > 0 && hb < 2 ** 31 - 1)) {
4362
+ if (!keep && this._watch !== void 0) {
4363
+ this._timers.clearInterval(this._watch);
4364
+ this._watch = void 0;
4365
+ }
4366
+ return;
4367
+ }
4368
+ this._pinged = true;
4369
+ if (!keep && (this._watch === void 0 || hb !== this._heartbeatMs)) {
3825
4370
  this._heartbeatMs = hb;
3826
4371
  this._watchHeartbeat();
3827
4372
  }
@@ -3843,6 +4388,11 @@ function isSendableCloseCode(code) {
3843
4388
  if (code >= 3e3 && code <= 4999) return true;
3844
4389
  return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006;
3845
4390
  }
4391
+ /** The buffers a relayed frame carries, to transfer rather than copy. */
4392
+ function transferables(data) {
4393
+ const d = data?.d;
4394
+ return d instanceof ArrayBuffer ? [d] : [];
4395
+ }
3846
4396
  //#endregion
3847
4397
  //#region src/ws/upgrade.ts
3848
4398
  /**
@@ -3874,6 +4424,7 @@ function newWebSocketUpgradeRequest(url, { protocols = [], headers } = {}) {
3874
4424
  all.set("connection", "Upgrade");
3875
4425
  all.set("sec-websocket-version", "13");
3876
4426
  if (!all.has("sec-websocket-key")) all.set("sec-websocket-key", randomKey());
4427
+ all.delete("sec-websocket-protocol");
3877
4428
  if (protocols.length > 0) all.set("sec-websocket-protocol", protocols.join(", "));
3878
4429
  if (!all.has("x-webrun-ws-version")) all.set(WS_VERSION_HEADER, `2`);
3879
4430
  return withHeaders(new Request(httpUrl, {
@@ -3933,6 +4484,23 @@ function upgradeWebSocket(request, options = {}) {
3933
4484
  url: request.url,
3934
4485
  protocol
3935
4486
  });
4487
+ const response = webSocketUpgradeResponse(channel.port2, protocol);
4488
+ Object.defineProperty(response, "webSocket", {
4489
+ value: socket,
4490
+ configurable: true
4491
+ });
4492
+ return {
4493
+ socket,
4494
+ response
4495
+ };
4496
+ }
4497
+ /**
4498
+ * A 101 for a socket whose server end the handler runs itself, on the other end of `port`
4499
+ * (a server of its own over this package's frames, say): what `upgradeWebSocket` returns, minus
4500
+ * the `ServerWebSocket`. The host hands `port` to the caller as it would for `upgradeWebSocket`
4501
+ * (`takePort`), and `protocol` (`""` for none) is the subprotocol picked.
4502
+ */
4503
+ function webSocketUpgradeResponse(port, protocol = "") {
3936
4504
  const response = new Response(null, {
3937
4505
  status: 200,
3938
4506
  headers: protocol ? { "sec-websocket-protocol": protocol } : {}
@@ -3946,23 +4514,16 @@ function upgradeWebSocket(request, options = {}) {
3946
4514
  value: "Switching Protocols",
3947
4515
  configurable: true
3948
4516
  },
3949
- webSocket: {
3950
- value: socket,
3951
- configurable: true
3952
- },
3953
4517
  [UPGRADE]: {
3954
4518
  value: {
3955
- port: channel.port2,
4519
+ port,
3956
4520
  protocol
3957
4521
  },
3958
4522
  configurable: true,
3959
4523
  writable: true
3960
4524
  }
3961
4525
  });
3962
- return {
3963
- socket,
3964
- response
3965
- };
4526
+ return response;
3966
4527
  }
3967
4528
  /**
3968
4529
  * The port to hand to the caller, for a response from `upgradeWebSocket`.
@@ -4029,6 +4590,15 @@ function randomKey() {
4029
4590
  crypto.getRandomValues(bytes);
4030
4591
  return btoa(String.fromCharCode(...bytes));
4031
4592
  }
4593
+ /** `headers` as pairs a `postMessage` can carry (a `Headers` object cannot be cloned). */
4594
+ function headerPairs(headers) {
4595
+ if (headers === void 0) return void 0;
4596
+ const pairs = [];
4597
+ new Headers(headers).forEach((value, name) => {
4598
+ pairs.push([name, value]);
4599
+ });
4600
+ return pairs;
4601
+ }
4032
4602
  //#endregion
4033
4603
  //#region src/sw/handler-endpoint.ts
4034
4604
  /** The message calls through a lost ServiceWorker fail with. */
@@ -4113,8 +4683,11 @@ var HandlerEndpoint = class {
4113
4683
  this._startKeepAlive();
4114
4684
  return await link.up.send(request);
4115
4685
  }
4116
- /** Opens a socket to the handler `url` routes to, through the ServiceWorker. */
4117
- async connectWebSocket(url, protocols = []) {
4686
+ /**
4687
+ * Opens a socket to the handler `url` routes to, through the ServiceWorker. The handler sees
4688
+ * `headers` on the upgrade request, and so does a `route` function choosing it.
4689
+ */
4690
+ async connectWebSocket(url, protocols = [], headers) {
4118
4691
  const link = await this._linkWith("ctl");
4119
4692
  const id = this._sockets.newId();
4120
4693
  const answer = this._sockets.wait(id, this.options.wsConnectTimeoutMs ?? 15e3, `the socket request for ${url}`);
@@ -4122,7 +4695,8 @@ var HandlerEndpoint = class {
4122
4695
  t: "ws-open",
4123
4696
  id,
4124
4697
  url,
4125
- protocols
4698
+ protocols,
4699
+ headers: headerPairs(headers)
4126
4700
  });
4127
4701
  return await answer;
4128
4702
  }
@@ -4292,11 +4866,11 @@ function serveHttpInWorker(handler, options = {}) {
4292
4866
  };
4293
4867
  scope.addEventListener("message", onMessage);
4294
4868
  const baseUrl = () => globalThis.location?.href;
4295
- const connectWebSocket = (url, protocols = []) => {
4869
+ const connectWebSocket = (url, protocols = [], headers) => {
4296
4870
  const httpUrl = new URL(`${url}`, baseUrl());
4297
4871
  if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
4298
4872
  else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
4299
- return endpoint.connectWebSocket(`${httpUrl}`, protocols);
4873
+ return endpoint.connectWebSocket(`${httpUrl}`, protocols, headers);
4300
4874
  };
4301
4875
  let WebSocketClass;
4302
4876
  return {
@@ -4323,4 +4897,4 @@ function serveHttpInWorker(handler, options = {}) {
4323
4897
  };
4324
4898
  }
4325
4899
  //#endregion
4326
- export { DEFAULT_CLOSE_TIMEOUT_MS, DEFAULT_HEARTBEAT_MS, HTTP_LINK_PROTOCOL, LinkLostError, ServerWebSocket, WS_PORT_PROTOCOL_VERSION, WS_VERSION_HEADER, WsCloseEvent, connectHttpLink, createWebSocketClass, isWebSocketUpgrade, newWebSocketUpgradeRequest, requestedProtocols, serveHttpInWorker, serveHttpLink, serveWebSocketUpgrade, takePort, takeUpgrade, trackedLink, upgradeWebSocket, withHeaders };
4900
+ export { DEFAULT_CLOSE_TIMEOUT_MS, DEFAULT_HEARTBEAT_MS, HTTP_LINK_PROTOCOL, LinkLostError, ServerWebSocket, WS_PORT_PROTOCOL_VERSION, WS_VERSION_HEADER, WsCloseEvent, connectHttpLink, createWebSocketClass, isWebSocketUpgrade, newWebSocketUpgradeRequest, requestedProtocols, serveHttpInWorker, serveHttpLink, serveWebSocketUpgrade, takePort, takeUpgrade, trackedLink, upgradeWebSocket, webSocketUpgradeResponse, withHeaders };