@statewalker/webrun-rpc 0.5.0 → 0.7.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
@@ -238,8 +238,11 @@ function listenPort(port, handler, { channelName = "", log = () => {} } = {}) {
238
238
  error
239
239
  });
240
240
  };
241
- port.addEventListener("message", onMessage);
242
- return () => port.removeEventListener("message", onMessage);
241
+ const listener = (event) => {
242
+ onMessage(event);
243
+ };
244
+ port.addEventListener("message", listener);
245
+ return () => port.removeEventListener("message", listener);
243
246
  }
244
247
  //#endregion
245
248
  //#region src/recieve.ts
@@ -468,6 +471,73 @@ async function* callBidi(port, input, { options = {}, ...params } = {}) {
468
471
  if (outerError !== void 0) throw outerError;
469
472
  }
470
473
  //#endregion
474
+ //#region src/async-dispose.ts
475
+ /**
476
+ * Makes `target` disposable with `await using`: installs `dispose` as its
477
+ * `[Symbol.asyncDispose]`, where the runtime defines the symbol. Hand-written
478
+ * iterators lose what a native generator gets from `AsyncIteratorPrototype`;
479
+ * this gives it back. A no-op where the symbol is missing. Kept off the
480
+ * declared types, which would require consumers to compile with the
481
+ * `esnext.disposable` lib.
482
+ */
483
+ function asyncDisposable(target, dispose) {
484
+ const asyncDispose = Symbol.asyncDispose;
485
+ if (asyncDispose && !(asyncDispose in target)) Object.defineProperty(target, asyncDispose, {
486
+ value: async () => {
487
+ await dispose();
488
+ },
489
+ configurable: true,
490
+ writable: true
491
+ });
492
+ return target;
493
+ }
494
+ //#endregion
495
+ //#region src/return-at-once.ts
496
+ /**
497
+ * `gen`, except that `return()` and `throw()` take effect at once.
498
+ *
499
+ * On an async generator both are queued behind a `next()` that is still
500
+ * pending: a consumer that gives up on a stream while it waits for the peer's
501
+ * next chunk — an aborted fetch whose response head has not come, say — cannot
502
+ * end it until that chunk arrives, which for a peer that never answers is
503
+ * never. The stream's `finally` (which closes its port and tells the peer)
504
+ * then never runs, and on a multiplexer the port is kept for good.
505
+ *
506
+ * So `abandon` runs first, synchronously: it must make the pending `next()`
507
+ * settle (abort the stream), which then resolves done, after which the
508
+ * generator's own `return()` / `throw()` proceeds as usual. On a generator
509
+ * suspended at a `yield` it is the same ending, a step earlier. `abandon` runs once, whichever comes first.
510
+ */
511
+ function returningAtOnce(gen, abandon) {
512
+ let abandoned = false;
513
+ const giveUp = (reason) => {
514
+ if (abandoned) return;
515
+ abandoned = true;
516
+ abandon(reason);
517
+ };
518
+ const wrapped = {
519
+ next: (...args) => gen.next(...args).catch((error) => {
520
+ if (abandoned) return {
521
+ done: true,
522
+ value: void 0
523
+ };
524
+ throw error;
525
+ }),
526
+ return(value) {
527
+ giveUp();
528
+ return gen.return(value);
529
+ },
530
+ throw(error) {
531
+ giveUp(error);
532
+ return gen.throw(error);
533
+ },
534
+ [Symbol.asyncIterator]() {
535
+ return wrapped;
536
+ }
537
+ };
538
+ return asyncDisposable(wrapped, () => wrapped.return(void 0));
539
+ }
540
+ //#endregion
471
541
  //#region src/duplex-over-port.ts
