@statewalker/webrun-rpc 0.7.0 → 0.8.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/README.md CHANGED
@@ -298,6 +298,7 @@ port cannot grow memory here.
298
298
  | `signal` | The transport's life. On abort the mux closes with `signal.reason`; an already-aborted signal gives a closed mux. See below. |
299
299
  | `window` | Per-port flow control in bytes, off when unset. See "A port with a window holds only its own bytes" below. |
300
300
  | `frameSize` | Fair framing in bytes, off when unset; needs `window`. See "Large messages take turns with small ones" below. |
301
+ | `maxReassembledBytes` | Ceiling on what the peer's framed messages hold past their ports' windows, summed over all ports, default `DEFAULT_MAX_REASSEMBLED_BYTES` (64 MiB). Past it, or past what a port's window leaves, the port closes. See "Large messages take turns with small ones" below. |
301
302
  | `keepalive` | `{ intervalMs, misses? }`, off when unset: ping the peer and close the mux after `misses` unanswered pings from a peer that answered before. See "A port lives until its transport or its user lets go" below. |
302
303
  | `onLeak` | Development aid, off when unset: called with a port's `PortInfo` when its consumer dropped it without `close()`, and the port is freed. See "A port lives until its transport or its user lets go" below. |
303
304
 
@@ -374,11 +375,35 @@ frames of messages posted together interleave.
374
375
  It also needs `window` and byte codecs (`encode` / `decode`) on both ends. A side frames only on a
375
376
  port whose window is in force with exact sizes agreed (`exact: true` in the `open` and in its
376
377
  acknowledgement), and accepts frames only there; a frame anywhere else is dropped. An older peer,
377
- or one whose codec cannot decode, gets whole messages. A frame sequence buffers no more than the
378
- same message sent whole would. If the pipe's `whenDrained()` rejects, the mux closes with that
378
+ or one whose codec cannot decode, gets whole messages. If the pipe's `whenDrained()` rejects, the mux closes with that
379
379
  error, so the ports waiting behind it fail instead of hanging. 16-64 KiB frames cost one small envelope each (about 15 bytes with
380
380
  `msgpackCodec`).
381
381
 
382
+ **A peer cannot make this side hold frames without limit.** Two limits apply as frames arrive:
383
+
384
+ - a message begun while this side still owed the port credit (delivered or consumed bytes not yet
385
+ granted back) must fit what the window left, since an honest sender counts at least that much as
386
+ outstanding; one begun with nothing owed may be larger than the window, as a whole message may;
387
+ - what messages larger than the window hold past it, summed across every port, is at most
388
+ `maxReassembledBytes` (64 MiB by default), so opening many ports does not multiply it. A partial
389
+ message within its window is not charged: the window bounds it already, as it bounds unframed
390
+ traffic, so many ports framing honest messages at once never trip the cap.
391
+
392
+ A frame past either limit closes its port: the partial message is dropped, the port's close signal
393
+ aborts with a `RangeError` ("webrun-rpc: a framed message passed what the window left …" or
394
+ "… passed maxReassembledBytes …"), and the peer's port closes as on any peer close. On the wire
395
+ the close carries reason `"max-reassembled-bytes"`. A port that closes with messages still queued
396
+ sends them whole, not framed, so they never trip the window check on the way out.
397
+
398
+ The cap is the receiver's: a sender with `frameSize` that frames a message more than the
399
+ peer's `maxReassembledBytes` past the window has its port closed, where the same message sent whole would arrive.
400
+ Set it above the largest message the peer legitimately frames; a message just past it looks like
401
+ a closed port, not a lost message.
402
+
403
+ Frames carry no sequence number. Reassembly relies on what every `MessageTarget` already
404
+ promises: an ordered, reliable pipe. Each port's frames leave from its own queue in order, and
405
+ other ports' frames interleave but are told apart by port id.
406
+
382
407
  `openPort(meta?)` allocates a port, announces it and returns the local end without waiting for the
383
408
  peer to accept. That keeps layer 1 free of round trips: messages posted before a rejection are
384
409
  dropped at the far end and the port goes inert. It is asynchronous because a natively multiplexed
@@ -589,6 +614,12 @@ caller without one does not.
589
614
  An abort unwinds a producing handler through `iter.return()`, not by throwing into it. A handler's
590
615
  `catch` never sees the abort reason; only `finally` runs. Put cleanup in `finally`.
591
616
 
