@statewalker/webrun-http-browser 0.9.0 → 0.10.0

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.
package/dist/index.js CHANGED
@@ -2126,6 +2126,12 @@ function asyncDisposable$2(target, dispose) {
2126
2126
  }
2127
2127
  /** Carries the serialized JS error between two webrun peers. */
2128
2128
  const PEER_ERROR_HEADER = "x-webrun-error";
2129
+ /**
2130
+ * The reason of `HttpExchange.signal` when the exchange is abandoned with no
2131
+ * error of its own (`return()`); one abandoned by `throw(error)` aborts with
2132
+ * that error.
2133
+ */
2134
+ const EXCHANGE_ABANDONED_MESSAGE = "webrun-http-streams: the exchange was abandoned before it was over";
2129
2135
  /** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
2130
2136
  const MAX_DETAIL_CHARS = 4096;
2131
2137
  /**
@@ -2306,7 +2312,11 @@ async function drain(output) {
2306
2312
  */
2307
2313
  function httpServe(handler, options = {}) {
2308
2314
  const codec = options.codec ?? defaultCodec;
2309
- return async function* httpHandlerDuplex(input) {
2315
+ return (input) => {
2316
+ const abandoned = new AbortController();
2317
+ 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")));
2318
+ };
2319
+ async function* exchange(input, context) {
2310
2320
  let decoded;
2311
2321
  try {
2312
2322
  decoded = await codec.decodeRequest(input);
@@ -2318,7 +2328,7 @@ function httpServe(handler, options = {}) {
2318
2328
  const replyCodec = decoded.codec ?? codec;
2319
2329
  let result;
2320
2330
  try {
2321
- result = await handler(decoded.envelope, decoded.body);
2331
+ result = await handler(decoded.envelope, decoded.body, context);
2322
2332
  } catch (error) {
2323
2333
  yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
2324
2334
  return;
@@ -2333,7 +2343,36 @@ function httpServe(handler, options = {}) {
2333
2343
  await result.release?.();
2334
2344
  } catch {}
2335
2345
  }
2346
+ }
2347
+ }
2348
+ /**
2349
+ * `gen`, except that `return()` and `throw()` from its consumer call `abandon`
2350
+ * at once — before they are queued behind a `next()` still parked inside the
2351
+ * generator (the handler's `await`) — unless the generator already finished.
2352
+ */
2353
+ function abortingOnReturn(gen, abandon) {
2354
+ let finished = false;
2355
+ const settle = (r) => {
2356
+ if (r.done) finished = true;
2357
+ return r;
2358
+ };
2359
+ const wrapped = {
2360
+ next: (...args) => gen.next(...args).then(settle),
2361
+ return(value) {
2362
+ if (!finished) abandon();
2363
+ finished = true;
2364
+ return gen.return(value);
2365
+ },
2366
+ throw(error) {
2367
+ if (!finished) abandon(error);
2368
+ finished = true;
2369
+ return gen.throw(error);
2370
+ },
2371
+ [Symbol.asyncIterator]() {
2372
+ return wrapped;
2373
+ }
2336
2374
  };
2375
+ return asyncDisposable$2(wrapped, () => wrapped.return(void 0));
2337
2376
  }
2338
2377
  /**
2339
2378
  * The one place that knows a runtime may not implement request body streams,
@@ -2605,10 +2644,11 @@ async function fetchOverDuplex(call, request, options = {}) {
2605
2644
  * registered with any `webrun-streams-*` adapter's `serve`.
2606
2645
  */
2607
2646
  function serveFetchOverDuplex(handler, options = {}) {
2608
- return httpServe(async (env, body) => {
2647
+ return httpServe(async (env, body, exchange) => {
2609
2648
  const reqInit = {
2610
2649
  method: env.method,
2611
- headers: forwardableHeaders(env.headers)
2650
+ headers: forwardableHeaders(env.headers),
2651
+ signal: exchange?.signal
2612
2652
  };
2613
2653
  let upload;
2614
2654
  if (env.method !== "GET" && env.method !== "HEAD") {
@@ -3469,7 +3509,11 @@ function receiveChunks(port, channelName, controller, touch) {
3469
3509
  * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
3470
3510
  */
3471
3511
  async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
3472
- const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
3512
+ const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
3513
+ const stream = throughAbort(framed, signal);
3514
+ const producer = output;
3515
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
3516
+ if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
3473
3517
  try {
3474
3518
  await sendIterator(async ({ done, value, error }) => {
3475
3519
  if (signal.aborted) return;
@@ -3493,6 +3537,8 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
3493
3537
  } catch (err) {
3494
3538
  if (signal.aborted) return;
3495
3539
  throw err;
3540
+ } finally {
3541
+ signal.removeEventListener("abort", returnProducer);
3496
3542
  }
3497
3543
  }
3498
3544
  /**
@@ -3631,10 +3677,18 @@ function estimateMessageSize(payload) {
3631
3677
  */
3632
3678
  function multiplexPort(port, options) {
3633
3679
  const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
3680
+ const maxReassembledBytes = options.maxReassembledBytes ?? 67108864;
3634
3681
  const misses = keepalive?.misses ?? 3;
3635
3682
  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`);
3636
3683
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
3684
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) throw new RangeError(`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`);
3637
3685
  const open = /* @__PURE__ */ new Map();
3686
+ let reassembling = 0;
3687
+ const freePartial = (entry) => {
3688
+ if (!entry?.partial) return;
3689
+ reassembling -= entry.partial.charged;
3690
+ entry.partial = void 0;
3691
+ };
3638
3692
  const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
3639
3693
  const entry = open.get(id);
3640
3694
  if (entry?.handle !== record) return;
@@ -3782,12 +3836,12 @@ function multiplexPort(port, options) {
3782
3836
  ...envelope,
3783
3837
  payload: structuredClone(payload, { transfer })
3784
3838
  } : void 0;
3785
- const send = () => {
3839
+ const send = (whole = false) => {
3786
3840
  if (!bytes) {
3787
3841
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
3788
3842
  return;
3789
3843
  }
3790
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
3844
+ if (!whole && frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
3791
3845
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
3792
3846
  const data = bytes.subarray(at, at + frameSize);
3793
3847
  const frame = at + frameSize >= bytes.byteLength ? {
@@ -3817,10 +3871,11 @@ function multiplexPort(port, options) {
3817
3871
  record.markClosed(reason);
3818
3872
  });
3819
3873
  function release(reason) {
3874
+ freePartial(open.get(id));
3820
3875
  open.delete(id);
3821
3876
  leaks?.unregister(record);
3822
3877
  if (c?.queue.length) {
3823
- for (const item of c.queue.splice(0)) item.send();
3878
+ for (const item of c.queue.splice(0)) item.send(true);
3824
3879
  c.queued = 0;
3825
3880
  }
3826
3881
  sendOnPort(id, () => post({
@@ -3959,15 +4014,38 @@ function multiplexPort(port, options) {
3959
4014
  if (envelope.type === "frame") {
3960
4015
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
3961
4016
  touch(envelope.id);
4017
+ const c = entry.credit;
4018
+ const owed = c.unconsumed + c.toGrant;
3962
4019
  const partial = entry.partial ?? {
3963
4020
  chunks: [],
3964
- bytes: 0
4021
+ bytes: 0,
4022
+ owed,
4023
+ charged: 0,
4024
+ ...owed > 0 ? { room: c.window - owed } : {}
3965
4025
  };
3966
4026
  entry.partial = partial;
4027
+ const size = envelope.data.byteLength;
4028
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
4029
+ 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;
4030
+ if (overrun) {
4031
+ freePartial(entry);
4032
+ dropOutgoing(envelope.id);
4033
+ open.delete(envelope.id);
4034
+ releaseWaiters(c);
4035
+ entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
4036
+ post({
4037
+ type: "close",
4038
+ id: envelope.id,
4039
+ reason: "max-reassembled-bytes"
4040
+ });
4041
+ return;
4042
+ }
4043
+ partial.bytes += size;
4044
+ reassembling += charged - partial.charged;
4045
+ partial.charged = charged;
3967
4046
  partial.chunks.push(envelope.data);
3968
- partial.bytes += envelope.data.byteLength;
3969
4047
  if (!envelope.last) return;
3970
- entry.partial = void 0;
4048
+ freePartial(entry);
3971
4049
  const whole = new Uint8Array(partial.bytes);
3972
4050
  let at = 0;
3973
4051
  for (const chunk of partial.chunks) {
@@ -3981,6 +4059,7 @@ function multiplexPort(port, options) {
3981
4059
  }
3982
4060
  dropOutgoing(envelope.id);
3983
4061
  if (!entry) return;
4062
+ freePartial(entry);
3984
4063
  open.delete(envelope.id);
3985
4064
  releaseWaiters(entry.credit);
3986
4065
  entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
@@ -4171,14 +4250,17 @@ const serve = async ({ mux: factory, timeout }, handler) => {
4171
4250
  const mux = await factory((port) => {
4172
4251
  let off;
4173
4252
  let finished = false;
4174
- const tracked = (input) => (async function* () {
4175
- try {
4176
- yield* handler(input);
4177
- } finally {
4178
- finished = true;
4179
- if (off) live.delete(off);
4180
- }
4181
- })();
4253
+ const tracked = (input) => {
4254
+ const output = handler(input);
4255
+ return returningAtOnce((async function* () {
4256
+ try {
4257
+ yield* output;
4258
+ } finally {
4259
+ finished = true;
4260
+ if (off) live.delete(off);
4261
+ }
4262
+ })(), () => void output.return(void 0).catch(() => {}));
4263
+ };
4182
4264
  off = serveDuplexOverPort(port, tracked, streamOptions);
4183
4265
  if (!finished) live.add(off);
4184
4266
  });
@@ -7408,4 +7490,4 @@ function serveHttpInWorker(handler, options = {}) {
7408
7490
  };
7409
7491
  }
7410
7492
  //#endregion
7411
- export { CLAIM_CALL, DEFAULT_CLOSE_TIMEOUT_MS, DEFAULT_HEARTBEAT_MS, DEFAULT_SERVICE_WORKER_TIMEOUT, DuplexSiteBuilder, HANDLER_LOST_MESSAGE, HTTP_LINK_PROTOCOL, HandlerEndpoint, HandlerTable, HttpError, HttpParseError, LinkLostError, PEER_ERROR_HEADER, PortRendezvous, ROUTER_LOST_MESSAGE, ServerWebSocket, ServiceWorkerControlError, SwHttpAdapter, SwHttpDispatcher, SwPortDispatcher, SwPortHandler, TransportClosedError, WS_PORT_PROTOCOL_VERSION, WS_VERSION_HEADER, WsCloseEvent, answerConnect, awaitActiveServiceWorker, awaitServiceWorkerControl, callChannel, callChannelWithin, callHttpService, callWebSocketService, collect, collectBytes, collectString, connectHttpLink, connectWebSocketThroughServiceWorker, createWebSocketClass, decodeJsonl, decodeMessage, decodeText, defaultCodec, deserializeError, emulateMux, encodeJsonl, encodeMessage, encodeText, fetchOverDuplex, fromReadableStream, getRelayWindowMessageHandler, handleChannelCalls, handleClaimRequests, handleHttpRequests, handleStreams, holdLock, httpCodec, httpFetch, httpServe, initHttpService, initServiceWorker, initializeConnection, isWebSocketUpgrade, joinLines, jsonEnvelopeCodec, leastInflightRoute, locksAvailable, map, newAsyncGenerator, newCreditGrantor, newCreditLedger, newHttpClientStub, newHttpCodec, newHttpServerStub, newInvokationChannel, newLockName, newRegistry, newRemoteRelayChannel, newServiceWorkerPort, newSniffingCodec, newWebSocketUpgradeRequest, normalizeToUint8Array, recieveIterator, registerConnectionsHandler, relayWebSocketClass, requestedProtocols, sendHttpRequest, sendIterator, sendOverConnection, sendStream, serializeError, serveFetchOverDuplex, serveHttpInWorker, serveHttpLink, serveWebSocketUpgrade, serviceWorkerWebSocketClass, splitLines, splitServiceUrl, startHttpDispatcher, takePort, takeUpgrade, toChunks, toReadableStream, trackedLink, unwrapTransfer, upgradeWebSocket, watchLock, withHeaders, withTransfer };
7493
+ export { CLAIM_CALL, DEFAULT_CLOSE_TIMEOUT_MS, DEFAULT_HEARTBEAT_MS, DEFAULT_SERVICE_WORKER_TIMEOUT, DuplexSiteBuilder, EXCHANGE_ABANDONED_MESSAGE, HANDLER_LOST_MESSAGE, HTTP_LINK_PROTOCOL, HandlerEndpoint, HandlerTable, HttpError, HttpParseError, LinkLostError, PEER_ERROR_HEADER, PortRendezvous, ROUTER_LOST_MESSAGE, ServerWebSocket, ServiceWorkerControlError, SwHttpAdapter, SwHttpDispatcher, SwPortDispatcher, SwPortHandler, TransportClosedError, WS_PORT_PROTOCOL_VERSION, WS_VERSION_HEADER, WsCloseEvent, answerConnect, awaitActiveServiceWorker, awaitServiceWorkerControl, callChannel, callChannelWithin, callHttpService, callWebSocketService, collect, collectBytes, collectString, connectHttpLink, connectWebSocketThroughServiceWorker, createWebSocketClass, decodeJsonl, decodeMessage, decodeText, defaultCodec, deserializeError, emulateMux, encodeJsonl, encodeMessage, encodeText, fetchOverDuplex, fromReadableStream, getRelayWindowMessageHandler, handleChannelCalls, handleClaimRequests, handleHttpRequests, handleStreams, holdLock, httpCodec, httpFetch, httpServe, initHttpService, initServiceWorker, initializeConnection, isWebSocketUpgrade, joinLines, jsonEnvelopeCodec, leastInflightRoute, locksAvailable, map, newAsyncGenerator, newCreditGrantor, newCreditLedger, newHttpClientStub, newHttpCodec, newHttpServerStub, newInvokationChannel, newLockName, newRegistry, newRemoteRelayChannel, newServiceWorkerPort, newSniffingCodec, newWebSocketUpgradeRequest, normalizeToUint8Array, recieveIterator, registerConnectionsHandler, relayWebSocketClass, requestedProtocols, sendHttpRequest, sendIterator, sendOverConnection, sendStream, serializeError, serveFetchOverDuplex, serveHttpInWorker, serveHttpLink, serveWebSocketUpgrade, serviceWorkerWebSocketClass, splitLines, splitServiceUrl, startHttpDispatcher, takePort, takeUpgrade, toChunks, toReadableStream, trackedLink, unwrapTransfer, upgradeWebSocket, watchLock, withHeaders, withTransfer };
package/dist/relay-sw.js CHANGED
@@ -2141,7 +2141,11 @@
2141
2141
  * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
2142
2142
  */
2143
2143
  async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
2144
- const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
2144
+ const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
2145
+ const stream = throughAbort(framed, signal);
2146
+ const producer = output;
2147
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
2148
+ if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
2145
2149
  try {
2146
2150
  await sendIterator(async ({ done, value, error }) => {
2147
2151
  if (signal.aborted) return;
@@ -2165,6 +2169,8 @@
2165
2169
  } catch (err) {
2166
2170
  if (signal.aborted) return;
2167
2171
  throw err;
2172
+ } finally {
2173
+ signal.removeEventListener("abort", returnProducer);
2168
2174
  }
2169
2175
  }
2170
2176
  /**
@@ -2303,10 +2309,18 @@
2303
2309
  */
2304
2310
  function multiplexPort(port, options) {
2305
2311
  const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
2312
+ const maxReassembledBytes = options.maxReassembledBytes ?? 67108864;
2306
2313
  const misses = keepalive?.misses ?? 3;
2307
2314
  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
2315
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
2316
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) throw new RangeError(`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`);
2309
2317
  const open = /* @__PURE__ */ new Map();
2318
+ let reassembling = 0;
2319
+ const freePartial = (entry) => {
2320
+ if (!entry?.partial) return;
2321
+ reassembling -= entry.partial.charged;
2322
+ entry.partial = void 0;
2323
+ };
2310
2324
  const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
2311
2325
  const entry = open.get(id);
2312
2326
  if (entry?.handle !== record) return;
@@ -2454,12 +2468,12 @@
2454
2468
  ...envelope,
2455
2469
  payload: structuredClone(payload, { transfer })
2456
2470
  } : void 0;
2457
- const send = () => {
2471
+ const send = (whole = false) => {
2458
2472
  if (!bytes) {
2459
2473
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
2460
2474
  return;
2461
2475
  }
2462
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2476
+ if (!whole && frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2463
2477
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
2464
2478
  const data = bytes.subarray(at, at + frameSize);
2465
2479
  const frame = at + frameSize >= bytes.byteLength ? {
@@ -2489,10 +2503,11 @@
2489
2503
  record.markClosed(reason);
2490
2504
  });
2491
2505
  function release(reason) {
2506
+ freePartial(open.get(id));
2492
2507
  open.delete(id);
2493
2508
  leaks?.unregister(record);
2494
2509
  if (c?.queue.length) {
2495
- for (const item of c.queue.splice(0)) item.send();
2510
+ for (const item of c.queue.splice(0)) item.send(true);
2496
2511
  c.queued = 0;
2497
2512
  }
2498
2513
  sendOnPort(id, () => post({
@@ -2631,15 +2646,38 @@
2631
2646
  if (envelope.type === "frame") {
2632
2647
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
2633
2648
  touch(envelope.id);
2649
+ const c = entry.credit;
2650
+ const owed = c.unconsumed + c.toGrant;
2634
2651
  const partial = entry.partial ?? {
2635
2652
  chunks: [],
2636
- bytes: 0
2653
+ bytes: 0,
2654
+ owed,
2655
+ charged: 0,
2656
+ ...owed > 0 ? { room: c.window - owed } : {}
2637
2657
  };
2638
2658
  entry.partial = partial;
2659
+ const size = envelope.data.byteLength;
2660
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
2661
+ 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;
2662
+ if (overrun) {
2663
+ freePartial(entry);
2664
+ dropOutgoing(envelope.id);
2665
+ open.delete(envelope.id);
2666
+ releaseWaiters(c);
2667
+ entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
2668
+ post({
2669
+ type: "close",
2670
+ id: envelope.id,
2671
+ reason: "max-reassembled-bytes"
2672
+ });
2673
+ return;
2674
+ }
2675
+ partial.bytes += size;
2676
+ reassembling += charged - partial.charged;
2677
+ partial.charged = charged;
2639
2678
  partial.chunks.push(envelope.data);
2640
- partial.bytes += envelope.data.byteLength;
2641
2679
  if (!envelope.last) return;
2642
- entry.partial = void 0;
2680
+ freePartial(entry);
2643
2681
  const whole = new Uint8Array(partial.bytes);
2644
2682
  let at = 0;
2645
2683
  for (const chunk of partial.chunks) {
@@ -2653,6 +2691,7 @@
2653
2691
  }
2654
2692
  dropOutgoing(envelope.id);
2655
2693
  if (!entry) return;
2694
+ freePartial(entry);
2656
2695
  open.delete(envelope.id);
2657
2696
  releaseWaiters(entry.credit);
2658
2697
  entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
@@ -2140,7 +2140,11 @@ function receiveChunks(port, channelName, controller, touch) {
2140
2140
  * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
2141
2141
  */
2142
2142
  async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
2143
- const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
2143
+ const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
2144
+ const stream = throughAbort(framed, signal);
2145
+ const producer = output;
2146
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
2147
+ if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
2144
2148
  try {
2145
2149
  await sendIterator(async ({ done, value, error }) => {
2146
2150
  if (signal.aborted) return;
@@ -2164,6 +2168,8 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
2164
2168
  } catch (err) {
2165
2169
  if (signal.aborted) return;
2166
2170
  throw err;
2171
+ } finally {
2172
+ signal.removeEventListener("abort", returnProducer);
2167
2173
  }
2168
2174
  }
2169
2175
  /**
@@ -2302,10 +2308,18 @@ function estimateMessageSize(payload) {
2302
2308
  */
2303
2309
  function multiplexPort(port, options) {
2304
2310
  const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
2311
+ const maxReassembledBytes = options.maxReassembledBytes ?? 67108864;
2305
2312
  const misses = keepalive?.misses ?? 3;
2306
2313
  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`);
2307
2314
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
2315
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) throw new RangeError(`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`);
2308
2316
  const open = /* @__PURE__ */ new Map();
2317
+ let reassembling = 0;
2318
+ const freePartial = (entry) => {
2319
+ if (!entry?.partial) return;
2320
+ reassembling -= entry.partial.charged;
2321
+ entry.partial = void 0;
2322
+ };
2309
2323
  const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
2310
2324
  const entry = open.get(id);
2311
2325
  if (entry?.handle !== record) return;
@@ -2453,12 +2467,12 @@ function multiplexPort(port, options) {
2453
2467
  ...envelope,
2454
2468
  payload: structuredClone(payload, { transfer })
2455
2469
  } : void 0;
2456
- const send = () => {
2470
+ const send = (whole = false) => {
2457
2471
  if (!bytes) {
2458
2472
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
2459
2473
  return;
2460
2474
  }
2461
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2475
+ if (!whole && frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2462
2476
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
2463
2477
  const data = bytes.subarray(at, at + frameSize);
2464
2478
  const frame = at + frameSize >= bytes.byteLength ? {
@@ -2488,10 +2502,11 @@ function multiplexPort(port, options) {
2488
2502
  record.markClosed(reason);
2489
2503
  });
2490
2504
  function release(reason) {
2505
+ freePartial(open.get(id));
2491
2506
  open.delete(id);
2492
2507
  leaks?.unregister(record);
2493
2508
  if (c?.queue.length) {
2494
- for (const item of c.queue.splice(0)) item.send();
2509
+ for (const item of c.queue.splice(0)) item.send(true);
2495
2510
  c.queued = 0;
2496
2511
  }
2497
2512
  sendOnPort(id, () => post({
@@ -2630,15 +2645,38 @@ function multiplexPort(port, options) {
2630
2645
  if (envelope.type === "frame") {
2631
2646
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
2632
2647
  touch(envelope.id);
2648
+ const c = entry.credit;
2649
+ const owed = c.unconsumed + c.toGrant;
2633
2650
  const partial = entry.partial ?? {
2634
2651
  chunks: [],
2635
- bytes: 0
2652
+ bytes: 0,
2653
+ owed,
2654
+ charged: 0,
2655
+ ...owed > 0 ? { room: c.window - owed } : {}
2636
2656
  };
2637
2657
  entry.partial = partial;
2658
+ const size = envelope.data.byteLength;
2659
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
2660
+ const overrun = partial.room != null && partial.bytes + size > partial.room ? `what the window left (${partial.room} of ${c.window} bytes, ${partial.owed} still owed)` : reassembling - partial.charged + charged > maxReassembledBytes ? `maxReassembledBytes (${maxReassembledBytes} bytes past the window, across all ports)` : void 0;
2661
+ if (overrun) {
2662
+ freePartial(entry);
2663
+ dropOutgoing(envelope.id);
2664
+ open.delete(envelope.id);
2665
+ releaseWaiters(c);
2666
+ entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
2667
+ post({
2668
+ type: "close",
2669
+ id: envelope.id,
2670
+ reason: "max-reassembled-bytes"
2671
+ });
2672
+ return;
2673
+ }
2674
+ partial.bytes += size;
2675
+ reassembling += charged - partial.charged;
2676
+ partial.charged = charged;
2638
2677
  partial.chunks.push(envelope.data);
2639
- partial.bytes += envelope.data.byteLength;
2640
2678
  if (!envelope.last) return;
2641
- entry.partial = void 0;
2679
+ freePartial(entry);
2642
2680
  const whole = new Uint8Array(partial.bytes);
2643
2681
  let at = 0;
2644
2682
  for (const chunk of partial.chunks) {
@@ -2652,6 +2690,7 @@ function multiplexPort(port, options) {
2652
2690
  }
2653
2691
  dropOutgoing(envelope.id);
2654
2692
  if (!entry) return;
2693
+ freePartial(entry);
2655
2694
  open.delete(envelope.id);
2656
2695
  releaseWaiters(entry.credit);
2657
2696
  entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
package/dist/sw-worker.js CHANGED
@@ -1394,7 +1394,11 @@
1394
1394
  */
1395
1395
  function httpServe(handler, options = {}) {
1396
1396
  const codec = options.codec ?? defaultCodec;
1397
- return async function* httpHandlerDuplex(input) {
1397
+ return (input) => {
1398
+ const abandoned = new AbortController();
1399
+ 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")));
1400
+ };
1401
+ async function* exchange(input, context) {
1398
1402
  let decoded;
1399
1403
  try {
1400
1404
  decoded = await codec.decodeRequest(input);
@@ -1406,7 +1410,7 @@
1406
1410
  const replyCodec = decoded.codec ?? codec;
1407
1411
  let result;
1408
1412
  try {
1409
- result = await handler(decoded.envelope, decoded.body);
1413
+ result = await handler(decoded.envelope, decoded.body, context);
1410
1414
  } catch (error) {
1411
1415
  yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
1412
1416
  return;
@@ -1421,7 +1425,36 @@
1421
1425
  await result.release?.();
1422
1426
  } catch {}
1423
1427
  }
1428
+ }
1429
+ }
1430
+ /**
1431
+ * `gen`, except that `return()` and `throw()` from its consumer call `abandon`
1432
+ * at once — before they are queued behind a `next()` still parked inside the
1433
+ * generator (the handler's `await`) — unless the generator already finished.
1434
+ */
1435
+ function abortingOnReturn(gen, abandon) {
1436
+ let finished = false;
1437
+ const settle = (r) => {
1438
+ if (r.done) finished = true;
1439
+ return r;
1440
+ };
1441
+ const wrapped = {
1442
+ next: (...args) => gen.next(...args).then(settle),
1443
+ return(value) {
1444
+ if (!finished) abandon();
1445
+ finished = true;
1446
+ return gen.return(value);
1447
+ },
1448
+ throw(error) {
1449
+ if (!finished) abandon(error);
1450
+ finished = true;
1451
+ return gen.throw(error);
1452
+ },
1453
+ [Symbol.asyncIterator]() {
1454
+ return wrapped;
1455
+ }
1424
1456
  };
1457
+ return asyncDisposable$2(wrapped, () => wrapped.return(void 0));
1425
1458
  }
1426
1459
  /**
1427
1460
  * The one place that knows a runtime may not implement request body streams,
@@ -1660,10 +1693,11 @@
1660
1693
  * registered with any `webrun-streams-*` adapter's `serve`.
1661
1694
  */
1662
1695
  function serveFetchOverDuplex(handler, options = {}) {
1663
- return httpServe(async (env, body) => {
1696
+ return httpServe(async (env, body, exchange) => {
1664
1697
  const reqInit = {
1665
1698
  method: env.method,
1666
- headers: forwardableHeaders(env.headers)
1699
+ headers: forwardableHeaders(env.headers),
1700
+ signal: exchange?.signal
1667
1701
  };
1668
1702
  let upload;
1669
1703
  if (env.method !== "GET" && env.method !== "HEAD") {
@@ -2565,7 +2599,11 @@
2565
2599
  * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
2566
2600
  */
2567
2601
  async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
2568
- const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
2602
+ const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
2603
+ const stream = throughAbort(framed, signal);
2604
+ const producer = output;
2605
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
2606
+ if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
2569
2607
  try {
2570
2608
  await sendIterator(async ({ done, value, error }) => {
2571
2609
  if (signal.aborted) return;
@@ -2589,6 +2627,8 @@
2589
2627
  } catch (err) {
2590
2628
  if (signal.aborted) return;
2591
2629
  throw err;
2630
+ } finally {
2631
+ signal.removeEventListener("abort", returnProducer);
2592
2632
  }
2593
2633
  }
2594
2634
  /**
@@ -2727,10 +2767,18 @@
2727
2767
  */
2728
2768
  function multiplexPort(port, options) {
2729
2769
  const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
2770
+ const maxReassembledBytes = options.maxReassembledBytes ?? 67108864;
2730
2771
  const misses = keepalive?.misses ?? 3;
2731
2772
  if (keepalive && !(Number.isFinite(keepalive.intervalMs) && keepalive.intervalMs > 0 && keepalive.intervalMs <= MAX_TIMER_MS && Number.isSafeInteger(misses) && misses > 0)) throw new RangeError(`webrun-rpc: keepalive needs an intervalMs in (0, ${MAX_TIMER_MS}] and a positive integer misses`);
2732
2773
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
2774
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) throw new RangeError(`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`);
2733
2775
  const open = /* @__PURE__ */ new Map();
2776
+ let reassembling = 0;
2777
+ const freePartial = (entry) => {
2778
+ if (!entry?.partial) return;
2779
+ reassembling -= entry.partial.charged;
2780
+ entry.partial = void 0;
2781
+ };
2734
2782
  const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
2735
2783
  const entry = open.get(id);
2736
2784
  if (entry?.handle !== record) return;
@@ -2878,12 +2926,12 @@
2878
2926
  ...envelope,
2879
2927
  payload: structuredClone(payload, { transfer })
2880
2928
  } : void 0;
2881
- const send = () => {
2929
+ const send = (whole = false) => {
2882
2930
  if (!bytes) {
2883
2931
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
2884
2932
  return;
2885
2933
  }
2886
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2934
+ if (!whole && frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2887
2935
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
2888
2936
  const data = bytes.subarray(at, at + frameSize);
2889
2937
  const frame = at + frameSize >= bytes.byteLength ? {
@@ -2913,10 +2961,11 @@
2913
2961
  record.markClosed(reason);
2914
2962
  });
2915
2963
  function release(reason) {
2964
+ freePartial(open.get(id));
2916
2965
  open.delete(id);
2917
2966
  leaks?.unregister(record);
2918
2967
  if (c?.queue.length) {
2919
- for (const item of c.queue.splice(0)) item.send();
2968
+ for (const item of c.queue.splice(0)) item.send(true);
2920
2969
  c.queued = 0;
2921
2970
  }
2922
2971
  sendOnPort(id, () => post({
@@ -3055,15 +3104,38 @@
3055
3104
  if (envelope.type === "frame") {
3056
3105
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
3057
3106
  touch(envelope.id);
3107
+ const c = entry.credit;
3108
+ const owed = c.unconsumed + c.toGrant;
3058
3109
  const partial = entry.partial ?? {
3059
3110
  chunks: [],
3060
- bytes: 0
3111
+ bytes: 0,
3112
+ owed,
3113
+ charged: 0,
3114
+ ...owed > 0 ? { room: c.window - owed } : {}
3061
3115
  };
3062
3116
  entry.partial = partial;
3117
+ const size = envelope.data.byteLength;
3118
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
3119
+ const overrun = partial.room != null && partial.bytes + size > partial.room ? `what the window left (${partial.room} of ${c.window} bytes, ${partial.owed} still owed)` : reassembling - partial.charged + charged > maxReassembledBytes ? `maxReassembledBytes (${maxReassembledBytes} bytes past the window, across all ports)` : void 0;
3120
+ if (overrun) {
3121
+ freePartial(entry);
3122
+ dropOutgoing(envelope.id);
3123
+ open.delete(envelope.id);
3124
+ releaseWaiters(c);
3125
+ entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
3126
+ post({
3127
+ type: "close",
3128
+ id: envelope.id,
3129
+ reason: "max-reassembled-bytes"
3130
+ });
3131
+ return;
3132
+ }
3133
+ partial.bytes += size;
3134
+ reassembling += charged - partial.charged;
3135
+ partial.charged = charged;
3063
3136
  partial.chunks.push(envelope.data);
3064
- partial.bytes += envelope.data.byteLength;
3065
3137
  if (!envelope.last) return;
3066
- entry.partial = void 0;
3138
+ freePartial(entry);
3067
3139
  const whole = new Uint8Array(partial.bytes);
3068
3140
  let at = 0;
3069
3141
  for (const chunk of partial.chunks) {
@@ -3077,6 +3149,7 @@
3077
3149
  }
3078
3150
  dropOutgoing(envelope.id);
3079
3151
  if (!entry) return;
3152
+ freePartial(entry);
3080
3153
  open.delete(envelope.id);
3081
3154
  releaseWaiters(entry.credit);
3082
3155
  entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
@@ -3267,14 +3340,17 @@
3267
3340
  const mux = await factory((port) => {
3268
3341
  let off;
3269
3342
  let finished = false;
3270
- const tracked = (input) => (async function* () {
3271
- try {
3272
- yield* handler(input);
3273
- } finally {
3274
- finished = true;
3275
- if (off) live.delete(off);
3276
- }
3277
- })();
3343
+ const tracked = (input) => {
3344
+ const output = handler(input);
3345
+ return returningAtOnce((async function* () {
3346
+ try {
3347
+ yield* output;
3348
+ } finally {
3349
+ finished = true;
3350
+ if (off) live.delete(off);
3351
+ }
3352
+ })(), () => void output.return(void 0).catch(() => {}));
3353
+ };
3278
3354
  off = serveDuplexOverPort(port, tracked, streamOptions);
3279
3355
  if (!finished) live.add(off);
3280
3356
  });
package/dist/sw.js CHANGED
@@ -1266,7 +1266,11 @@ async function drain(output) {
1266
1266
  */
1267
1267
  function httpServe(handler, options = {}) {
1268
1268
  const codec = options.codec ?? defaultCodec;
1269
- return async function* httpHandlerDuplex(input) {
1269
+ return (input) => {
1270
+ const abandoned = new AbortController();
1271
+ 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")));
1272
+ };
1273
+ async function* exchange(input, context) {
1270
1274
  let decoded;
1271
1275
  try {
1272
1276
  decoded = await codec.decodeRequest(input);
@@ -1278,7 +1282,7 @@ function httpServe(handler, options = {}) {
1278
1282
  const replyCodec = decoded.codec ?? codec;
1279
1283
  let result;
1280
1284
  try {
1281
- result = await handler(decoded.envelope, decoded.body);
1285
+ result = await handler(decoded.envelope, decoded.body, context);
1282
1286
  } catch (error) {
1283
1287
  yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
1284
1288
  return;
@@ -1293,7 +1297,36 @@ function httpServe(handler, options = {}) {
1293
1297
  await result.release?.();
1294
1298
  } catch {}
1295
1299
  }
1300
+ }
1301
+ }
1302
+ /**
1303
+ * `gen`, except that `return()` and `throw()` from its consumer call `abandon`
1304
+ * at once — before they are queued behind a `next()` still parked inside the
1305
+ * generator (the handler's `await`) — unless the generator already finished.
1306
+ */
1307
+ function abortingOnReturn(gen, abandon) {
1308
+ let finished = false;
1309
+ const settle = (r) => {
1310
+ if (r.done) finished = true;
1311
+ return r;
1312
+ };
1313
+ const wrapped = {
1314
+ next: (...args) => gen.next(...args).then(settle),
1315
+ return(value) {
1316
+ if (!finished) abandon();
1317
+ finished = true;
1318
+ return gen.return(value);
1319
+ },
1320
+ throw(error) {
1321
+ if (!finished) abandon(error);
1322
+ finished = true;
1323
+ return gen.throw(error);
1324
+ },
1325
+ [Symbol.asyncIterator]() {
1326
+ return wrapped;
1327
+ }
1296
1328
  };
1329
+ return asyncDisposable$2(wrapped, () => wrapped.return(void 0));
1297
1330
  }
1298
1331
  /**
1299
1332
  * The one place that knows a runtime may not implement request body streams,
@@ -1532,10 +1565,11 @@ async function fetchOverDuplex(call, request, options = {}) {
1532
1565
  * registered with any `webrun-streams-*` adapter's `serve`.
1533
1566
  */
1534
1567
  function serveFetchOverDuplex(handler, options = {}) {
1535
- return httpServe(async (env, body) => {
1568
+ return httpServe(async (env, body, exchange) => {
1536
1569
  const reqInit = {
1537
1570
  method: env.method,
1538
- headers: forwardableHeaders(env.headers)
1571
+ headers: forwardableHeaders(env.headers),
1572
+ signal: exchange?.signal
1539
1573
  };
1540
1574
  let upload;
1541
1575
  if (env.method !== "GET" && env.method !== "HEAD") {
@@ -2234,7 +2268,11 @@ function receiveChunks(port, channelName, controller, touch) {
2234
2268
  * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
2235
2269
  */
2236
2270
  async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
2237
- const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
2271
+ const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
2272
+ const stream = throughAbort(framed, signal);
2273
+ const producer = output;
2274
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
2275
+ if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
2238
2276
  try {
2239
2277
  await sendIterator(async ({ done, value, error }) => {
2240
2278
  if (signal.aborted) return;
@@ -2258,6 +2296,8 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
2258
2296
  } catch (err) {
2259
2297
  if (signal.aborted) return;
2260
2298
  throw err;
2299
+ } finally {
2300
+ signal.removeEventListener("abort", returnProducer);
2261
2301
  }
2262
2302
  }
2263
2303
  /**
@@ -2396,10 +2436,18 @@ function estimateMessageSize(payload) {
2396
2436
  */
2397
2437
  function multiplexPort(port, options) {
2398
2438
  const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
2439
+ const maxReassembledBytes = options.maxReassembledBytes ?? 67108864;
2399
2440
  const misses = keepalive?.misses ?? 3;
2400
2441
  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`);
2401
2442
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
2443
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) throw new RangeError(`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`);
2402
2444
  const open = /* @__PURE__ */ new Map();
2445
+ let reassembling = 0;
2446
+ const freePartial = (entry) => {
2447
+ if (!entry?.partial) return;
2448
+ reassembling -= entry.partial.charged;
2449
+ entry.partial = void 0;
2450
+ };
2403
2451
  const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
2404
2452
  const entry = open.get(id);
2405
2453
  if (entry?.handle !== record) return;
@@ -2547,12 +2595,12 @@ function multiplexPort(port, options) {
2547
2595
  ...envelope,
2548
2596
  payload: structuredClone(payload, { transfer })
2549
2597
  } : void 0;
2550
- const send = () => {
2598
+ const send = (whole = false) => {
2551
2599
  if (!bytes) {
2552
2600
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
2553
2601
  return;
2554
2602
  }
2555
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2603
+ if (!whole && frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2556
2604
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
2557
2605
  const data = bytes.subarray(at, at + frameSize);
2558
2606
  const frame = at + frameSize >= bytes.byteLength ? {
@@ -2582,10 +2630,11 @@ function multiplexPort(port, options) {
2582
2630
  record.markClosed(reason);
2583
2631
  });
2584
2632
  function release(reason) {
2633
+ freePartial(open.get(id));
2585
2634
  open.delete(id);
2586
2635
  leaks?.unregister(record);
2587
2636
  if (c?.queue.length) {
2588
- for (const item of c.queue.splice(0)) item.send();
2637
+ for (const item of c.queue.splice(0)) item.send(true);
2589
2638
  c.queued = 0;
2590
2639
  }
2591
2640
  sendOnPort(id, () => post({
@@ -2724,15 +2773,38 @@ function multiplexPort(port, options) {
2724
2773
  if (envelope.type === "frame") {
2725
2774
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
2726
2775
  touch(envelope.id);
2776
+ const c = entry.credit;
2777
+ const owed = c.unconsumed + c.toGrant;
2727
2778
  const partial = entry.partial ?? {
2728
2779
  chunks: [],
2729
- bytes: 0
2780
+ bytes: 0,
2781
+ owed,
2782
+ charged: 0,
2783
+ ...owed > 0 ? { room: c.window - owed } : {}
2730
2784
  };
2731
2785
  entry.partial = partial;
2786
+ const size = envelope.data.byteLength;
2787
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
2788
+ 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;
2789
+ if (overrun) {
2790
+ freePartial(entry);
2791
+ dropOutgoing(envelope.id);
2792
+ open.delete(envelope.id);
2793
+ releaseWaiters(c);
2794
+ entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
2795
+ post({
2796
+ type: "close",
2797
+ id: envelope.id,
2798
+ reason: "max-reassembled-bytes"
2799
+ });
2800
+ return;
2801
+ }
2802
+ partial.bytes += size;
2803
+ reassembling += charged - partial.charged;
2804
+ partial.charged = charged;
2732
2805
  partial.chunks.push(envelope.data);
2733
- partial.bytes += envelope.data.byteLength;
2734
2806
  if (!envelope.last) return;
2735
- entry.partial = void 0;
2807
+ freePartial(entry);
2736
2808
  const whole = new Uint8Array(partial.bytes);
2737
2809
  let at = 0;
2738
2810
  for (const chunk of partial.chunks) {
@@ -2746,6 +2818,7 @@ function multiplexPort(port, options) {
2746
2818
  }
2747
2819
  dropOutgoing(envelope.id);
2748
2820
  if (!entry) return;
2821
+ freePartial(entry);
2749
2822
  open.delete(envelope.id);
2750
2823
  releaseWaiters(entry.credit);
2751
2824
  entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
@@ -2936,14 +3009,17 @@ const serve = async ({ mux: factory, timeout }, handler) => {
2936
3009
  const mux = await factory((port) => {
2937
3010
  let off;
2938
3011
  let finished = false;
2939
- const tracked = (input) => (async function* () {
2940
- try {
2941
- yield* handler(input);
2942
- } finally {
2943
- finished = true;
2944
- if (off) live.delete(off);
2945
- }
2946
- })();
3012
+ const tracked = (input) => {
3013
+ const output = handler(input);
3014
+ return returningAtOnce((async function* () {
3015
+ try {
3016
+ yield* output;
3017
+ } finally {
3018
+ finished = true;
3019
+ if (off) live.delete(off);
3020
+ }
3021
+ })(), () => void output.return(void 0).catch(() => {}));
3022
+ };
2947
3023
  off = serveDuplexOverPort(port, tracked, streamOptions);
2948
3024
  if (!finished) live.add(off);
2949
3025
  });
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") {
@@ -2120,7 +2154,11 @@ function receiveChunks(port, channelName, controller, touch) {
2120
2154
  * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
2121
2155
  */
2122
2156
  async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
2123
- const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
2157
+ const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
2158
+ const stream = throughAbort(framed, signal);
2159
+ const producer = output;
2160
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
2161
+ if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
2124
2162
  try {
2125
2163
  await sendIterator(async ({ done, value, error }) => {
2126
2164
  if (signal.aborted) return;
@@ -2144,6 +2182,8 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
2144
2182
  } catch (err) {
2145
2183
  if (signal.aborted) return;
2146
2184
  throw err;
2185
+ } finally {
2186
+ signal.removeEventListener("abort", returnProducer);
2147
2187
  }
2148
2188
  }
2149
2189
  /**
@@ -2282,10 +2322,18 @@ function estimateMessageSize(payload) {
2282
2322
  */
2283
2323
  function multiplexPort(port, options) {
2284
2324
  const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
2325
+ const maxReassembledBytes = options.maxReassembledBytes ?? 67108864;
2285
2326
  const misses = keepalive?.misses ?? 3;
2286
2327
  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
2328
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
2329
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) throw new RangeError(`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`);
2288
2330
  const open = /* @__PURE__ */ new Map();
2331
+ let reassembling = 0;
2332
+ const freePartial = (entry) => {
2333
+ if (!entry?.partial) return;
2334
+ reassembling -= entry.partial.charged;
2335
+ entry.partial = void 0;
2336
+ };
2289
2337
  const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
2290
2338
  const entry = open.get(id);
2291
2339
  if (entry?.handle !== record) return;
@@ -2433,12 +2481,12 @@ function multiplexPort(port, options) {
2433
2481
  ...envelope,
2434
2482
  payload: structuredClone(payload, { transfer })
2435
2483
  } : void 0;
2436
- const send = () => {
2484
+ const send = (whole = false) => {
2437
2485
  if (!bytes) {
2438
2486
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
2439
2487
  return;
2440
2488
  }
2441
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2489
+ if (!whole && frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
2442
2490
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
2443
2491
  const data = bytes.subarray(at, at + frameSize);
2444
2492
  const frame = at + frameSize >= bytes.byteLength ? {
@@ -2468,10 +2516,11 @@ function multiplexPort(port, options) {
2468
2516
  record.markClosed(reason);
2469
2517
  });
2470
2518
  function release(reason) {
2519
+ freePartial(open.get(id));
2471
2520
  open.delete(id);
2472
2521
  leaks?.unregister(record);
2473
2522
  if (c?.queue.length) {
2474
- for (const item of c.queue.splice(0)) item.send();
2523
+ for (const item of c.queue.splice(0)) item.send(true);
2475
2524
  c.queued = 0;
2476
2525
  }
2477
2526
  sendOnPort(id, () => post({
@@ -2610,15 +2659,38 @@ function multiplexPort(port, options) {
2610
2659
  if (envelope.type === "frame") {
2611
2660
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
2612
2661
  touch(envelope.id);
2662
+ const c = entry.credit;
2663
+ const owed = c.unconsumed + c.toGrant;
2613
2664
  const partial = entry.partial ?? {
2614
2665
  chunks: [],
2615
- bytes: 0
2666
+ bytes: 0,
2667
+ owed,
2668
+ charged: 0,
2669
+ ...owed > 0 ? { room: c.window - owed } : {}
2616
2670
  };
2617
2671
  entry.partial = partial;
2672
+ const size = envelope.data.byteLength;
2673
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
2674
+ 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;
2675
+ if (overrun) {
2676
+ freePartial(entry);
2677
+ dropOutgoing(envelope.id);
2678
+ open.delete(envelope.id);
2679
+ releaseWaiters(c);
2680
+ entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
2681
+ post({
2682
+ type: "close",
2683
+ id: envelope.id,
2684
+ reason: "max-reassembled-bytes"
2685
+ });
2686
+ return;
2687
+ }
2688
+ partial.bytes += size;
2689
+ reassembling += charged - partial.charged;
2690
+ partial.charged = charged;
2618
2691
  partial.chunks.push(envelope.data);
2619
- partial.bytes += envelope.data.byteLength;
2620
2692
  if (!envelope.last) return;
2621
- entry.partial = void 0;
2693
+ freePartial(entry);
2622
2694
  const whole = new Uint8Array(partial.bytes);
2623
2695
  let at = 0;
2624
2696
  for (const chunk of partial.chunks) {
@@ -2632,6 +2704,7 @@ function multiplexPort(port, options) {
2632
2704
  }
2633
2705
  dropOutgoing(envelope.id);
2634
2706
  if (!entry) return;
2707
+ freePartial(entry);
2635
2708
  open.delete(envelope.id);
2636
2709
  releaseWaiters(entry.credit);
2637
2710
  entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
@@ -2822,14 +2895,17 @@ const serve = async ({ mux: factory, timeout }, handler) => {
2822
2895
  const mux = await factory((port) => {
2823
2896
  let off;
2824
2897
  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
- })();
2898
+ const tracked = (input) => {
2899
+ const output = handler(input);
2900
+ return returningAtOnce((async function* () {
2901
+ try {
2902
+ yield* output;
2903
+ } finally {
2904
+ finished = true;
2905
+ if (off) live.delete(off);
2906
+ }
2907
+ })(), () => void output.return(void 0).catch(() => {}));
2908
+ };
2833
2909
  off = serveDuplexOverPort(port, tracked, streamOptions);
2834
2910
  if (!finished) live.add(off);
2835
2911
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-browser",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "ServiceWorker-based HTTP and WebSocket server for browsers: handlers in pages and Dedicated Workers, same-origin and relay modes, streams with backpressure",
@@ -58,9 +58,9 @@
58
58
  ],
59
59
  "dependencies": {
60
60
  "idb-keyval": "^6.3.0",
61
- "@statewalker/webrun-http-streams": "^0.3.2",
61
+ "@statewalker/webrun-rpc": "^0.8.0",
62
62
  "@statewalker/webrun-streams": "^0.2.3",
63
- "@statewalker/webrun-rpc": "^0.7.0"
63
+ "@statewalker/webrun-http-streams": "^0.4.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@biomejs/biome": "^2.5.15",