472
542
  /**
473
543
  * The `type` of the out-of-band notice a side posts when it abandons a stream.
@@ -565,7 +635,7 @@ function runCallerSide(port, input, options) {
565
635
  const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
566
636
  const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
567
637
  pump.catch(() => {});
568
- return (async function* () {
638
+ return returningAtOnce((async function* () {
569
639
  try {
570
640
  yield* inbound.stream;
571
641
  } finally {
@@ -579,7 +649,9 @@ function runCallerSide(port, input, options) {
579
649
  await port.close?.();
580
650
  } catch {}
581
651
  }
582
- })();
652
+ })(), () => {
653
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
654
+ });
583
655
  }
584
656
  /**
585
657
  * Listens for the peer's abort notice, and can post our own. The notice is a
@@ -780,6 +852,50 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
780
852
  }
781
853
  }
782
854
  //#endregion
855
+ //#region src/port-credit.ts
856
+ /**
857
+ * Runs `fn` on a later task, never a microtask: a producer looping on
858
+ * `whenPortReady` must let the transport's own events run. `setImmediate`
859
+ * where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
860
+ * are clamped to 4 ms in browsers.
861
+ */
862
+ const setImmediateFn = globalThis.setImmediate;
863
+ /** @internal */
864
+ const nextTask = setImmediateFn ? (fn) => {
865
+ setImmediateFn(fn);
866
+ } : (() => {
867
+ let channel;
868
+ const queue = [];
869
+ return (fn) => {
870
+ if (!channel) {
871
+ channel = new MessageChannel();
872
+ channel.port1.onmessage = () => queue.shift()?.();
873
+ }
874
+ queue.push(fn);
875
+ channel.port2.postMessage(0);
876
+ };
877
+ })();
878
+ const states = /* @__PURE__ */ new WeakMap();
879
+ /** @internal Registered by the multiplexer for each port with a window. */
880
+ function setPortSendState(port, state) {
881
+ states.set(port, state);
882
+ }
883
+ /**
884
+ * Bytes queued on `port` because its window is spent (0 for a port without
885
+ * flow control). A sender that should not queue without bound waits for
886
+ * `whenPortReady` before posting more.
887
+ */
888
+ function portBufferedAmount(port) {
889
+ return states.get(port)?.bufferedAmount() ?? 0;
890
+ }
891
+ /**
892
+ * Resolves once at most `below` bytes are queued on `port`, always on a later
893
+ * task (for a port without flow control, on the next one).
894
+ */
895
+ function whenPortReady(port, below = 0) {
896
+ return states.get(port)?.ready(below) ?? new Promise((resolve) => nextTask(resolve));
897
+ }
898
+ //#endregion
783
899
  //#region src/virtual-port.ts
784
900
  /**
785
901
  * One virtual port.
@@ -796,6 +912,14 @@ function newVirtualPort(send, requestClose) {
796
912
  if (closing.signal.aborted) return;
797
913
  closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
798
914
  };
915
+ const close = (reason) => {
916
+ if (closed) return;
917
+ closed = true;
918
+ const notify = requestClose;
919
+ listeners.clear();
920
+ notify(reason);
921
+ abort(reason);
922
+ };
799
923
  const port = {
800
924
  addEventListener(_type, listener) {
801
925
  listeners.add(listener);
@@ -807,24 +931,28 @@ function newVirtualPort(send, requestClose) {
807
931
  if (closed) return;
808
932
  send(message, transfer);
809
933
  },
810
- close() {
811
- if (closed) return;
812
- closed = true;
813
- const notify = requestClose;
814
- listeners.clear();
815
- notify();
816
- abort();
817
- }
934
+ close
818
935
  };
819
936
  setPortCloseSignal(port, closing.signal);
937
+ const dispose = Symbol.dispose;
938
+ if (dispose) Object.defineProperty(port, dispose, {
939
+ value: () => close(),
940
+ configurable: true
941
+ });
820
942
  return {
821
943
  port,
822
944
  deliver(payload) {
823
945
  if (closed) return;
824
946
  const event = new MessageEvent("message", { data: payload });
947
+ let pending;
825
948
  for (const listener of [...listeners]) try {
826
- listener(event);
949
+ const result = listener(event);
950
+ if (result && typeof result.then === "function") {
951
+ pending ??= [];
952
+ pending.push(result);
953
+ }
827
954
  } catch {}
955
+ return pending ? Promise.allSettled(pending) : void 0;
828
956
  },
829
957
  markClosed(reason) {
830
958
  closed = true;
@@ -833,6 +961,9 @@ function newVirtualPort(send, requestClose) {
833
961
  },
834
962
  isClosed() {
835
963
  return closed;
964
+ },
965
+ hasListeners() {
966
+ return listeners.size > 0;
836
967
  }
837
968
  };
838
969
  }
@@ -843,45 +974,303 @@ function newVirtualPort(send, requestClose) {
843
974
  * multiplexer when it is closed (`PortMux.close()`).
844
975
  */