617
+ `serve` forwards that `return()` to the handler's output at once, even while the handler is still
618
+ working on its first chunk, with or without `maxMessageSize` (whose chunker would queue it too). Its own tracking wrapper would otherwise queue it behind that pending
619
+ `next()`, as any async generator does. A handler that wraps its output to watch for `return()`
620
+ therefore sees the abandonment when it happens, not when it next yields:
621
+ `@statewalker/webrun-http-streams` aborts the served `Request.signal` that way.
622
+
592
623
  ### `transferPortMux` moves real ports
593
624
 
594
625
  Each `openPort` creates a `MessageChannel`, posts `{ type: PORT_TRANSFER, meta }` with one end in
@@ -86,6 +86,8 @@ export interface OverPipeOptions {
86
86
  window?: number;
87
87
  /** Fair framing in bytes — see `PortMuxOptions.frameSize`. */
88
88
  frameSize?: number;
89
+ /** Ceiling on a message reassembled from frames — see `PortMuxOptions.maxReassembledBytes`. */
90
+ maxReassembledBytes?: number;
89
91
  /** Liveness of the peer by ping/pong — see `PortMuxOptions.keepalive`. */
90
92
  keepalive?: {
91
93
  intervalMs: number;
@@ -1 +1 @@
1
- {"version":3,"file":"connect-serve.d.ts","sourceRoot":"","sources":["../src/connect-serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAM1E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAW1E,uEAAuE;AACvE,MAAM,MAAM,MAAM,GAAG,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,OAAO,GAAG,SAAS,CAAC;AAElF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAE7E,MAAM,WAAW,UAAU;IACzB;;;;;;;;;;OAUG;IACH,GAAG,EAAE,cAAc,CAAC;IACpB;;;yEAGqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,UAAU,CAyCvC,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,EAAE,KAAK,CAAC,UAAU,CAqDnC,CAAC;AAEF,MAAM,WAAW,eAAe;IAC9B,mFAAmF;IACnF,KAAK,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,WAAW,GAAG,WAAW,CAAC;IACjC,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gFAAgF;IAChF,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0EAA0E;IAC1E,SAAS,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACpD,6FAA6F;IAC7F,MAAM,CAAC,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAC;CACnC;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,eAAe,GAAG,cAAc,CAEtF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,cAAc,GAAG,cAAc,CAEjE"}
1
+ {"version":3,"file":"connect-serve.d.ts","sourceRoot":"","sources":["../src/connect-serve.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAU,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAM1E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAW1E,uEAAuE;AACvE,MAAM,MAAM,MAAM,GAAG,CAAC,IAAI,EAAE,aAAa,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,OAAO,GAAG,SAAS,CAAC;AAElF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAE7E,MAAM,WAAW,UAAU;IACzB;;;;;;;;;;OAUG;IACH,GAAG,EAAE,cAAc,CAAC;IACpB;;;yEAGqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,OAAO,EAAE,OAAO,CAAC,UAAU,CAyCvC,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,EAAE,KAAK,CAAC,UAAU,CA8DnC,CAAC;AAEF,MAAM,WAAW,eAAe;IAC9B,mFAAmF;IACnF,KAAK,EAAE,SAAS,CAAC;IACjB;;;;OAIG;IACH,IAAI,CAAC,EAAE,WAAW,GAAG,WAAW,CAAC;IACjC,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gFAAgF;IAChF,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+FAA+F;IAC/F,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,0EAA0E;IAC1E,SAAS,CAAC,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACpD,6FAA6F;IAC7F,MAAM,CAAC,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAC;CACnC;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,eAAe,GAAG,cAAc,CAEtF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,cAAc,GAAG,cAAc,CAEjE"}
package/dist/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export * from "./io-send.js";
10
10
  export * from "./listen-bidi.js";
11
11
  export * from "./listen-port.js";
12
12
  export * from "./message-target.js";
13
- export { DEFAULT_MAX_PORTS, MUX_CLOSED_MESSAGE, multiplexPort } from "./multiplex-port.js";
13
+ export { DEFAULT_MAX_PORTS, DEFAULT_MAX_REASSEMBLED_BYTES, MUX_CLOSED_MESSAGE, multiplexPort, } from "./multiplex-port.js";
14
14
  export { type PortSendState, portBufferedAmount, whenPortReady } from "./port-credit.js";
15
15
  export type { OpenPortOptions, PortCodec, PortEnvelope, PortInfo, PortMux, PortMuxOptions, } from "./port-types.js";
16
16
  export * from "./recieve.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAC/D,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,EACL,OAAO,EACP,KAAK,MAAM,EACX,KAAK,eAAe,EACpB,QAAQ,EACR,SAAS,EACT,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,GACN,MAAM,oBAAoB,CAAC;AAC5B,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAC3F,OAAO,EAAE,KAAK,aAAa,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACzF,YAAY,EACV,eAAe,EACf,SAAS,EACT,YAAY,EACZ,QAAQ,EACR,OAAO,EACP,cAAc,GACf,MAAM,iBAAiB,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,cAAc,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAC/D,cAAc,gBAAgB,CAAC;AAC/B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,qBAAqB,CAAC;AACpC,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC3E,OAAO,EACL,OAAO,EACP,KAAK,MAAM,EACX,KAAK,eAAe,EACpB,QAAQ,EACR,SAAS,EACT,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,GACN,MAAM,oBAAoB,CAAC;AAC5B,cAAc,uBAAuB,CAAC;AACtC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,qBAAqB,CAAC;AACpC,OAAO,EACL,iBAAiB,EACjB,6BAA6B,EAC7B,kBAAkB,EAClB,aAAa,GACd,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,KAAK,aAAa,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACzF,YAAY,EACV,eAAe,EACf,SAAS,EACT,YAAY,EACZ,QAAQ,EACR,OAAO,EACP,cAAc,GACf,MAAM,iBAAiB,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,cAAc,wBAAwB,CAAC"}
package/dist/index.js CHANGED
@@ -825,7 +825,11 @@ function receiveChunks(port, channelName, controller, touch) {
825
825
  * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
826
826
  */
827
827
  async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
828
- const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
828
+ const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
829
+ const stream = throughAbort(framed, signal);
830
+ const producer = output;
831
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
832
+ if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
829
833
  try {
830
834
  await sendIterator(async ({ done, value, error }) => {
831
835
  if (signal.aborted) return;
@@ -849,6 +853,8 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
849
853
  } catch (err) {
850
854
  if (signal.aborted) return;
851
855
  throw err;
856
+ } finally {
857
+ signal.removeEventListener("abort", returnProducer);
852
858
  }
853
859
  }
854
860
  //#endregion
@@ -984,6 +990,8 @@ const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
984
990
  const MAX_TIMER_MS = 2 ** 31 - 1;
985
991
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
986
992
  const DEFAULT_MAX_PORTS = 1024;
993
+ /** Default ceiling on one message reassembled from frames (`maxReassembledBytes`). */
994
+ const DEFAULT_MAX_REASSEMBLED_BYTES = 67108864;
987
995
  /**
988
996
  * What a message costs a port's window when the codec cannot say
989
997
  * (`PortCodec.encode`): its bytes and string lengths, found in its values up
@@ -1013,10 +1021,18 @@ function estimateMessageSize(payload) {
1013
1021
  */
1014
1022
  function multiplexPort(port, options) {
1015
1023
  const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
1024
+ const maxReassembledBytes = options.maxReassembledBytes ?? 67108864;
1016
1025
  const misses = keepalive?.misses ?? 3;
1017
1026
  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`);
1018
1027
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
1028
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) throw new RangeError(`webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`);
1019
1029
  const open = /* @__PURE__ */ new Map();
1030
+ let reassembling = 0;
1031
+ const freePartial = (entry) => {
1032
+ if (!entry?.partial) return;
1033
+ reassembling -= entry.partial.charged;
1034
+ entry.partial = void 0;
1035
+ };
1020
1036
  const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
1021
1037
  const entry = open.get(id);
1022
1038
  if (entry?.handle !== record) return;
@@ -1164,12 +1180,12 @@ function multiplexPort(port, options) {
1164
1180
  ...envelope,
1165
1181
  payload: structuredClone(payload, { transfer })
1166
1182
  } : void 0;
1167
- const send = () => {
1183
+ const send = (whole = false) => {
1168
1184
  if (!bytes) {
1169
1185
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
1170
1186
  return;
1171
1187
  }
1172
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
1188
+ if (!whole && frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
1173
1189
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
1174
1190
  const data = bytes.subarray(at, at + frameSize);
1175
1191
  const frame = at + frameSize >= bytes.byteLength ? {
@@ -1199,10 +1215,11 @@ function multiplexPort(port, options) {
1199
1215
  record.markClosed(reason);
1200
1216
  });
1201
1217
  function release(reason) {
1218
+ freePartial(open.get(id));
1202
1219
  open.delete(id);
1203
1220
  leaks?.unregister(record);
1204
1221
  if (c?.queue.length) {
1205
- for (const item of c.queue.splice(0)) item.send();
1222
+ for (const item of c.queue.splice(0)) item.send(true);
1206
1223
  c.queued = 0;
1207
1224
  }
1208
1225
  sendOnPort(id, () => post({
@@ -1341,15 +1358,38 @@ function multiplexPort(port, options) {
1341
1358
  if (envelope.type === "frame") {
1342
1359
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
1343
1360
  touch(envelope.id);
1361
+ const c = entry.credit;
1362
+ const owed = c.unconsumed + c.toGrant;
1344
1363
  const partial = entry.partial ?? {
1345
1364
  chunks: [],
1346
- bytes: 0
1365
+ bytes: 0,
1366
+ owed,
1367
+ charged: 0,
1368
+ ...owed > 0 ? { room: c.window - owed } : {}
1347
1369
  };
1348
1370
  entry.partial = partial;
1371
+ const size = envelope.data.byteLength;
1372
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
1373
+ 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;
1374
+ if (overrun) {
1375
+ freePartial(entry);
1376
+ dropOutgoing(envelope.id);
1377
+ open.delete(envelope.id);
1378
+ releaseWaiters(c);
1379
+ entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
1380
+ post({
1381
+ type: "close",
1382
+ id: envelope.id,
1383
+ reason: "max-reassembled-bytes"
1384
+ });
1385
+ return;
1386
+ }
1387
+ partial.bytes += size;
1388
+ reassembling += charged - partial.charged;
1389
+ partial.charged = charged;
1349
1390
  partial.chunks.push(envelope.data);
1350
- partial.bytes += envelope.data.byteLength;
1351
1391
  if (!envelope.last) return;
1352
- entry.partial = void 0;
1392
+ freePartial(entry);
1353
1393
  const whole = new Uint8Array(partial.bytes);
1354
1394
  let at = 0;
1355
1395
  for (const chunk of partial.chunks) {
@@ -1363,6 +1403,7 @@ function multiplexPort(port, options) {
1363
1403
  }
1364
1404
  dropOutgoing(envelope.id);
1365
1405
  if (!entry) return;
1406
+ freePartial(entry);
1366
1407
  open.delete(envelope.id);
1367
1408
  releaseWaiters(entry.credit);
1368
1409
  entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
@@ -1555,14 +1596,17 @@ const serve = async ({ mux: factory, timeout }, handler) => {
1555
1596
  const mux = await factory((port) => {
1556
1597
  let off;
1557
1598
  let finished = false;
1558
- const tracked = (input) => (async function* () {
1559
- try {
1560
- yield* handler(input);
1561
- } finally {
1562
- finished = true;
1563
- if (off) live.delete(off);
1564
- }
1565
- })();
1599
+ const tracked = (input) => {
1600
+ const output = handler(input);
1601
+ return returningAtOnce((async function* () {
1602
+ try {
1603
+ yield* output;
1604
+ } finally {
1605
+ finished = true;
1606
+ if (off) live.delete(off);
1607
+ }
1608
+ })(), () => void output.return(void 0).catch(() => {}));
1609
+ };
1566
1610
  off = serveDuplexOverPort(port, tracked, streamOptions);
1567
1611
  if (!finished) live.add(off);
1568
1612
  });
@@ -1780,4 +1824,4 @@ function transferPortMux(target, options = {}) {
1780
1824
  };
1781
1825
  }
1782
1826
  //#endregion
1783
- export { CANCEL_CHANNEL_TYPE, DEFAULT_MAX_PORTS, MUX_CLOSED_MESSAGE, NO_TIMEOUT, PORT_TRANSFER, STREAM_ABORT, byteChannelFromMessagePort, callBidi, callPort, connect, duplexOverPort, getPortCloseSignal, ioHandle, ioSend, listenBidi, listenCancelChannel, listenPort, multiplexPort, overPipe, overPorts, portBufferedAmount, postCancelChannel, recieve, send, serve, serveDuplexOverPort, setPortCloseSignal, structuredCodec, transferPortMux, whenPortReady };
1827
+ export { CANCEL_CHANNEL_TYPE, DEFAULT_MAX_PORTS, DEFAULT_MAX_REASSEMBLED_BYTES, MUX_CLOSED_MESSAGE, NO_TIMEOUT, PORT_TRANSFER, STREAM_ABORT, byteChannelFromMessagePort, callBidi, callPort, connect, duplexOverPort, getPortCloseSignal, ioHandle, ioSend, listenBidi, listenCancelChannel, listenPort, multiplexPort, overPipe, overPorts, portBufferedAmount, postCancelChannel, recieve, send, serve, serveDuplexOverPort, setPortCloseSignal, structuredCodec, transferPortMux, whenPortReady };
@@ -7,6 +7,8 @@ import type { PortInfo, PortMux, PortMuxOptions } from "./port-types.js";
7
7
  export declare const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
8
8
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
9
9
  export declare const DEFAULT_MAX_PORTS = 1024;
10
+ /** Default ceiling on one message reassembled from frames (`maxReassembledBytes`). */
11
+ export declare const DEFAULT_MAX_REASSEMBLED_BYTES: number;
10
12
  /**
11
13
  * What a message costs a port's window when the codec cannot say
12
14
  * (`PortCodec.encode`): its bytes and string lengths, found in its values up
@@ -1 +1 @@
1
- {"version":3,"file":"multiplex-port.d.ts","sourceRoot":"","sources":["../src/multiplex-port.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAGV,QAAQ,EACR,OAAO,EACP,cAAc,EACf,MAAM,iBAAiB,CAAC;AAGzB;;;GAGG;AACH,eAAO,MAAM,kBAAkB,2CAA2C,CAAC;AAY3E,4EAA4E;AAC5E,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAc5D;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,aAAa,EACnB,OAAO,EAAE,cAAc,GACtB,OAAO,GAAG;IAAE,KAAK,IAAI,QAAQ,EAAE,CAAA;CAAE,CAqnBnC"}
1
+ {"version":3,"file":"multiplex-port.d.ts","sourceRoot":"","sources":["../src/multiplex-port.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,KAAK,EAGV,QAAQ,EACR,OAAO,EACP,cAAc,EACf,MAAM,iBAAiB,CAAC;AAGzB;;;GAGG;AACH,eAAO,MAAM,kBAAkB,2CAA2C,CAAC;AAY3E,4EAA4E;AAC5E,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC,sFAAsF;AACtF,eAAO,MAAM,6BAA6B,QAAmB,CAAC;AAE9D;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAc5D;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,aAAa,EACnB,OAAO,EAAE,cAAc,GACtB,OAAO,GAAG;IAAE,KAAK,IAAI,QAAQ,EAAE,CAAA;CAAE,CA0rBnC"}
@@ -147,6 +147,19 @@ export interface PortMuxOptions {
147
147
  * whole. 16-64 KiB is a reasonable range.
148
148
  */
149
149
  frameSize?: number;
150
+ /**
151
+ * Ceiling, in bytes, on what this side holds of a peer's framed messages
152
+ * past their ports' windows, summed over all ports (default 64 MiB).
153
+ * A partial message counts against its port's window as it arrives: one
154
+ * begun while this side still owed credit must fit what the window left,
155
+ * and one begun with nothing owed may pass the window, its excess charged
156
+ * to this cap. A frame past either limit makes the mux drop that port's
157
+ * partial message, close the port with a `RangeError` and send the peer a
158
+ * close with reason `"max-reassembled-bytes"`. A framed message more than
159
+ * this cap past the window never arrives, though the same message sent
160
+ * whole would.
161
+ */
162
+ maxReassembledBytes?: number;
150
163
  /**
151
164
  * Liveness of the peer (off when unset). Every `intervalMs` the mux pings
152
165
  * and the peer answers; anything from it counts as an answer. After `misses`
@@ -1 +1 @@
1
- {"version":3,"file":"port-types.d.ts","sourceRoot":"","sources":["../src/port-types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"port-types.d.ts","sourceRoot":"","sources":["../src/port-types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-rpc",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Ports and RPC over them: a port multiplexer, and typed request/response and streaming primitives over any MessageTarget.",
@@ -139,15 +139,24 @@ export const serve: Serve<PortParams> = async ({ mux: factory, timeout }, handle
139
139
  // reaches it as `return()` through `throughAbort`, so the `finally` runs
140
140
  // on every ending — normal exhaustion, handler throw, peer abort, local
141
141
  // teardown. `serveDuplexOverPort` reports no completion of its own.
142
- const tracked: Duplex = (input) =>
143
- (async function* () {
144
- try {
145
- yield* handler(input);
146
- } finally {
147
- finished = true;
148
- if (off) live.delete(off);
149
- }
150
- })();
142
+ const tracked: Duplex = (input) => {
143
+ const output = handler(input);
144
+ // `return()` on this wrapper is queued behind its pending `next()`, as on
145
+ // any async generator, so the abandonment would reach the handler only
146
+ // once it answers. Forwarded at once, a handler still waiting (an HTTP
147
+ // long poll) learns now that its caller left.
148
+ return returningAtOnce(
149
+ (async function* () {
150
+ try {
151
+ yield* output;
152
+ } finally {
153
+ finished = true;
154
+ if (off) live.delete(off);
155
+ }
156
+ })(),
157
+ () => void output.return(undefined).catch(() => {}),
158
+ );
159
+ };
151
160
  off = serveDuplexOverPort(port, tracked, streamOptions);
152
161
  // `serveDuplexOverPort` invokes the handler synchronously. The generator
153
162
  // body above is lazy, so today it cannot have finished by the time `off`
@@ -201,6 +210,8 @@ export interface OverPipeOptions {
201
210
  window?: number;
202
211
  /** Fair framing in bytes — see `PortMuxOptions.frameSize`. */
203
212
  frameSize?: number;
213
+ /** Ceiling on a message reassembled from frames — see `PortMuxOptions.maxReassembledBytes`. */
214
+ maxReassembledBytes?: number;
204
215
  /** Liveness of the peer by ping/pong — see `PortMuxOptions.keepalive`. */
205
216
  keepalive?: { intervalMs: number; misses?: number };
206
217
  /** Development aid: report ports dropped without `close()` — see `PortMuxOptions.onLeak`. */
@@ -481,6 +481,12 @@ async function sendChunks(
481
481
  ): Promise<void> {
482
482
  const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
483
483
  const stream = throughAbort(framed, signal);
484
+ // `throughAbort` returns `framed`; when chunking wraps the output, that
485
+ // `return()` is queued behind the chunker's pending `next()`, so the abort
486
+ // would reach the producer only once it yields. Return the producer itself.
487
+ const producer = output as Partial<AsyncIterator<Uint8Array>>;
488
+ const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
489
+ if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
484
490
  try {
485
491
  await sendIterator<Uint8Array>(async ({ done, value, error }) => {
486
492
  if (signal.aborted) return;
@@ -502,5 +508,7 @@ async function sendChunks(
502
508
  // anything else is a genuine transport failure worth surfacing.
503
509
  if (signal.aborted) return;
504
510
  throw err;
511
+ } finally {
512
+ signal.removeEventListener("abort", returnProducer);
505
513
  }
506
514
  }
package/src/index.ts CHANGED
@@ -21,7 +21,12 @@ export * from "./io-send.js";
21
21
  export * from "./listen-bidi.js";
22
22
  export * from "./listen-port.js";
23
23
  export * from "./message-target.js";
24
- export { DEFAULT_MAX_PORTS, MUX_CLOSED_MESSAGE, multiplexPort } from "./multiplex-port.js";
24
+ export {
25
+ DEFAULT_MAX_PORTS,
26
+ DEFAULT_MAX_REASSEMBLED_BYTES,
27
+ MUX_CLOSED_MESSAGE,
28
+ multiplexPort,
29
+ } from "./multiplex-port.js";
25
30
  export { type PortSendState, portBufferedAmount, whenPortReady } from "./port-credit.js";
26
31
  export type {
27
32
  OpenPortOptions,
@@ -29,6 +29,9 @@ const MAX_TIMER_MS = 2 ** 31 - 1;
29
29
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
30
30
  export const DEFAULT_MAX_PORTS = 1024;
31
31
 
32
+ /** Default ceiling on one message reassembled from frames (`maxReassembledBytes`). */
33
+ export const DEFAULT_MAX_REASSEMBLED_BYTES = 64 * 1024 * 1024;
34
+
32
35
  /**
33
36
  * What a message costs a port's window when the codec cannot say
34
37
  * (`PortCodec.encode`): its bytes and string lengths, found in its values up
@@ -73,6 +76,7 @@ export function multiplexPort(
73
76
  keepalive,
74
77
  onLeak,
75
78
  } = options;
79
+ const maxReassembledBytes = options.maxReassembledBytes ?? DEFAULT_MAX_REASSEMBLED_BYTES;
76
80
  const misses = keepalive?.misses ?? 3;
77
81
  if (
78
82
  keepalive &&
@@ -92,6 +96,11 @@ export function multiplexPort(
92
96
  if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) {
93
97
  throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
94
98
  }
99
+ if (!(Number.isSafeInteger(maxReassembledBytes) && maxReassembledBytes > 0)) {
100
+ throw new RangeError(
101
+ `webrun-rpc: maxReassembledBytes must be a positive integer, got ${maxReassembledBytes}`,
102
+ );
103
+ }
95
104
 
96
105
  /**
97
106
  * Flow control of one port (`PortMuxOptions.window`): the send side's queue
@@ -117,7 +126,7 @@ export function multiplexPort(
117
126
  */
118
127
  exact: boolean;
119
128
  outstanding: number;
120
- queue: { send: () => void; size: number }[];
129
+ queue: { send: (whole?: boolean) => void; size: number }[];
121
130
  queued: number;
122
131
  unconsumed: number;
123
132
  waiters: { below: number; resolve: () => void }[];
@@ -138,10 +147,28 @@ export function multiplexPort(
138
147
  interface Entry extends PortInfo {
139
148
  handle: PortRecord;
140
149
  credit?: Credit;
141
- /** The frames of a message still arriving. */
142
- partial?: { chunks: Uint8Array[]; bytes: number };
150
+ /**
151
+ * The frames of a message still arriving. `room`: what the window left
152
+ * when the first came, if anything was still owed back then. `charged`:
153
+ * its bytes past the window, counted in `reassembling`.
154
+ */
155
+ partial?: {
156
+ chunks: Uint8Array[];
157
+ bytes: number;
158
+ room?: number;
159
+ owed: number;
160
+ charged: number;
161
+ };
143
162
  }
144
163
  const open = new Map<number, Entry>();
164
+ // Bytes of partial messages held past their port's window, across every
165
+ // port (`maxReassembledBytes`). What fits a window is bounded by it already.
166
+ let reassembling = 0;
167
+ const freePartial = (entry: Entry | undefined): void => {
168
+ if (!entry?.partial) return;
169
+ reassembling -= entry.partial.charged;
170
+ entry.partial = undefined;
171
+ };
145
172
  // A port collected while still open: report it, then free its slot on both
146
173
  // ends as its close would. Only with `onLeak` — a registry costs per port.
147
174
  const leaks = onLeak
@@ -346,14 +373,22 @@ export function multiplexPort(
346
373
  !bytes && waits
347
374
  ? { ...envelope, payload: structuredClone(payload, { transfer }) }
348
375
  : undefined;
349
- const send = (): void => {
376
+ // `whole`: at close, what is still queued leaves past the window, and
377
+ // a frame past the window is what the peer refuses (`maxReassembledBytes`).
378
+ const send = (whole = false): void => {
350
379
  if (!bytes) {
351
380
  sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
352
381
  return;
353
382
  }
354
383
  // Frames only once the peer has acknowledged exact sizing: before
355
384
  // that, it may be a peer that cannot reassemble them.
356
- if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
385
+ if (
386
+ !whole &&
387
+ frameSize != null &&
388
+ c.enforced &&
389
+ c.exact &&
390
+ bytes.byteLength > frameSize
391
+ ) {
357
392
  for (let at = 0; at < bytes.byteLength; at += frameSize) {
358
393
  const data = bytes.subarray(at, at + frameSize);
359
394
  const frame: PortEnvelope =
@@ -376,6 +411,7 @@ export function multiplexPort(
376
411
  },
377
412
  );
378
413
  function release(reason: unknown): void {
414
+ freePartial(open.get(id));
379
415
  open.delete(id);
380
416
  leaks?.unregister(record);
381
417
  // What is queued goes out now, before the close and regardless of the
@@ -384,7 +420,7 @@ export function multiplexPort(
384
420
  // kept for a port nobody holds. Without a window it would all have
385
421
  // gone out at once anyway.
386
422
  if (c?.queue.length) {
387
- for (const item of c.queue.splice(0)) item.send();
423
+ for (const item of c.queue.splice(0)) item.send(true);
388
424
  c.queued = 0;
389
425
  }
390
426
  // Behind this port's frames, so the peer gets the whole message first.
@@ -520,16 +556,51 @@ export function multiplexPort(
520
556
 
521
557
  if (envelope.type === "frame") {
522
558
  // Only where this end advertised exact sizing — the one place a peer may
523
- // frame. A frame sequence buffers no more than the same message sent
524
- // whole, which a byte transport would also have to hold entire.
559
+ // frame. What a partial message may hold is bounded below, so frames
560
+ // that never end cannot make this side buffer without limit.
525
561
  if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
526
562
  touch(envelope.id);
527
- const partial = entry.partial ?? { chunks: [], bytes: 0 };
563
+ const c = entry.credit;
564
+ // What this side still owes back (delivered and unconsumed, or consumed
565
+ // and not yet granted) is never more than what the sender counted as
566
+ // outstanding. So an honest sender starts a message only when owed plus
567
+ // the message fits the window, or when nothing is owed at all: a message
568
+ // larger than the window, bounded by `maxReassembledBytes` alone.
569
+ const owed = c.unconsumed + c.toGrant;
570
+ const partial = entry.partial ?? {
571
+ chunks: [],
572
+ bytes: 0,
573
+ owed,
574
+ charged: 0,
575
+ ...(owed > 0 ? { room: c.window - owed } : {}),
576
+ };
528
577
  entry.partial = partial;
578
+ const size = envelope.data.byteLength;
579
+ // Only a message begun with nothing owed may pass the window; what it
580
+ // holds past it is what the cap, shared by all ports, bounds.
581
+ const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
582
+ const overrun =
583
+ partial.room != null && partial.bytes + size > partial.room
584
+ ? `what the window left (${partial.room} of ${c.window} bytes, ${partial.owed} still owed)`
585
+ : reassembling - partial.charged + charged > maxReassembledBytes
586
+ ? `maxReassembledBytes (${maxReassembledBytes} bytes past the window, across all ports)`
587
+ : undefined;
588
+ if (overrun) {
589
+ // A peer that frames past what it may send: hold none of it.
590
+ freePartial(entry);
591
+ dropOutgoing(envelope.id);
592
+ open.delete(envelope.id);
593
+ releaseWaiters(c);
594
+ entry.handle.markClosed(new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
595
+ post({ type: "close", id: envelope.id, reason: "max-reassembled-bytes" });
596
+ return;
597
+ }
598
+ partial.bytes += size;
599
+ reassembling += charged - partial.charged;
600
+ partial.charged = charged;
529
601
  partial.chunks.push(envelope.data);
530
- partial.bytes += envelope.data.byteLength;
531
602
  if (!envelope.last) return;
532
- entry.partial = undefined;
603
+ freePartial(entry);
533
604
  const whole = new Uint8Array(partial.bytes);
534
605
  let at = 0;
535
606
  for (const chunk of partial.chunks) {
@@ -545,6 +616,7 @@ export function multiplexPort(
545
616
  // The peer closed the port: whatever it still had to receive is moot.
546
617
  dropOutgoing(envelope.id);
547
618
  if (!entry) return;
619
+ freePartial(entry);
548
620
  open.delete(envelope.id);
549
621
  releaseWaiters(entry.credit);
550
622
  entry.handle.markClosed(new Error("webrun-rpc: the peer closed the port"));
package/src/port-types.ts CHANGED
@@ -124,6 +124,19 @@ export interface PortMuxOptions {
124
124
  * whole. 16-64 KiB is a reasonable range.
125
125
  */
126
126
  frameSize?: number;
127
+ /**
128
+ * Ceiling, in bytes, on what this side holds of a peer's framed messages
129
+ * past their ports' windows, summed over all ports (default 64 MiB).
130
+ * A partial message counts against its port's window as it arrives: one
131
+ * begun while this side still owed credit must fit what the window left,
132
+ * and one begun with nothing owed may pass the window, its excess charged
133
+ * to this cap. A frame past either limit makes the mux drop that port's
134
+ * partial message, close the port with a `RangeError` and send the peer a
135
+ * close with reason `"max-reassembled-bytes"`. A framed message more than
136
+ * this cap past the window never arrives, though the same message sent
137
+ * whole would.
138
+ */
139
+ maxReassembledBytes?: number;
127
140
  /**
128
141
  * Liveness of the peer (off when unset). Every `intervalMs` the mux pings
129
142
  * and the peer answers; anything from it counts as an answer. After `misses`