845
976
  const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
977
+ /**
978
+ * The id of keepalive's `ping` / `pong`: one no port ever takes. An older mux
979
+ * whose codec passes unknown envelopes through reads them as a close of that
980
+ * id, which must therefore not be a real port (0 is the initiator's first).
981
+ */
982
+ const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
983
+ /** The longest delay a timer holds; past it `setInterval` fires at once. */
984
+ const MAX_TIMER_MS = 2 ** 31 - 1;
846
985
  /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
847
986
  const DEFAULT_MAX_PORTS = 1024;
848
987
  /**
988
+ * What a message costs a port's window when the codec cannot say
989
+ * (`PortCodec.encode`): its bytes and string lengths, found in its values up
990
+ * to 4 levels deep (at most 256 values), and at least 64. Both ends compute it
991
+ * from the same structured value, so they agree.
992
+ */
993
+ function estimateMessageSize(payload) {
994
+ let budget = 256;
995
+ const walk = (v, depth) => {
996
+ if (budget-- <= 0) return 0;
997
+ if (typeof v === "string") return v.length;
998
+ if (v instanceof ArrayBuffer) return v.byteLength;
999
+ if (ArrayBuffer.isView(v)) return v.byteLength;
1000
+ if (v === null || typeof v !== "object" || depth >= 4) return 8;
1001
+ let n = 16;
1002
+ if (Array.isArray(v)) for (const x of v) n += walk(x, depth + 1);
1003
+ else for (const k in v) n += k.length + walk(v[k], depth + 1);
1004
+ return n;
1005
+ };
1006
+ return Math.max(64, walk(payload, 0));
1007
+ }
1008
+ /**
849
1009
  * The default `PortMux`: emulates multiplexing over a single port.
850
1010
  *
851
1011
  * A transport that already multiplexes natively supplies its own `PortMux`
852
1012
  * instead — this implementation is for transports that offer one pipe.
853
1013
  */
854
1014
  function multiplexPort(port, options) {
855
- const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
1015
+ const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
1016
+ const misses = keepalive?.misses ?? 3;
1017
+ 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
+ if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
856
1019
  const open = /* @__PURE__ */ new Map();
1020
+ const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
1021
+ const entry = open.get(id);
1022
+ if (entry?.handle !== record) return;
1023
+ const { handle: _, credit: __, partial: ___, ...info } = entry;
1024
+ if (!record.hasListeners()) {
1025
+ release("leaked");
1026
+ record.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was dropped without close()"));
1027
+ }
1028
+ try {
1029
+ onLeak?.(info);
1030
+ } catch {}
1031
+ }) : void 0;
1032
+ const touch = (id) => {
1033
+ const entry = open.get(id);
1034
+ if (entry) entry.lastActivity = Date.now();
1035
+ };
857
1036
  let nextId = side === "initiator" ? 0 : 1;
858
1037
  let muxClosed = false;
1038
+ /** This end counts encoded bytes and reassembles frames. */
1039
+ const exactCodec = codec.encode != null && codec.decode != null;
859
1040
  const post = (envelope, transfer) => {
860
1041
  if (muxClosed) return;
1042
+ codecPost(envelope, transfer);
1043
+ };
1044
+ /** Like `post`, also while closing: the mux's own close notices. */
1045
+ const codecPost = (envelope, transfer) => {
861
1046
  try {
862
1047
  codec.post(port, envelope, transfer);
863
1048
  } catch {}
864
1049
  };
865
- const attach = (id) => {
866
- let self;
867
- self = newVirtualPort((payload, transfer) => post({
868
- type: "message",
869
- id,
870
- payload
871
- }, transfer), (reason) => {
1050
+ const outgoing = /* @__PURE__ */ new Map();
1051
+ const ring = [];
1052
+ let pumping = false;
1053
+ const pump = async () => {
1054
+ pumping = true;
1055
+ try {
1056
+ while (ring.length && !muxClosed) {
1057
+ if (port.whenDrained) await port.whenDrained();
1058
+ if (muxClosed) break;
1059
+ const id = ring.shift();
1060
+ const queue = outgoing.get(id);
1061
+ const item = queue?.shift();
1062
+ if (queue?.length) ring.push(id);
1063
+ else outgoing.delete(id);
1064
+ item?.();
1065
+ }
1066
+ } catch (err) {
1067
+ close(err).catch(() => {});
1068
+ } finally {
1069
+ pumping = false;
1070
+ }
1071
+ };
1072
+ const sendOnPort = (id, item) => {
1073
+ if (frameSize == null) {
1074
+ item();
1075
+ return;
1076
+ }
1077
+ let queue = outgoing.get(id);
1078
+ if (!queue) {
1079
+ queue = [];
1080
+ outgoing.set(id, queue);
1081
+ ring.push(id);
1082
+ }
1083
+ queue.push(item);
1084
+ if (!pumping) pump();
1085
+ };
1086
+ const dropOutgoing = (id) => {
1087
+ if (!outgoing.delete(id)) return;
1088
+ const at = ring.indexOf(id);
1089
+ if (at >= 0) ring.splice(at, 1);
1090
+ };
1091
+ const postBytes = (bytes) => {
1092
+ if (muxClosed) return;
1093
+ try {
1094
+ port.postMessage(bytes);
1095
+ } catch {}
1096
+ };
1097
+ const releaseWaiters = (c) => {
1098
+ for (const w of c?.waiters.splice(0) ?? []) w.resolve();
1099
+ };
1100
+ const flush = (c) => {
1101
+ while (c.queue.length) {
1102
+ const next = c.queue[0];
1103
+ if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
1104
+ c.queue.shift();
1105
+ c.queued -= next.size;
1106
+ c.outstanding += next.size;
1107
+ next.send();
1108
+ }
1109
+ for (const w of [...c.waiters]) if (c.queued <= w.below) {
1110
+ c.waiters.splice(c.waiters.indexOf(w), 1);
1111
+ w.resolve();
1112
+ }
1113
+ };
1114
+ const grant = (id, c, bytes) => {
1115
+ c.unconsumed -= bytes;
1116
+ c.toGrant += bytes;
1117
+ const send = () => {
1118
+ c.grantScheduled = false;
1119
+ if (!c.toGrant || !open.has(id)) return;
872
1120
  post({
1121
+ type: "credit",
1122
+ id,
1123
+ bytes: c.toGrant
1124
+ });
1125
+ c.toGrant = 0;
1126
+ };
1127
+ if (c.toGrant >= c.window / 4) {
1128
+ send();
1129
+ return;
1130
+ }
1131
+ if (c.grantScheduled) return;
1132
+ c.grantScheduled = true;
1133
+ setTimeout(send, 0);
1134
+ };
1135
+ const attach = (id, meta, portWindow, opener, exact) => {
1136
+ const c = typeof portWindow === "number" && Number.isSafeInteger(portWindow) && portWindow > 0 ? {
1137
+ window: portWindow,
1138
+ enforced: false,
1139
+ opener,
1140
+ exact,
1141
+ outstanding: 0,
1142
+ queue: [],
1143
+ queued: 0,
1144
+ unconsumed: 0,
1145
+ waiters: [],
1146
+ toGrant: 0,
1147
+ grantScheduled: false
1148
+ } : void 0;
1149
+ const self = newVirtualPort((payload, transfer) => {
1150
+ touch(id);
1151
+ const envelope = {
1152
+ type: "message",
1153
+ id,
1154
+ payload
1155
+ };
1156
+ if (!c) {
1157
+ post(envelope, transfer);
1158
+ return;
1159
+ }
1160
+ const bytes = c.exact ? codec.encode?.(envelope) : void 0;
1161
+ const size = bytes?.byteLength ?? estimateMessageSize(payload);
1162
+ const waits = frameSize != null || c.queue.length > 0 || c.enforced && c.outstanding > 0 && c.outstanding + size > c.window;
1163
+ const held = !bytes && waits ? {
1164
+ ...envelope,
1165
+ payload: structuredClone(payload, { transfer })
1166
+ } : void 0;
1167
+ const send = () => {
1168
+ if (!bytes) {
1169
+ sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
1170
+ return;
1171
+ }
1172
+ if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
1173
+ for (let at = 0; at < bytes.byteLength; at += frameSize) {
1174
+ const data = bytes.subarray(at, at + frameSize);
1175
+ const frame = at + frameSize >= bytes.byteLength ? {
1176
+ type: "frame",
1177
+ id,
1178
+ data,
1179
+ last: true
1180
+ } : {
1181
+ type: "frame",
1182
+ id,
1183
+ data
1184
+ };
1185
+ sendOnPort(id, () => post(frame));
1186
+ }
1187
+ return;
1188
+ }
1189
+ sendOnPort(id, () => postBytes(bytes));
1190
+ };
1191
+ c.queue.push({
1192
+ send,
1193
+ size
1194
+ });
1195
+ c.queued += size;
1196
+ flush(c);
1197
+ }, (reason) => {
1198
+ release(reason);
1199
+ record.markClosed(reason);
1200
+ });
1201
+ function release(reason) {
1202
+ open.delete(id);
1203
+ leaks?.unregister(record);
1204
+ if (c?.queue.length) {
1205
+ for (const item of c.queue.splice(0)) item.send();
1206
+ c.queued = 0;
1207
+ }
1208
+ sendOnPort(id, () => post({
873
1209
  type: "close",
874
1210
  id,
875
1211
  reason
1212
+ }));
1213
+ releaseWaiters(c);
1214
+ }
1215
+ const { markClosed, isClosed, hasListeners } = self;
1216
+ if (c) {
1217
+ const deliver = self.deliver;
1218
+ self.deliver = (payload, size = estimateMessageSize(payload)) => {
1219
+ c.unconsumed += size;
1220
+ const done = deliver(payload);
1221
+ if (done) done.then(() => grant(id, c, size));
1222
+ else grant(id, c, size);
1223
+ return done;
1224
+ };
1225
+ setPortSendState(self.port, {
1226
+ bufferedAmount: () => c.queued,
1227
+ ready: (below = 0) => new Promise((resolve) => {
1228
+ if (c.queued <= below || isClosed()) nextTask(resolve);
1229
+ else c.waiters.push({
1230
+ below,
1231
+ resolve: () => nextTask(resolve)
1232
+ });
1233
+ })
876
1234
  });
877
- open.delete(id);
878
- self.markClosed();
1235
+ }
1236
+ const record = {
1237
+ deliver: self.deliver,
1238
+ markClosed,
1239
+ isClosed,
1240
+ hasListeners,
1241
+ close: (reason) => {
1242
+ if (isClosed()) return;
1243
+ release(reason);
1244
+ markClosed(reason);
1245
+ }
1246
+ };
1247
+ leaks?.register(self.port, {
1248
+ id,
1249
+ record,
1250
+ release
1251
+ }, record);
1252
+ const now = Date.now();
1253
+ open.set(id, {
1254
+ id,
1255
+ meta,
1256
+ openedAt: now,
1257
+ lastActivity: now,
1258
+ handle: record,
1259
+ credit: c
879
1260
  });
880
- open.set(id, self);
881
1261
  return self;
882
1262
  };
883
- const handleEnvelope = (envelope) => {
1263
+ const handleEnvelope = (envelope, event) => {
884
1264
  if (muxClosed) return;
1265
+ heardSincePing = true;
1266
+ if (envelope.type === "ping" || envelope.type === "pong") {
1267
+ peerKeepsAlive = true;
1268
+ if (envelope.type === "ping") post({
1269
+ type: "pong",
1270
+ id: KEEPALIVE_ID
1271
+ });
1272
+ return;
1273
+ }
885
1274
  if (envelope.type === "open") {
886
1275
  if (open.has(envelope.id)) return;
887
1276
  if (open.size >= maxPorts) {
@@ -892,7 +1281,18 @@ function multiplexPort(port, options) {
892
1281
  });
893
1282
  return;
894
1283
  }
895
- const handle = attach(envelope.id);
1284
+ const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
1285
+ const credit = open.get(envelope.id)?.credit;
1286
+ if (credit) post(credit.exact ? {
1287
+ type: "credit",
1288
+ id: envelope.id,
1289
+ bytes: 0,
1290
+ exact: true
1291
+ } : {
1292
+ type: "credit",
1293
+ id: envelope.id,
1294
+ bytes: 0
1295
+ });
896
1296
  let accepted = false;
897
1297
  if (onPort) try {
898
1298
  accepted = onPort(handle.port, envelope.meta) !== false;
@@ -907,57 +1307,191 @@ function multiplexPort(port, options) {
907
1307
  id: envelope.id,
908
1308
  reason: "rejected"
909
1309
  });
1310
+ return;
910
1311
  }
911
1312
  return;
912
1313
  }
913
- const handle = open.get(envelope.id);
1314
+ const entry = open.get(envelope.id);
1315
+ if (envelope.type === "credit") {
1316
+ const c = entry?.credit;
1317
+ if (!c || !(envelope.bytes >= 0)) return;
1318
+ if (!c.enforced && c.opener) {
1319
+ if (c.exact && envelope.exact !== true) {
1320
+ c.exact = false;
1321
+ c.outstanding = 0;
1322
+ }
1323
+ post({
1324
+ type: "credit",
1325
+ id: envelope.id,
1326
+ bytes: 0
1327
+ });
1328
+ }
1329
+ c.enforced = true;
1330
+ c.outstanding = Math.max(0, c.outstanding - envelope.bytes);
1331
+ flush(c);
1332
+ return;
1333
+ }
914
1334
  if (envelope.type === "message") {
915
- if (!handle) return;
916
- handle.deliver(envelope.payload);
1335
+ const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
1336
+ if (!entry) return;
1337
+ touch(envelope.id);
1338
+ entry.handle.deliver(envelope.payload, entry.credit ? sizeOf(entry.credit) : void 0);
917
1339
  return;
918
1340
  }
919
- if (!handle) return;
1341
+ if (envelope.type === "frame") {
1342
+ if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
1343
+ touch(envelope.id);
1344
+ const partial = entry.partial ?? {
1345
+ chunks: [],
1346
+ bytes: 0
1347
+ };
1348
+ entry.partial = partial;
1349
+ partial.chunks.push(envelope.data);
1350
+ partial.bytes += envelope.data.byteLength;
1351
+ if (!envelope.last) return;
1352
+ entry.partial = void 0;
1353
+ const whole = new Uint8Array(partial.bytes);
1354
+ let at = 0;
1355
+ for (const chunk of partial.chunks) {
1356
+ whole.set(chunk, at);
1357
+ at += chunk.byteLength;
1358
+ }
1359
+ const inner = codec.decode(whole);
1360
+ if (inner?.type !== "message" || inner.id !== envelope.id) return;
1361
+ entry.handle.deliver(inner.payload, whole.byteLength);
1362
+ return;
1363
+ }
1364
+ dropOutgoing(envelope.id);
1365
+ if (!entry) return;
920
1366
  open.delete(envelope.id);
921
- handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
1367
+ releaseWaiters(entry.credit);
1368
+ entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
922
1369
  };
923
1370
  const listener = (event) => {
924
1371
  const envelope = codec.read(event);
925
- if (envelope) handleEnvelope(envelope);
1372
+ if (envelope) handleEnvelope(envelope, event);
1373
+ };
1374
+ let peerKeepsAlive = false;
1375
+ let heardSincePing = true;
1376
+ let unanswered = 0;
1377
+ let pinger;
1378
+ const tick = () => {
1379
+ if (!keepalive) return;
1380
+ const backlogged = (port.bufferedAmount ?? 0) > 0;
1381
+ if (heardSincePing) unanswered = 0;
1382
+ else if (peerKeepsAlive && !backlogged) unanswered++;
1383
+ if (unanswered >= misses) {
1384
+ close(/* @__PURE__ */ new Error(`webrun-rpc: the peer did not answer ${misses} keepalive pings, ${keepalive.intervalMs} ms apart`)).catch(() => {});
1385
+ return;
1386
+ }
1387
+ heardSincePing = false;
1388
+ post({
1389
+ type: "ping",
1390
+ id: KEEPALIVE_ID
1391
+ });
1392
+ };
1393
+ const close = async (reason) => {
1394
+ if (muxClosed) return;
1395
+ clearInterval(pinger);
1396
+ muxClosed = true;
1397
+ signal?.removeEventListener("abort", onTransportAbort);
1398
+ port.removeEventListener("message", listener);
1399
+ const closedNow = /* @__PURE__ */ new Set();
1400
+ for (const [id, { handle, credit }] of [...open]) {
1401
+ closedNow.add(id);
1402
+ codecPost({
1403
+ type: "close",
1404
+ id
1405
+ });
1406
+ open.delete(id);
1407
+ releaseWaiters(credit);
1408
+ handle.markClosed(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the multiplexer was closed"));
1409
+ }
1410
+ for (const id of outgoing.keys()) if (!closedNow.has(id)) codecPost({
1411
+ type: "close",
1412
+ id
1413
+ });
1414
+ outgoing.clear();
1415
+ ring.length = 0;
1416
+ await port.close?.();
1417
+ };
1418
+ const onTransportAbort = () => {
1419
+ close(signal?.reason).catch(() => {});
926
1420
  };
927
1421
  port.addEventListener("message", listener);
928
1422
  port.start?.();
1423
+ if (keepalive) {
1424
+ pinger = setInterval(tick, keepalive.intervalMs);
1425
+ pinger.unref?.();
1426
+ }
1427
+ if (signal?.aborted) close(signal.reason).catch(() => {});
1428
+ else signal?.addEventListener("abort", onTransportAbort, { once: true });
929
1429
  return {
930
1430
  maxMessageSize,
931
- async openPort(meta) {
1431
+ async openPort(meta, { signal: portSignal } = {}) {
932
1432
  if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
933
- if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
1433
+ if (portSignal?.aborted) throw portSignal.reason;
1434
+ if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached; open ports by kind: ${describeKinds(open.values())}`);
934
1435
  while (open.has(nextId)) nextId += 2;
935
1436
  const id = nextId;
936
1437
  nextId += 2;
937
- const handle = attach(id);
938
- post({
1438
+ const handle = attach(id, meta, window, true, exactCodec);
1439
+ post(window ? exactCodec ? {
1440
+ type: "open",
1441
+ id,
1442
+ meta,
1443
+ window,
1444
+ exact: true
1445
+ } : {
1446
+ type: "open",
1447
+ id,
1448
+ meta,
1449
+ window
1450
+ } : {
939
1451
  type: "open",
940
1452
  id,
941
1453
  meta
942
1454
  });
1455
+ if (portSignal) {
1456
+ const record = open.get(id)?.handle;
1457
+ const onAbort = () => {
1458
+ if (record && open.get(id)?.handle === record) record.close(portSignal.reason);
1459
+ };
1460
+ portSignal.addEventListener("abort", onAbort, { once: true });
1461
+ getPortCloseSignal(handle.port)?.addEventListener("abort", () => portSignal.removeEventListener("abort", onAbort), { once: true });
1462
+ }
943
1463
  return handle.port;
944
1464
  },
945
- async close() {
946
- if (muxClosed) return;
947
- for (const [id, handle] of [...open]) {
948
- post({
949
- type: "close",
950
- id
951
- });
952
- open.delete(id);
953
- handle.markClosed(/* @__PURE__ */ new Error(MUX_CLOSED_MESSAGE));
954
- }
955
- muxClosed = true;
956
- port.removeEventListener("message", listener);
957
- await port.close?.();
1465
+ close,
1466
+ ports() {
1467
+ return [...open.values()].map(({ handle: _, credit, ...info }) => credit ? {
1468
+ ...info,
1469
+ ...credit.enforced ? { window: credit.window } : {},
1470
+ queued: credit.queued,
1471
+ outstanding: credit.outstanding,
1472
+ unconsumed: credit.unconsumed
1473
+ } : info);
958
1474
  }
959
1475
  };
960
1476
  }
1477
+ /** The byte length of a received message's data, or `undefined` if it is not bytes. */
1478
+ function byteLengthOf(data) {
1479
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return data.byteLength;
1480
+ }
1481
+ /**
1482
+ * `"http: 1000, ws: 24"`: the three commonest `meta.kind`s among `entries`, so
1483
+ * a full table names who filled it. A port without a kind counts as `"?"`.
1484
+ */
1485
+ function describeKinds(entries) {
1486
+ const counts = /* @__PURE__ */ new Map();
1487
+ for (const { meta } of entries) {
1488
+ const kind = meta?.kind;
1489
+ const name = typeof kind === "string" ? kind : "?";
1490
+ counts.set(name, (counts.get(name) ?? 0) + 1);
1491
+ }
1492
+ if (!counts.size) return "none";
1493
+ return [...counts].sort((x, y) => y[1] - x[1]).slice(0, 3).map(([name, n]) => `${name}: ${n}`).join(", ");
1494
+ }
961
1495
  //#endregion
962
1496
  //#region src/connect-serve.ts
963
1497
  /**
@@ -981,9 +1515,22 @@ const connect = async ({ mux: factory, timeout }) => {
981
1515
  maxMessageSize: mux.maxMessageSize,
982
1516
  timeout
983
1517
  };
984
- const call = (input) => (async function* () {
985
- yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
986
- })();
1518
+ const call = (input) => {
1519
+ let stream;
1520
+ let abandoned = false;
1521
+ return returningAtOnce((async function* () {
1522
+ const port = await mux.openPort(STREAM_META);
1523
+ if (abandoned) {
1524
+ await port.close?.();
1525
+ return;
1526
+ }
1527
+ stream = duplexOverPort(port, streamOptions)(input);
1528
+ yield* stream;
1529
+ })(), () => {
1530
+ abandoned = true;
1531
+ stream?.return(void 0).catch(() => {});
1532
+ });
1533
+ };
987
1534
  return {
988
1535
  call,
989
1536
  async close() {
@@ -1119,7 +1666,7 @@ function isEnvelope(value) {
1119
1666
  const candidate = value;
1120
1667
  if (typeof candidate.id !== "number") return false;
1121
1668
  if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
1122
- return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
1669
+ return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
1123
1670
  }
1124
1671
  /**
1125
1672
  * For ports whose messages are structured values — a real `MessagePort`, a
@@ -1165,7 +1712,7 @@ const PORT_TRANSFER = "webrun-rpc:port-transfer";
1165
1712
  */
1166
1713
  function transferPortMux(target, options = {}) {
1167
1714
  const { onPort, maxMessageSize } = options;
1168
- const issued = /* @__PURE__ */ new Set();
1715
+ const issued = /* @__PURE__ */ new Map();
1169
1716
  let closed = false;
1170
1717
  const listener = (event) => {
1171
1718
  if (closed) return;
@@ -1184,28 +1731,47 @@ function transferPortMux(target, options = {}) {
1184
1731
  port.close();
1185
1732
  return;
1186
1733
  }
1187
- issued.add(port);
1734
+ const forget = () => issued.delete(port);
1735
+ issued.set(port, forget);
1736
+ const close = port.close.bind(port);
1737
+ port.close = () => {
1738
+ forget();
1739
+ close();
1740
+ };
1188
1741
  };
1189
1742
  target.addEventListener("message", listener);
1190
1743
  target.start?.();
1191
1744
  return {
1192
1745
  maxMessageSize,
1193
- async openPort(meta) {
1746
+ async openPort(meta, { signal } = {}) {
1194
1747
  if (closed) throw new Error("webrun-rpc: the multiplexer is closed");
1748
+ if (signal?.aborted) throw signal.reason;
1195
1749
  const channel = new MessageChannel();
1196
1750
  channel.port1.start();
1197
1751
  target.postMessage({
1198
1752
  type: PORT_TRANSFER,
1199
1753
  meta
1200
1754
  }, [channel.port2]);
1201
- issued.add(channel.port1);
1202
- return channel.port1;
1755
+ const port = channel.port1;
1756
+ const onAbort = () => port.close();
1757
+ signal?.addEventListener("abort", onAbort, { once: true });
1758
+ const forget = () => {
1759
+ signal?.removeEventListener("abort", onAbort);
1760
+ issued.delete(port);
1761
+ };
1762
+ issued.set(port, forget);
1763
+ const close = port.close.bind(port);
1764
+ port.close = () => {
1765
+ forget();
1766
+ close();
1767
+ };
1768
+ return port;
1203
1769
  },
1204
1770
  async close() {
1205
1771
  if (closed) return;
1206
1772
  closed = true;
1207
1773
  target.removeEventListener("message", listener);
1208
- for (const port of issued) try {
1774
+ for (const port of [...issued.keys()]) try {
1209
1775
  port.close();
1210
1776
  } catch {}
1211
1777
  issued.clear();
@@ -1214,4 +1780,4 @@ function transferPortMux(target, options = {}) {
1214
1780
  };
1215
1781
  }
1216
1782
  //#endregion
1217
- 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, postCancelChannel, recieve, send, serve, serveDuplexOverPort, setPortCloseSignal, structuredCodec, transferPortMux };
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 };