@actuallab/rpc 14.1.5 → 14.3.5

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
@@ -165,12 +165,24 @@ function readVarUint(data, offset) {
165
165
  } while (shift < 35);
166
166
  return { value: value >>> 0, bytesRead };
167
167
  }
168
+ var MIN_BINARY_ENVELOPE_SIZE = 7;
169
+ var MIN_COMPACT_BINARY_ENVELOPE_SIZE = 10;
170
+ function requireInBounds(data, pos, length, what) {
171
+ const end = pos + length;
172
+ if (!Number.isSafeInteger(length) || length < 0 || end > data.length)
173
+ throw new Error(`Invalid binary RPC message: ${what} length ${length} at ${pos} is out of bounds.`);
174
+ return end;
175
+ }
168
176
  function skipHeaders(data, pos, headerCount) {
169
177
  for (let h = 0; h < headerCount; h++) {
178
+ if (pos >= data.length)
179
+ throw new Error("Invalid binary RPC message: truncated header.");
170
180
  const keyLen = data[pos++];
171
- pos += keyLen;
181
+ pos = requireInBounds(data, pos, keyLen, "header key");
172
182
  const valLen = readVarUint(data, pos);
173
- pos += valLen.bytesRead + valLen.value;
183
+ if (valLen.bytesRead === 0)
184
+ throw new Error("Invalid binary RPC message: truncated header value length.");
185
+ pos = requireInBounds(data, pos + valLen.bytesRead, valLen.value, "header value");
174
186
  }
175
187
  return pos;
176
188
  }
@@ -233,6 +245,8 @@ function serializeBinaryMessage(message, args, encoder = defaultBinaryEncoder) {
233
245
  }
234
246
  function deserializeBinaryMessage(data, offset, decoder = defaultBinaryDecoder) {
235
247
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
248
+ if (offset + MIN_BINARY_ENVELOPE_SIZE > data.length)
249
+ throw new Error(`Invalid binary RPC message: truncated envelope at ${offset}.`);
236
250
  let pos = offset;
237
251
  const byte0 = data[pos++];
238
252
  const callTypeId = byte0 >> 5 & 7;
@@ -241,13 +255,15 @@ function deserializeBinaryMessage(data, offset, decoder = defaultBinaryDecoder)
241
255
  pos += relId.bytesRead;
242
256
  const methodLen = readVarUint(data, pos);
243
257
  pos += methodLen.bytesRead;
244
- const methodBytes = data.subarray(pos, pos + methodLen.value);
245
- const method = textDecoder.decode(methodBytes);
246
- pos += methodLen.value;
247
- const argDataLen = view.getInt32(pos, true);
258
+ const methodEnd = requireInBounds(data, pos, methodLen.value, "method");
259
+ const method = textDecoder.decode(data.subarray(pos, methodEnd));
260
+ pos = methodEnd;
261
+ if (pos + 4 > data.length)
262
+ throw new Error(`Invalid binary RPC message: truncated argument data length at ${pos}.`);
263
+ const argDataLen = view.getUint32(pos, true);
248
264
  pos += 4;
249
265
  const args = [];
250
- const argEnd = pos + argDataLen;
266
+ const argEnd = requireInBounds(data, pos, argDataLen, "argument data");
251
267
  if (argDataLen > 0) {
252
268
  const argSlice = data.subarray(pos, argEnd);
253
269
  for (const decoded of decoder.decodeMulti(argSlice)) {
@@ -268,12 +284,16 @@ function splitBinaryFrame(frame, decoder = defaultBinaryDecoder) {
268
284
  const results = [];
269
285
  let offset = 0;
270
286
  while (offset < frame.length) {
271
- const { message, args, bytesRead } = deserializeBinaryMessage(
272
- frame,
273
- offset,
274
- decoder
275
- );
276
- if (bytesRead <= 0) break;
287
+ let parsed;
288
+ try {
289
+ parsed = deserializeBinaryMessage(frame, offset, decoder);
290
+ } catch (e) {
291
+ warnLog?.log(`Failed to parse a binary message at offset ${offset}:`, e);
292
+ break;
293
+ }
294
+ const { message, args, bytesRead } = parsed;
295
+ if (!Number.isFinite(bytesRead) || bytesRead < MIN_BINARY_ENVELOPE_SIZE)
296
+ break;
277
297
  results.push({ message, args });
278
298
  offset += bytesRead;
279
299
  }
@@ -323,19 +343,23 @@ function serializeCompactBinaryMessage(message, args, registry, encoder = defaul
323
343
  }
324
344
  function deserializeCompactBinaryMessage(data, offset, registry, decoder = defaultBinaryDecoder) {
325
345
  const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
346
+ if (offset + MIN_COMPACT_BINARY_ENVELOPE_SIZE > data.length)
347
+ throw new Error(`Invalid compact binary RPC message: truncated envelope at ${offset}.`);
326
348
  let pos = offset;
327
349
  const byte0 = data[pos++];
328
350
  const callTypeId = byte0 >> 5 & 7;
329
351
  const headerCount = byte0 & 31;
330
352
  const relId = readVarUint(data, pos);
331
353
  pos += relId.bytesRead;
354
+ if (pos + 8 > data.length)
355
+ throw new Error(`Invalid compact binary RPC message: truncated envelope at ${offset}.`);
332
356
  const methodHash = view.getUint32(pos, true);
333
357
  pos += 4;
334
358
  const method = registry?.getName(methodHash | 0) ?? `<hash:0x${methodHash.toString(16).padStart(8, "0")}>`;
335
- const argDataLen = view.getInt32(pos, true);
359
+ const argDataLen = view.getUint32(pos, true);
336
360
  pos += 4;
337
361
  const args = [];
338
- const argEnd = pos + argDataLen;
362
+ const argEnd = requireInBounds(data, pos, argDataLen, "argument data");
339
363
  if (argDataLen > 0) {
340
364
  const argSlice = data.subarray(pos, argEnd);
341
365
  for (const decoded of decoder.decodeMulti(argSlice)) {
@@ -356,13 +380,16 @@ function splitCompactBinaryFrame(frame, registry, decoder = defaultBinaryDecoder
356
380
  const results = [];
357
381
  let offset = 0;
358
382
  while (offset < frame.length) {
359
- const { message, args, bytesRead } = deserializeCompactBinaryMessage(
360
- frame,
361
- offset,
362
- registry,
363
- decoder
364
- );
365
- if (bytesRead <= 0) break;
383
+ let parsed;
384
+ try {
385
+ parsed = deserializeCompactBinaryMessage(frame, offset, registry, decoder);
386
+ } catch (e) {
387
+ warnLog?.log(`Failed to parse a compact binary message at offset ${offset}:`, e);
388
+ break;
389
+ }
390
+ const { message, args, bytesRead } = parsed;
391
+ if (!Number.isFinite(bytesRead) || bytesRead < MIN_COMPACT_BINARY_ENVELOPE_SIZE)
392
+ break;
366
393
  results.push({ message, args });
367
394
  offset += bytesRead;
368
395
  }
@@ -424,46 +451,38 @@ var RpcWebSocketConnection = class {
424
451
  ws.onmessage = (ev) => {
425
452
  if (ev.data instanceof Uint8Array || ev.data instanceof ArrayBuffer) {
426
453
  const frame = ev.data instanceof Uint8Array ? ev.data : new Uint8Array(ev.data);
454
+ let messages;
427
455
  try {
428
- const messages = this._splitBinary(frame);
429
- for (const { message, args } of messages) {
430
- this.messageReceived.trigger({
431
- kind: "binary",
432
- message,
433
- args
434
- });
435
- }
456
+ messages = this._splitBinary(frame);
436
457
  } catch (e) {
437
458
  warnLog2?.log("Failed to split binary frame:", e);
438
459
  this.error.trigger(e);
460
+ return;
439
461
  }
462
+ for (const { message, args } of messages)
463
+ this._dispatchBinary(message, args);
440
464
  } else if (typeof Blob !== "undefined" && ev.data instanceof Blob && this.binaryMode) {
441
465
  void ev.data.arrayBuffer().then((ab) => {
442
466
  const frame = new Uint8Array(ab);
467
+ let messages;
443
468
  try {
444
- const messages = this._splitBinary(frame);
445
- for (const { message, args } of messages) {
446
- this.messageReceived.trigger({
447
- kind: "binary",
448
- message,
449
- args
450
- });
451
- }
469
+ messages = this._splitBinary(frame);
452
470
  } catch (e) {
453
471
  warnLog2?.log("Failed to split binary blob frame:", e);
454
472
  this.error.trigger(e);
473
+ return;
455
474
  }
475
+ for (const { message, args } of messages)
476
+ this._dispatchBinary(message, args);
456
477
  });
457
478
  } else {
458
479
  const data = typeof ev.data === "string" ? ev.data : String(ev.data);
459
- const messages = splitFrame(data);
460
- for (const msg of messages) {
480
+ for (const msg of splitFrame(data))
461
481
  if (msg.length > 0)
462
- this.messageReceived.trigger({
463
- kind: "text",
464
- raw: msg
465
- });
466
- }
482
+ this.messageReceived.triggerSafe(
483
+ { kind: "text", raw: msg },
484
+ (e) => warnLog2?.log("Failed to handle a text message:", e)
485
+ );
467
486
  }
468
487
  };
469
488
  ws.onclose = (ev) => {
@@ -502,6 +521,15 @@ var RpcWebSocketConnection = class {
502
521
  }
503
522
  return splitBinaryFrame(frame, this._decoder);
504
523
  }
524
+ // Containment matters here: .NET batches many RPC messages per WebSocket frame,
525
+ // so letting one bad message escape would drop every other message in the frame -
526
+ // including $sys.Ok replies, whose per-call timeouts are unbounded by default.
527
+ _dispatchBinary(message, args) {
528
+ this.messageReceived.triggerSafe(
529
+ { kind: "binary", message, args },
530
+ (e) => warnLog2?.log("Failed to handle a binary message:", e)
531
+ );
532
+ }
505
533
  close(code, reason) {
506
534
  this._ws.close(code, reason);
507
535
  }
@@ -527,6 +555,7 @@ var RpcWebSocketConnection = class {
527
555
 
528
556
  // src/rpc-message-channel-connection.ts
529
557
  import { EventHandlerSet as EventHandlerSet2 } from "@actuallab/core";
558
+ var { warnLog: warnLog3 } = getLogs("RpcMessageChannelConnection");
530
559
  var RpcMessageChannelConnection = class {
531
560
  _port;
532
561
  _open = true;
@@ -541,7 +570,10 @@ var RpcMessageChannelConnection = class {
541
570
  const data = typeof ev.data === "string" ? ev.data : String(ev.data);
542
571
  for (const msg of splitFrame(data))
543
572
  if (msg.length > 0)
544
- this.messageReceived.trigger({ kind: "text", raw: msg });
573
+ this.messageReceived.triggerSafe(
574
+ { kind: "text", raw: msg },
575
+ (e) => warnLog3?.log("Failed to handle a text message:", e)
576
+ );
545
577
  };
546
578
  }
547
579
  get isOpen() {
@@ -858,10 +890,18 @@ var RpcError = class extends Error {
858
890
  }
859
891
  };
860
892
  var REMOTE_EXCEPTION_TYPE_REF = "ActualLab.Serialization.RemoteException, ActualLab.Core";
861
- function toExceptionInfo(error) {
893
+ var genericErrorFilter = () => ({
894
+ TypeRef: REMOTE_EXCEPTION_TYPE_REF,
895
+ Message: "Error: An error occurred while processing the request."
896
+ });
897
+ function toExceptionInfo(error, filter) {
862
898
  const name = error instanceof Error ? error.name : "Error";
863
899
  const message = error instanceof Error ? error.message : String(error);
864
- return { TypeRef: REMOTE_EXCEPTION_TYPE_REF, Message: `${name}: ${message}` };
900
+ const info = {
901
+ TypeRef: REMOTE_EXCEPTION_TYPE_REF,
902
+ Message: `${name}: ${message}`
903
+ };
904
+ return filter === void 0 ? info : filter(error, info);
865
905
  }
866
906
 
867
907
  // src/increasing-seq-compressor.ts
@@ -955,1271 +995,1308 @@ function _readVarUint(data, offset) {
955
995
  return [value, offset];
956
996
  }
957
997
 
958
- // src/rpc-stream.ts
959
- import Denque from "denque";
960
- import { PromiseSource as PromiseSource4 } from "@actuallab/core";
961
-
962
- // src/rpc-object.ts
963
- var RpcObjectKind = { Local: 0, Remote: 1 };
998
+ // src/base64.ts
999
+ function base64Encode(bytes) {
1000
+ const chunkSize = 32768;
1001
+ let binary = "";
1002
+ for (let i = 0; i < bytes.length; i += chunkSize) {
1003
+ binary += String.fromCharCode(
1004
+ ...bytes.subarray(i, Math.min(bytes.length, i + chunkSize))
1005
+ );
1006
+ }
1007
+ return btoa(binary);
1008
+ }
1009
+ function base64UrlEncode(bytes) {
1010
+ return base64Encode(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1011
+ }
1012
+ function base64Decode(encoded) {
1013
+ const binary = atob(encoded);
1014
+ const out = new Uint8Array(binary.length);
1015
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
1016
+ return out;
1017
+ }
964
1018
 
965
- // src/rpc-stream-sender.ts
966
- import { PromiseSource as PromiseSource3, RingBuffer } from "@actuallab/core";
967
- var { warnLog: warnLog3 } = getLogs("RpcSharedStream");
968
- var DEFAULT_ACK_PERIOD = 256;
969
- var DEFAULT_ACK_ADVANCE = 128;
970
- var RTT_WINDOW_MS = 2e4;
971
- var RpcStreamSender = class _RpcStreamSender {
972
- static _emptyGuid = "00000000-0000-0000-0000-000000000000";
973
- id;
974
- kind = RpcObjectKind.Local;
975
- allowReconnect;
976
- isRealTime;
977
- canSkipTo;
978
- peer;
979
- ackPeriod;
980
- /**
981
- * Wire-level flow-control window: the maximum index the sender may
982
- * advance past the most recently acknowledged index. Mirrors .NET
983
- * `RpcStream.AckAdvance`.
984
- */
985
- ackAdvance;
986
- /**
987
- * Effective local ring buffer capacity used by the pump. Set larger
988
- * than `ackAdvance` to pre-buffer items beyond the in-flight window;
989
- * values below `ackAdvance` are clamped up (with a warning) at
990
- * construction time. Not transmitted on the wire.
991
- */
992
- bufferSize;
1019
+ // src/rpc-system-call-handler.ts
1020
+ var { debugLog, warnLog: warnLog4 } = getLogs("RpcSystemCallHandler");
1021
+ var RpcSystemCallHandler = class {
1022
+ handle(message, args, peer) {
1023
+ const method = message.Method;
1024
+ const relatedId = message.RelatedId ?? 0;
1025
+ debugLog?.log(`'${peer.ref}': handle ${method}#${relatedId}`);
1026
+ switch (method) {
1027
+ case RpcSystemCalls.ok: {
1028
+ const call = peer.outboundCalls.get(relatedId);
1029
+ if (call !== void 0) {
1030
+ if (args.length > 1) {
1031
+ peer.outboundCalls.remove(relatedId);
1032
+ call.result.reject(polymorphicPayloadError(method, args.length, 1));
1033
+ break;
1034
+ }
1035
+ call.completedStage |= RpcCallStage.ResultReady;
1036
+ if (call.removeOnOk) {
1037
+ peer.outboundCalls.remove(relatedId);
1038
+ }
1039
+ call.result.resolve(args[0]);
1040
+ }
1041
+ break;
1042
+ }
1043
+ case RpcSystemCalls.reconnect: {
1044
+ this._handleReconnect(relatedId, args, peer);
1045
+ break;
1046
+ }
1047
+ case RpcSystemCalls.error: {
1048
+ const call = peer.outboundCalls.get(relatedId);
1049
+ if (call !== void 0) {
1050
+ call.completedStage |= RpcCallStage.ResultReady;
1051
+ if (call.removeOnOk) {
1052
+ peer.outboundCalls.remove(relatedId);
1053
+ }
1054
+ const errorInfo = args[0];
1055
+ const msg = errorInfo?.Message ?? errorInfo?.message ?? "RPC error";
1056
+ const typeName = extractTypeName(errorInfo?.TypeRef ?? errorInfo?.typeRef);
1057
+ if (typeName === "ActualLab.Rpc.RpcRerouteException")
1058
+ warnLog4?.log("Got RpcRerouteException from remote peer:", msg);
1059
+ call.result.reject(new RpcError(msg, typeName));
1060
+ }
1061
+ break;
1062
+ }
1063
+ case RpcSystemCalls.cancel: {
1064
+ const call = peer.inboundCalls.remove(relatedId);
1065
+ call?.cancel();
1066
+ break;
1067
+ }
1068
+ case RpcSystemCalls.keepAlive: {
1069
+ peer.notifyKeepAliveReceived();
1070
+ const keepAliveIds = args[0];
1071
+ if (Array.isArray(keepAliveIds) && keepAliveIds.length > 0 && peer.connection !== void 0) {
1072
+ const unknownIds = [];
1073
+ for (const id of keepAliveIds)
1074
+ if (peer.sharedObjects.get(id) === void 0)
1075
+ unknownIds.push(id);
1076
+ if (unknownIds.length > 0)
1077
+ peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, unknownIds);
1078
+ }
1079
+ break;
1080
+ }
1081
+ case RpcSystemCalls.item: {
1082
+ const stream = peer.remoteObjects.get(relatedId);
1083
+ if (!stream)
1084
+ debugLog?.log(`$sys.I: no stream for relatedId=${relatedId}`);
1085
+ else if (args.length > 2)
1086
+ stream.onEnd(args[0], polymorphicPayloadError(method, args.length, 2));
1087
+ else
1088
+ stream.onItem(args[0], args[1]);
1089
+ break;
1090
+ }
1091
+ case RpcSystemCalls.batch: {
1092
+ const stream = peer.remoteObjects.get(relatedId);
1093
+ if (!stream)
1094
+ debugLog?.log(`$sys.B: no stream for relatedId=${relatedId}`);
1095
+ else if (args.length > 2)
1096
+ stream.onEnd(args[0], polymorphicPayloadError(method, args.length, 2));
1097
+ else if (Array.isArray(args[1]))
1098
+ stream.onBatch(args[0], args[1]);
1099
+ break;
1100
+ }
1101
+ case RpcSystemCalls.end: {
1102
+ const stream = peer.remoteObjects.get(relatedId);
1103
+ if (!stream) {
1104
+ debugLog?.log(`$sys.End: no stream for relatedId=${relatedId}`);
1105
+ } else {
1106
+ const errorInfo = args[1];
1107
+ const typeName = extractTypeName(errorInfo?.TypeRef ?? errorInfo?.typeRef);
1108
+ const msg = errorInfo?.Message ?? errorInfo?.message;
1109
+ let error = null;
1110
+ if (typeName || msg) {
1111
+ error = new Error(msg ?? typeName ?? "RPC stream error");
1112
+ if (typeName)
1113
+ error.name = typeName;
1114
+ }
1115
+ stream.onEnd(args[0], error);
1116
+ }
1117
+ break;
1118
+ }
1119
+ case RpcSystemCalls.ack: {
1120
+ const sender = peer.sharedObjects.get(relatedId);
1121
+ if (sender)
1122
+ sender.onAck(args[0], args[1]);
1123
+ else if (peer.connection !== void 0)
1124
+ peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1125
+ break;
1126
+ }
1127
+ case RpcSystemCalls.ackEnd: {
1128
+ const sender = peer.sharedObjects.get(relatedId);
1129
+ if (sender)
1130
+ sender.onAckEnd(args[0]);
1131
+ else if (peer.connection !== void 0)
1132
+ peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1133
+ break;
1134
+ }
1135
+ case RpcSystemCalls.disconnect: {
1136
+ const ids = args[0];
1137
+ if (Array.isArray(ids)) {
1138
+ for (const id of ids) {
1139
+ const remoteObj = peer.remoteObjects.get(id);
1140
+ if (remoteObj && typeof remoteObj.disconnect === "function") {
1141
+ remoteObj.disconnect();
1142
+ }
1143
+ }
1144
+ }
1145
+ break;
1146
+ }
1147
+ }
1148
+ }
993
1149
  /**
994
- * If true, the source was produced by a factory `(abortSignal) => AsyncIterable<T>`
995
- * and may honor the AbortSignal to exit gracefully. `disconnect()` first aborts
996
- * the signal and gives the source up to `RpcStream.disconnectGracePeriodMs` ms
997
- * before force-closing via `iterator.return()`.
1150
+ * Server-side handler for the `$sys.Reconnect` call. Parses the peer's
1151
+ * report of in-flight call IDs (grouped by stage, IncreasingSeqCompressor-
1152
+ * encoded, base64-wrapped for JSON transport), computes the subset this
1153
+ * peer does not recognize, and replies with a compressed byte[] of the
1154
+ * unknown IDs wrapped in `$sys.Ok`.
998
1155
  *
999
- * If false, the source is a plain AsyncIterable that cannot observe the signal,
1000
- * so `disconnect()` force-closes immediately.
1156
+ * @param relatedId The outbound call ID the client used for this
1157
+ * `$sys.Reconnect` invocation — we send `$sys.Ok` back
1158
+ * with the same `RelatedId`.
1001
1159
  */
1002
- sourceUsesAbortSignal;
1003
- // -- Pump state --
1004
- /** Next stream index that will be sent (matches .NET's `index`). */
1005
- _nextIndex = 0;
1006
- _ended = false;
1007
- _started = new PromiseSource3();
1008
- /** Highest `nextIndex` observed in an ACK so far (matches .NET's `lastAckNextIndex` idiom). */
1009
- _lastAckedIndex = 0;
1010
- _abortController = new AbortController();
1011
- _iterator = null;
1012
- // -- RTT sampling (send → matching ack round trips, windowed minimum) --
1013
- _pendingSendTimes = [];
1014
- _rttSamples = [];
1015
- // -- Recorder-controller metrics (Step 9.1) --
1016
- /** Total items skipped via real-time `canSkipTo` compaction. */
1017
- _skipCount = 0;
1018
- /** Fires after a $sys.Ack has been processed (post-compaction). The
1019
- * recorder's quality controller uses this to bump a "last ACK at"
1020
- * watchdog so it can distinguish "stuck" (no ACK > N s) from
1021
- * "throttled" (ACKs flowing). */
1022
- onAckProcessed;
1023
- /** Fires after each item is pushed onto the local ring buffer. The
1024
- * argument is the buffer count *after* the push. Together with
1025
- * `onAckProcessed` (which is the only place items leave the buffer),
1026
- * this gives controllers a complete picture of buffer utilization.
1027
- * When `bufferedCount === bufferSize`, the source pull is paused
1028
- * until the next ACK frees space. Listener errors are swallowed. */
1029
- onBuffered;
1030
- get nextIndex() {
1031
- return this._nextIndex;
1032
- }
1033
- get lastAckIndex() {
1034
- return this._lastAckedIndex;
1035
- }
1036
- get skipCount() {
1037
- return this._skipCount;
1038
- }
1039
- // Windowed-MIN ack round trip (ms, -1 until sampled): converges on propagation
1040
- // RTT — unlike a mean/EMA it is not inflated by self-induced queuing.
1041
- get minRttMs() {
1042
- const cutoffMs = Date.now() - RTT_WINDOW_MS;
1043
- while (this._rttSamples.length > 0 && this._rttSamples[0].atMs < cutoffMs)
1044
- this._rttSamples.shift();
1045
- if (this._rttSamples.length === 0)
1046
- return -1;
1047
- let min = this._rttSamples[0].rttMs;
1048
- for (const s of this._rttSamples) {
1049
- if (s.rttMs < min)
1050
- min = s.rttMs;
1051
- }
1052
- return min;
1053
- }
1054
- // -- ACK queue (analog of .NET's Channel<(long, bool)>) --
1055
- _acks = [];
1056
- _whenAckReady = null;
1057
- constructor(peer, ackPeriod = DEFAULT_ACK_PERIOD, ackAdvance = DEFAULT_ACK_ADVANCE, allowReconnect = true, isRealTime = false, canSkipTo = () => true, sourceUsesAbortSignal = false, bufferSize) {
1058
- const localId = peer.sharedObjects.nextId();
1059
- this.id = { hostId: peer.hub.hubId, localId };
1060
- this.allowReconnect = allowReconnect;
1061
- this.isRealTime = isRealTime;
1062
- this.canSkipTo = canSkipTo;
1063
- this.peer = peer;
1064
- this.ackPeriod = ackPeriod;
1065
- this.ackAdvance = ackAdvance;
1066
- this.sourceUsesAbortSignal = sourceUsesAbortSignal;
1067
- if (bufferSize !== void 0 && bufferSize !== 0 && bufferSize < ackAdvance)
1068
- warnLog3?.log(
1069
- `RpcStream bufferSize (${bufferSize}) is below ackAdvance (${ackAdvance}); using ackAdvance as buffer size.`
1160
+ _handleReconnect(relatedId, args, peer) {
1161
+ if (peer.connection === void 0) return;
1162
+ const handshakeIndex = args[0];
1163
+ if (typeof handshakeIndex !== "number" || handshakeIndex !== peer.ownHandshakeIndex) {
1164
+ const got = typeof handshakeIndex === "number" ? handshakeIndex : `<${typeof handshakeIndex}>`;
1165
+ peer.hub.systemCallSender.error(
1166
+ peer.connection,
1167
+ peer.serializationFormat,
1168
+ relatedId,
1169
+ new Error(
1170
+ `TooLateToReconnect: own handshake index ${peer.ownHandshakeIndex} != ${got}`
1171
+ )
1070
1172
  );
1071
- this.bufferSize = bufferSize !== void 0 && bufferSize >= ackAdvance ? bufferSize : ackAdvance;
1072
- }
1073
- /** AbortSignal that is aborted when the sender is disconnected. */
1074
- get abortSignal() {
1075
- return this._abortController.signal;
1076
- }
1077
- /** Returns the stream reference string for the $sys.Ok response. */
1078
- toRef() {
1079
- return `${this.id.hostId},${this.id.localId},${this.ackPeriod},${this.ackAdvance},${this.allowReconnect ? "1" : "0"},${this.isRealTime ? "1" : "0"}`;
1080
- }
1081
- /**
1082
- * Called by system call handler when $sys.Ack is received from the client.
1083
- *
1084
- * Mirrors .NET `RpcSharedStream<T>.OnAck` at
1085
- * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:68-101.
1086
- */
1087
- onAck(nextIndex, hostId) {
1088
- const mustReset = hostId !== "" && hostId !== _RpcStreamSender._emptyGuid;
1089
- if (mustReset && hostId !== this.id.hostId) {
1090
- this._sendDisconnect();
1091
1173
  return;
1092
1174
  }
1093
- if (!this._started.isCompleted) {
1094
- if (mustReset && nextIndex === 0)
1095
- this._started.resolve();
1096
- else {
1097
- this._sendDisconnect();
1098
- return;
1175
+ let unknownIds = [];
1176
+ try {
1177
+ const stagesRaw = args[1];
1178
+ if (stagesRaw && typeof stagesRaw === "object" && !Array.isArray(stagesRaw)) {
1179
+ const unknownSet = /* @__PURE__ */ new Set();
1180
+ for (const [stageKey, rawValue] of Object.entries(stagesRaw)) {
1181
+ const bytes = _toBytes(rawValue);
1182
+ if (bytes === null) continue;
1183
+ const completedStage = parseInt(stageKey, 10) || 0;
1184
+ const callIds = IncreasingSeqCompressor.deserialize(bytes);
1185
+ for (const callId of callIds) {
1186
+ const call = peer.inboundCalls.get(callId);
1187
+ if (call === void 0)
1188
+ unknownSet.add(callId);
1189
+ else if (completedStage < 1)
1190
+ call.resendResult();
1191
+ }
1192
+ }
1193
+ unknownIds = [...unknownSet].sort((a, b) => a - b);
1099
1194
  }
1100
- } else if (this._ended) {
1101
- this._sendDisconnect();
1102
- return;
1103
- } else if (mustReset && !this.allowReconnect) {
1104
- this._sendDisconnect();
1105
- return;
1106
- }
1107
- this._sampleRtt(nextIndex, mustReset);
1108
- this._acks.push({ nextIndex, mustReset });
1109
- if (this._whenAckReady) {
1110
- this._whenAckReady.resolve();
1111
- this._whenAckReady = null;
1195
+ } catch {
1112
1196
  }
1197
+ const responseBytes = IncreasingSeqCompressor.serialize(unknownIds);
1198
+ const responseValue = peer.serializationFormat.isBinary ? responseBytes : base64Encode(responseBytes);
1199
+ peer.hub.systemCallSender.ok(peer.connection, peer.serializationFormat, relatedId, responseValue);
1113
1200
  }
1114
- // Tells the consumer this shared stream is gone, then disposes it —
1115
- // mirrors RpcSharedStream.SendDisconnect (RpcSharedStream.cs:284-289).
1116
- _sendDisconnect() {
1117
- const conn = this.peer.connection;
1118
- if (conn)
1119
- this.peer.hub.systemCallSender.disconnect(conn, this.peer.serializationFormat, [this.id.localId]);
1120
- this.disconnect();
1201
+ };
1202
+ function polymorphicPayloadError(method, got, expected) {
1203
+ return new Error(
1204
+ `${method}: expected ${expected} value(s), got ${got} \u2014 polymorphic payloads are not supported by the TS client`
1205
+ );
1206
+ }
1207
+ function extractTypeName(typeRef) {
1208
+ if (typeof typeRef === "string") {
1209
+ if (typeRef.length === 0)
1210
+ return void 0;
1211
+ const commaIdx = typeRef.indexOf(",");
1212
+ return commaIdx >= 0 ? typeRef.slice(0, commaIdx) : typeRef;
1121
1213
  }
1122
- /** Called by system call handler when $sys.AckEnd is received from the client. */
1123
- onAckEnd(_hostId) {
1124
- this.disconnect();
1214
+ if (typeRef !== null && typeof typeRef === "object") {
1215
+ const t = typeRef;
1216
+ const aqn = t.AssemblyQualifiedName ?? t.assemblyQualifiedName;
1217
+ if (typeof aqn === "string" && aqn.length > 0) {
1218
+ const commaIdx = aqn.indexOf(",");
1219
+ return commaIdx >= 0 ? aqn.slice(0, commaIdx) : aqn;
1220
+ }
1221
+ return t.TypeName ?? t.typeName;
1125
1222
  }
1126
- /**
1127
- * Send a single item to the client at the current `_nextIndex`.
1128
- *
1129
- * Advances `_nextIndex` unconditionally — even if the peer is disconnected
1130
- * the item is considered "sent" from the pump's perspective so the replay
1131
- * buffer indexing stays consistent. If a connection is absent, the wire
1132
- * message is dropped; the item will be resent from the replay buffer
1133
- * after a reconnect (client sends `Ack(N, mustReset=true)`).
1134
- */
1135
- sendItem(item) {
1136
- if (this._ended) return;
1137
- const conn = this.peer.connection;
1138
- if (conn) {
1139
- this.peer.hub.systemCallSender.item(
1140
- conn,
1141
- this.peer.serializationFormat,
1142
- this.id.localId,
1143
- this._nextIndex,
1144
- item
1145
- );
1146
- this._pendingSendTimes.push({ index: this._nextIndex, sentAtMs: Date.now() });
1223
+ return void 0;
1224
+ }
1225
+ function _toBytes(value) {
1226
+ if (value instanceof Uint8Array) return value;
1227
+ if (typeof value === "string") {
1228
+ try {
1229
+ return base64Decode(value);
1230
+ } catch {
1231
+ return null;
1147
1232
  }
1148
- this._nextIndex++;
1149
1233
  }
1150
- /** Send a batch of items to the client. See {@link sendItem} for disconnect semantics. */
1151
- sendBatch(items) {
1152
- if (this._ended || items.length === 0) return;
1153
- const conn = this.peer.connection;
1154
- if (conn) {
1155
- this.peer.hub.systemCallSender.batch(
1234
+ return null;
1235
+ }
1236
+
1237
+ // src/rpc-system-call-sender.ts
1238
+ var { errorLog } = getLogs("RpcSystemCallSender");
1239
+ var RpcSystemCallSender = class _RpcSystemCallSender {
1240
+ // Reusable envelopes — one per hot-path method.
1241
+ _itemEnv = {
1242
+ Method: RpcSystemCalls.item,
1243
+ RelatedId: 0
1244
+ };
1245
+ _batchEnv = {
1246
+ Method: RpcSystemCalls.batch,
1247
+ RelatedId: 0
1248
+ };
1249
+ _endEnv = {
1250
+ Method: RpcSystemCalls.end,
1251
+ RelatedId: 0
1252
+ };
1253
+ _ackEnv = {
1254
+ Method: RpcSystemCalls.ack,
1255
+ RelatedId: 0
1256
+ };
1257
+ _ackEndEnv = {
1258
+ Method: RpcSystemCalls.ackEnd,
1259
+ RelatedId: 0
1260
+ };
1261
+ // Reusable arg arrays.
1262
+ _itemArgs = [0, null];
1263
+ _batchArgs = [0, null];
1264
+ _endArgs = [0, { TypeRef: "", Message: "" }];
1265
+ _ackArgs = [0, ""];
1266
+ _ackEndArgs = [""];
1267
+ static NO_ERROR = Object.freeze({ TypeRef: "", Message: "" });
1268
+ registry;
1269
+ /** Applied to every error this hub sends to a remote peer. Undefined means
1270
+ * pass through, i.e. the peer sees the original message — set it (e.g. to
1271
+ * {@link genericErrorFilter}) on a TS-hosted server, where handler errors
1272
+ * would otherwise disclose internal details to unauthenticated callers. */
1273
+ errorFilter;
1274
+ _send(conn, format, envelope, args) {
1275
+ const wireData = format.serializeMessage(
1276
+ envelope,
1277
+ args,
1278
+ conn.encoder,
1279
+ this.registry
1280
+ );
1281
+ if (typeof wireData === "string") {
1282
+ conn.send(wireData);
1283
+ } else {
1284
+ conn.sendBinary(wireData);
1285
+ }
1286
+ }
1287
+ handshake(conn, format, peerId, hubId, index) {
1288
+ const handshakeArg = format.isBinary ? [peerId, null, hubId, 2, index] : {
1289
+ RemotePeerId: peerId,
1290
+ RemoteApiVersionSet: null,
1291
+ RemoteHubId: hubId,
1292
+ ProtocolVersion: 2,
1293
+ Index: index
1294
+ };
1295
+ this._send(conn, format, { Method: RpcSystemCalls.handshake }, [
1296
+ handshakeArg
1297
+ ]);
1298
+ }
1299
+ ok(conn, format, relatedId, result) {
1300
+ try {
1301
+ this._send(
1156
1302
  conn,
1157
- this.peer.serializationFormat,
1158
- this.id.localId,
1159
- this._nextIndex,
1160
- items
1303
+ format,
1304
+ { Method: RpcSystemCalls.ok, RelatedId: relatedId },
1305
+ [result]
1161
1306
  );
1307
+ } catch (e) {
1308
+ errorLog?.log(`Failed to send Ok response for call #${relatedId}`, e);
1309
+ this.error(conn, format, relatedId, e);
1162
1310
  }
1163
- this._nextIndex += items.length;
1164
1311
  }
1165
- /** Signal stream completion to the client. */
1166
- sendEnd(error) {
1167
- if (this._ended) return;
1168
- this._ended = true;
1169
- const conn = this.peer.connection;
1170
- if (!conn) return;
1171
- const errorInfo = error ? toExceptionInfo(error) : { TypeRef: "", Message: "" };
1172
- this.peer.hub.systemCallSender.end(
1312
+ error(conn, format, relatedId, error) {
1313
+ this._send(
1173
1314
  conn,
1174
- this.peer.serializationFormat,
1175
- this.id.localId,
1176
- this._nextIndex,
1177
- errorInfo
1315
+ format,
1316
+ { Method: RpcSystemCalls.error, RelatedId: relatedId },
1317
+ [toExceptionInfo(error, this.errorFilter)]
1178
1318
  );
1179
1319
  }
1180
- /**
1181
- * Consume an AsyncIterable and send all items to the client.
1182
- *
1183
- * Matches .NET `RpcSharedStream<T>.OnRun` at
1184
- * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs.
1185
- */
1186
- async writeFrom(source) {
1187
- await this._started;
1188
- if (this._ended) return;
1189
- const iterator = source[Symbol.asyncIterator]();
1190
- this._iterator = iterator;
1191
- const state = { iteratorDone: false };
1192
- try {
1193
- await this._run(iterator, state);
1194
- } finally {
1195
- if (state.iteratorDone && this._iterator === iterator) {
1196
- this._iterator = null;
1197
- }
1198
- }
1320
+ cancel(conn, format, relatedId) {
1321
+ this._send(conn, format, {
1322
+ Method: RpcSystemCalls.cancel,
1323
+ RelatedId: relatedId
1324
+ });
1325
+ }
1326
+ keepAlive(conn, format, activeCallIds) {
1327
+ this._send(conn, format, { Method: RpcSystemCalls.keepAlive }, [
1328
+ activeCallIds
1329
+ ]);
1330
+ }
1331
+ // Tells the remote peer the listed shared-object ids are gone on our side,
1332
+ // so its matching remote objects (e.g. RpcStream consumers) fail fast
1333
+ // instead of hanging. Mirrors .NET RpcSystemCallSender.Disconnect.
1334
+ disconnect(conn, format, localIds) {
1335
+ this._send(conn, format, { Method: RpcSystemCalls.disconnect }, [
1336
+ localIds
1337
+ ]);
1338
+ }
1339
+ item(conn, format, localId, index, item) {
1340
+ this._itemEnv.RelatedId = localId;
1341
+ this._itemArgs[0] = index;
1342
+ this._itemArgs[1] = item;
1343
+ this._send(conn, format, this._itemEnv, this._itemArgs);
1344
+ this._itemArgs[1] = null;
1345
+ }
1346
+ batch(conn, format, localId, index, items) {
1347
+ this._batchEnv.RelatedId = localId;
1348
+ this._batchArgs[0] = index;
1349
+ this._batchArgs[1] = items;
1350
+ this._send(conn, format, this._batchEnv, this._batchArgs);
1351
+ this._batchArgs[1] = null;
1352
+ }
1353
+ end(conn, format, localId, index, error = _RpcSystemCallSender.NO_ERROR) {
1354
+ this._endEnv.RelatedId = localId;
1355
+ this._endArgs[0] = index;
1356
+ this._endArgs[1] = error;
1357
+ this._send(conn, format, this._endEnv, this._endArgs);
1358
+ }
1359
+ ack(conn, format, localId, nextIndex, hostId) {
1360
+ this._ackEnv.RelatedId = localId;
1361
+ this._ackArgs[0] = nextIndex;
1362
+ this._ackArgs[1] = hostId;
1363
+ this._send(conn, format, this._ackEnv, this._ackArgs);
1364
+ }
1365
+ ackEnd(conn, format, localId, hostId) {
1366
+ this._ackEndEnv.RelatedId = localId;
1367
+ this._ackEndArgs[0] = hostId;
1368
+ this._send(conn, format, this._ackEndEnv, this._ackEndArgs);
1199
1369
  }
1370
+ };
1371
+
1372
+ // src/rpc-object.ts
1373
+ var RpcObjectKind = { Local: 0, Remote: 1 };
1374
+
1375
+ // src/rpc-stream.ts
1376
+ import Denque from "denque";
1377
+ import { PromiseSource as PromiseSource4 } from "@actuallab/core";
1378
+
1379
+ // src/rpc-stream-sender.ts
1380
+ import { PromiseSource as PromiseSource3, RingBuffer } from "@actuallab/core";
1381
+ var { warnLog: warnLog5 } = getLogs("RpcSharedStream");
1382
+ var DEFAULT_ACK_PERIOD = 256;
1383
+ var DEFAULT_ACK_ADVANCE = 128;
1384
+ var RTT_WINDOW_MS = 2e4;
1385
+ var MAX_PENDING_SEND_TIMES = 4096;
1386
+ var RpcStreamSender = class _RpcStreamSender {
1387
+ static _emptyGuid = "00000000-0000-0000-0000-000000000000";
1388
+ id;
1389
+ kind = RpcObjectKind.Local;
1390
+ allowReconnect;
1391
+ isRealTime;
1392
+ canSkipTo;
1393
+ peer;
1394
+ ackPeriod;
1200
1395
  /**
1201
- * ACK-driven pump. Non-real-time streams apply normal backpressure at
1202
- * `ackAdvance`; real-time streams may compact the already-buffered unsent
1203
- * suffix to the latest buffered `canSkipTo` item after the consumer ACKs.
1204
- * With `bufferSize > ackAdvance` the local ring buffer holds more than the
1205
- * in-flight window — real-time mode uses the extra space to pre-buffer.
1396
+ * Wire-level flow-control window: the maximum index the sender may
1397
+ * advance past the most recently acknowledged index. Mirrors .NET
1398
+ * `RpcStream.AckAdvance`.
1206
1399
  */
1207
- /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _ended changes across awaits */
1208
- async _run(iterator, state) {
1209
- const buffer = new RingBuffer(this.bufferSize + 1);
1210
- const isRealTime = this.isRealTime;
1211
- let bufferStart = 0;
1212
- let isFullyBuffered = false;
1213
- let whenMovedNext = null;
1214
- const pending = /* @__PURE__ */ Symbol("pending");
1215
- const readNext = async () => {
1216
- const next = whenMovedNext ?? iterator.next();
1217
- whenMovedNext = null;
1218
- return await next;
1219
- };
1220
- const tryReadReady = async () => {
1221
- whenMovedNext ??= iterator.next();
1222
- return await Promise.race([
1223
- whenMovedNext,
1224
- Promise.resolve(pending)
1225
- ]);
1226
- };
1227
- const fireBuffered = () => {
1228
- try {
1229
- this.onBuffered?.(buffer.count);
1230
- } catch {
1231
- }
1232
- };
1233
- const bufferNext = async (onlyIfReady, prefetchNext) => {
1234
- let r;
1235
- try {
1236
- r = onlyIfReady ? await tryReadReady() : await readNext();
1237
- if (r === pending)
1238
- return false;
1239
- whenMovedNext = null;
1240
- if (this._ended) return false;
1241
- if (r.done) {
1242
- buffer.pushTail(_endItem);
1243
- isFullyBuffered = true;
1244
- state.iteratorDone = true;
1245
- } else {
1246
- buffer.pushTail({ kind: "value", value: r.value });
1247
- if (prefetchNext)
1248
- whenMovedNext = iterator.next();
1249
- }
1250
- fireBuffered();
1251
- return true;
1252
- } catch (e) {
1253
- whenMovedNext = null;
1254
- state.iteratorDone = true;
1255
- isFullyBuffered = true;
1256
- buffer.pushTail({
1257
- kind: "error",
1258
- error: e instanceof Error ? e : new Error(String(e))
1259
- });
1260
- fireBuffered();
1261
- return true;
1262
- }
1263
- };
1264
- while (true) {
1265
- let ack = this._tryProcessAcks();
1266
- if (!ack) {
1267
- await this._waitAckReady();
1268
- if (this._ended) return;
1269
- ack = this._tryProcessAcks();
1270
- if (!ack) {
1271
- warnLog3?.log("Something is off: couldn't read an acknowledgement");
1272
- return;
1273
- }
1274
- }
1275
- this._lastAckedIndex = ack.nextIndex;
1276
- if (isRealTime && ack.mustReset && !isFullyBuffered) {
1277
- buffer.clear();
1278
- bufferStart = this._nextIndex;
1279
- while (true) {
1280
- if (this._ended) return;
1281
- let item;
1282
- let accepted = false;
1283
- try {
1284
- const r = await readNext();
1285
- if (this._ended) return;
1286
- if (r.done) {
1287
- item = _endItem;
1288
- isFullyBuffered = true;
1289
- state.iteratorDone = true;
1290
- } else if (this.canSkipTo(r.value)) {
1291
- item = { kind: "value", value: r.value };
1292
- accepted = true;
1293
- } else {
1294
- continue;
1295
- }
1296
- } catch (e) {
1297
- state.iteratorDone = true;
1298
- isFullyBuffered = true;
1299
- item = {
1300
- kind: "error",
1301
- error: e instanceof Error ? e : new Error(String(e))
1302
- };
1303
- }
1304
- buffer.pushTail(item);
1305
- fireBuffered();
1306
- if (accepted || isFullyBuffered) break;
1307
- }
1308
- }
1309
- const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1310
- if (shift > 0) {
1311
- buffer.moveHead(shift);
1312
- bufferStart += shift;
1313
- }
1314
- if (this._nextIndex < bufferStart) {
1315
- if (!this._ended)
1316
- this.sendEnd(new Error("Stream position unavailable."));
1317
- return;
1318
- }
1319
- let bufferIndex = this._nextIndex - bufferStart;
1320
- const maxIndex = ack.nextIndex + this.ackAdvance;
1321
- while (this._nextIndex < maxIndex) {
1322
- if (this._ended) return;
1323
- if (isRealTime) {
1324
- while (buffer.hasRemainingCapacity && !isFullyBuffered && this._acks.length === 0) {
1325
- if (!await bufferNext(true, true))
1326
- break;
1327
- }
1328
- if (bufferIndex >= buffer.count && !isFullyBuffered && buffer.hasRemainingCapacity && this._acks.length === 0) {
1329
- await bufferNext(false, true);
1330
- }
1331
- if (ack.nextIndex > 0) {
1332
- const result = _compactBufferedUnsentSuffix(
1333
- buffer,
1334
- bufferIndex,
1335
- this.canSkipTo
1336
- );
1337
- bufferIndex = result.newIndex;
1338
- if (result.skipped > 0)
1339
- this._skipCount += result.skipped;
1340
- }
1341
- if (this._acks.length > 0) break;
1342
- }
1343
- while (this._nextIndex < maxIndex && bufferIndex < buffer.count) {
1344
- const item = buffer.get(bufferIndex++);
1345
- if (item.kind === "value") {
1346
- this.sendItem(item.value);
1347
- } else if (item.kind === "end") {
1348
- if (!this._ended) this.sendEnd();
1349
- return;
1350
- } else {
1351
- if (!this._ended) this.sendEnd(item.error);
1352
- return;
1353
- }
1354
- }
1355
- if (this._nextIndex >= maxIndex)
1356
- break;
1357
- if (isFullyBuffered) break;
1358
- if (!buffer.hasRemainingCapacity) break;
1359
- if (!isRealTime)
1360
- await bufferNext(false, false);
1361
- }
1362
- }
1400
+ ackAdvance;
1401
+ /**
1402
+ * Effective local ring buffer capacity used by the pump. Set larger
1403
+ * than `ackAdvance` to pre-buffer items beyond the in-flight window;
1404
+ * values below `ackAdvance` are clamped up (with a warning) at
1405
+ * construction time. Not transmitted on the wire.
1406
+ */
1407
+ bufferSize;
1408
+ /**
1409
+ * If true, the source was produced by a factory `(abortSignal) => AsyncIterable<T>`
1410
+ * and may honor the AbortSignal to exit gracefully. `disconnect()` first aborts
1411
+ * the signal and gives the source up to `RpcStream.disconnectGracePeriodMs` ms
1412
+ * before force-closing via `iterator.return()`.
1413
+ *
1414
+ * If false, the source is a plain AsyncIterable that cannot observe the signal,
1415
+ * so `disconnect()` force-closes immediately.
1416
+ */
1417
+ sourceUsesAbortSignal;
1418
+ // -- Pump state --
1419
+ /** Next stream index that will be sent (matches .NET's `index`). */
1420
+ _nextIndex = 0;
1421
+ _ended = false;
1422
+ _started = new PromiseSource3();
1423
+ /** Highest `nextIndex` observed in an ACK so far (matches .NET's `lastAckNextIndex` idiom). */
1424
+ _lastAckedIndex = 0;
1425
+ _abortController = new AbortController();
1426
+ _iterator = null;
1427
+ // -- RTT sampling (send → matching ack round trips, windowed minimum) --
1428
+ _pendingSendTimes = [];
1429
+ _rttSamples = [];
1430
+ // -- Recorder-controller metrics (Step 9.1) --
1431
+ /** Total items skipped via real-time `canSkipTo` compaction. */
1432
+ _skipCount = 0;
1433
+ /** Fires after a $sys.Ack has been processed (post-compaction). The
1434
+ * recorder's quality controller uses this to bump a "last ACK at"
1435
+ * watchdog so it can distinguish "stuck" (no ACK > N s) from
1436
+ * "throttled" (ACKs flowing). */
1437
+ onAckProcessed;
1438
+ /** Fires after each item is pushed onto the local ring buffer. The
1439
+ * argument is the buffer count *after* the push. Together with
1440
+ * `onAckProcessed` (which is the only place items leave the buffer),
1441
+ * this gives controllers a complete picture of buffer utilization.
1442
+ * When `bufferedCount === bufferSize`, the source pull is paused
1443
+ * until the next ACK frees space. Listener errors are swallowed. */
1444
+ onBuffered;
1445
+ get nextIndex() {
1446
+ return this._nextIndex;
1363
1447
  }
1364
- /* eslint-enable @typescript-eslint/no-unnecessary-condition */
1365
- /** Drain all queued ACKs, returning the most recent one (or null if none).
1366
- * A reconnect's reset ACK may be drained in one batch with regular
1367
- * consumption ACKs the client sends right after it, so `mustReset` is
1368
- * OR-ed over the whole batch — the last ACK alone can't represent it. */
1369
- _tryProcessAcks() {
1370
- if (this._acks.length === 0) return null;
1371
- let nextIndex = 0;
1372
- let mustReset = false;
1373
- while (this._acks.length > 0) {
1374
- const a = this._acks.shift();
1375
- nextIndex = a.nextIndex;
1376
- mustReset ||= a.mustReset;
1377
- if (a.mustReset || this._nextIndex < a.nextIndex) {
1378
- this._nextIndex = a.nextIndex;
1379
- }
1380
- }
1381
- try {
1382
- this.onAckProcessed?.();
1383
- } catch {
1384
- }
1385
- return { nextIndex, mustReset };
1448
+ get lastAckIndex() {
1449
+ return this._lastAckedIndex;
1386
1450
  }
1387
- /** Wait for the ACK queue to have at least one entry (or `_ended`). */
1388
- async _waitAckReady() {
1389
- if (this._acks.length > 0 || this._ended) return;
1390
- this._whenAckReady = new PromiseSource3();
1391
- await this._whenAckReady;
1392
- this._whenAckReady = null;
1451
+ get skipCount() {
1452
+ return this._skipCount;
1393
1453
  }
1394
- // One RTT sample per ack: time from sending the newest acked item to the
1395
- // ack's arrival (a slight upper bound — includes the server's ack cadence).
1396
- _sampleRtt(ackNextIndex, mustReset) {
1397
- if (mustReset) {
1398
- this._pendingSendTimes.length = 0;
1399
- return;
1400
- }
1401
- let sentAtMs = -1;
1402
- while (this._pendingSendTimes.length > 0 && this._pendingSendTimes[0].index < ackNextIndex) {
1403
- sentAtMs = this._pendingSendTimes[0].sentAtMs;
1404
- this._pendingSendTimes.shift();
1405
- }
1406
- if (sentAtMs < 0)
1407
- return;
1408
- const nowMs = Date.now();
1409
- const cutoffMs = nowMs - RTT_WINDOW_MS;
1454
+ // Windowed-MIN ack round trip (ms, -1 until sampled): converges on propagation
1455
+ // RTT — unlike a mean/EMA it is not inflated by self-induced queuing.
1456
+ get minRttMs() {
1457
+ const cutoffMs = Date.now() - RTT_WINDOW_MS;
1410
1458
  while (this._rttSamples.length > 0 && this._rttSamples[0].atMs < cutoffMs)
1411
1459
  this._rttSamples.shift();
1412
- this._rttSamples.push({ atMs: nowMs, rttMs: nowMs - sentAtMs });
1460
+ if (this._rttSamples.length === 0)
1461
+ return -1;
1462
+ let min = this._rttSamples[0].rttMs;
1463
+ for (const s of this._rttSamples) {
1464
+ if (s.rttMs < min)
1465
+ min = s.rttMs;
1466
+ }
1467
+ return min;
1413
1468
  }
1414
- // -- IRpcObject --
1415
- reconnect() {
1469
+ // -- Coalesced ACK state (analog of .NET's Channel<(long, bool)>) --
1470
+ // The pump reduces any queued ACKs to the newest index plus the OR of
1471
+ // `mustReset` anyway, so we accumulate exactly that at receipt: an ACK flood
1472
+ // then costs O(1) memory and O(1) work instead of an unbounded array drained
1473
+ // with repeated `shift()`.
1474
+ /** Newest unprocessed ACK index; -1 means "nothing pending". */
1475
+ _ackIndex = -1;
1476
+ _ackMustReset = false;
1477
+ /** Highest ACK index accepted so far — the monotonicity baseline a
1478
+ * non-reset ACK may not regress below. */
1479
+ _maxAckIndex = 0;
1480
+ _whenAckReady = null;
1481
+ constructor(peer, ackPeriod = DEFAULT_ACK_PERIOD, ackAdvance = DEFAULT_ACK_ADVANCE, allowReconnect = true, isRealTime = false, canSkipTo = () => true, sourceUsesAbortSignal = false, bufferSize) {
1482
+ const localId = peer.sharedObjects.nextId();
1483
+ this.id = { hostId: peer.hub.hubId, localId };
1484
+ this.allowReconnect = allowReconnect;
1485
+ this.isRealTime = isRealTime;
1486
+ this.canSkipTo = canSkipTo;
1487
+ this.peer = peer;
1488
+ this.ackPeriod = ackPeriod;
1489
+ this.ackAdvance = ackAdvance;
1490
+ this.sourceUsesAbortSignal = sourceUsesAbortSignal;
1491
+ if (bufferSize !== void 0 && bufferSize !== 0 && bufferSize < ackAdvance)
1492
+ warnLog5?.log(
1493
+ `RpcStream bufferSize (${bufferSize}) is below ackAdvance (${ackAdvance}); using ackAdvance as buffer size.`
1494
+ );
1495
+ this.bufferSize = bufferSize !== void 0 && bufferSize >= ackAdvance ? bufferSize : ackAdvance;
1416
1496
  }
1417
- disconnect() {
1418
- if (this._ended) return;
1419
- this._ended = true;
1420
- this._abortController.abort();
1497
+ /** AbortSignal that is aborted when the sender is disconnected. */
1498
+ get abortSignal() {
1499
+ return this._abortController.signal;
1500
+ }
1501
+ /** Returns the stream reference string for the $sys.Ok response. */
1502
+ toRef() {
1503
+ return `${this.id.hostId},${this.id.localId},${this.ackPeriod},${this.ackAdvance},${this.allowReconnect ? "1" : "0"},${this.isRealTime ? "1" : "0"}`;
1504
+ }
1505
+ /**
1506
+ * Called by system call handler when $sys.Ack is received from the client.
1507
+ *
1508
+ * Mirrors .NET `RpcSharedStream<T>.OnAck` at
1509
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs:68-101.
1510
+ */
1511
+ onAck(nextIndex, hostId) {
1512
+ const mustReset = hostId !== "" && hostId !== _RpcStreamSender._emptyGuid;
1513
+ if (mustReset && hostId !== this.id.hostId) {
1514
+ this._sendDisconnect();
1515
+ return;
1516
+ }
1517
+ if (!Number.isSafeInteger(nextIndex) || nextIndex < 0 || nextIndex > this._nextIndex) {
1518
+ this._sendDisconnect();
1519
+ return;
1520
+ }
1421
1521
  if (!this._started.isCompleted) {
1422
- this._started.resolve();
1522
+ if (mustReset && nextIndex === 0)
1523
+ this._started.resolve();
1524
+ else {
1525
+ this._sendDisconnect();
1526
+ return;
1527
+ }
1528
+ } else if (this._ended) {
1529
+ this._sendDisconnect();
1530
+ return;
1531
+ } else if (mustReset && !this.allowReconnect) {
1532
+ this._sendDisconnect();
1533
+ return;
1423
1534
  }
1535
+ if (!mustReset && nextIndex < this._maxAckIndex)
1536
+ return;
1537
+ this._maxAckIndex = nextIndex;
1538
+ this._sampleRtt(nextIndex, mustReset);
1539
+ this._ackIndex = nextIndex;
1540
+ this._ackMustReset ||= mustReset;
1424
1541
  if (this._whenAckReady) {
1425
1542
  this._whenAckReady.resolve();
1426
1543
  this._whenAckReady = null;
1427
1544
  }
1428
- this.peer.sharedObjects.unregister(this);
1429
- const iterator = this._iterator;
1430
- if (!iterator) return;
1431
- if (!this.sourceUsesAbortSignal) {
1432
- this._iterator = null;
1433
- this._forceCloseIterator(iterator);
1434
- return;
1435
- }
1436
- setTimeout(() => {
1437
- if (this._iterator === iterator) {
1438
- this._iterator = null;
1439
- this._forceCloseIterator(iterator);
1440
- }
1441
- }, RpcStream.disconnectGracePeriodMs);
1442
- }
1443
- _forceCloseIterator(iterator) {
1444
- try {
1445
- const ret = iterator.return?.(void 0);
1446
- if (ret) void ret.catch(() => {
1447
- });
1448
- } catch {
1449
- }
1450
- }
1451
- };
1452
- var _endItem = { kind: "end" };
1453
- function _clamp(value, min, max) {
1454
- return value < min ? min : value > max ? max : value;
1455
- }
1456
- function _compactBufferedUnsentSuffix(buffer, firstUnsentIndex, canSkipTo) {
1457
- let skipToIndex = -1;
1458
- for (let i = firstUnsentIndex; i < buffer.count; i++) {
1459
- const item = buffer.get(i);
1460
- if (item.kind === "value" && canSkipTo(item.value))
1461
- skipToIndex = i;
1462
1545
  }
1463
- if (skipToIndex <= firstUnsentIndex)
1464
- return { newIndex: firstUnsentIndex, skipped: 0 };
1465
- const cutFrom = firstUnsentIndex;
1466
- const cutTo = skipToIndex;
1467
- const items = buffer.toArray();
1468
- buffer.clear();
1469
- for (let i = 0; i < cutFrom; i++)
1470
- buffer.pushTail(items[i]);
1471
- for (let i = cutTo; i < items.length; i++)
1472
- buffer.pushTail(items[i]);
1473
- return { newIndex: firstUnsentIndex, skipped: skipToIndex - firstUnsentIndex };
1474
- }
1475
-
1476
- // src/rpc-stream.ts
1477
- var { warnLog: warnLog4 } = getLogs("RpcStream");
1478
- var DEFAULT_ACK_PERIOD2 = 30;
1479
- var DEFAULT_ACK_ADVANCE2 = 61;
1480
- function parseStreamRef(value) {
1481
- if (typeof value === "string") {
1482
- const parts = value.split(",");
1483
- if (parts.length < 4 || parts.length > 6) return null;
1484
- const hostId = parts[0];
1485
- const localId = parseInt(parts[1], 10);
1486
- const ackPeriod = parseInt(parts[2], 10);
1487
- const ackAdvance = parseInt(parts[3], 10);
1488
- if (isNaN(localId) || isNaN(ackPeriod) || isNaN(ackAdvance))
1489
- return null;
1490
- const allowReconnect = parts.length < 5 || parts[4] !== "0";
1491
- const isRealTime = parts.length >= 6 && parts[5] === "1";
1492
- return { hostId, localId, ackPeriod, ackAdvance, allowReconnect, isRealTime };
1546
+ // Tells the consumer this shared stream is gone, then disposes it —
1547
+ // mirrors RpcSharedStream.SendDisconnect (RpcSharedStream.cs:284-289).
1548
+ _sendDisconnect() {
1549
+ const conn = this.peer.connection;
1550
+ if (conn)
1551
+ this.peer.hub.systemCallSender.disconnect(conn, this.peer.serializationFormat, [this.id.localId]);
1552
+ this.disconnect();
1493
1553
  }
1494
- if (typeof value === "object" && value !== null) {
1495
- const obj = value;
1496
- const serializedId = obj.SerializedId;
1497
- if (!Array.isArray(serializedId) || serializedId.length < 2)
1498
- return null;
1499
- const rawHostId = serializedId[0];
1500
- const hostId = String(rawHostId);
1501
- const localId = Number(serializedId[1]);
1502
- const ackPeriod = Number(obj.AckPeriod ?? 256);
1503
- const ackAdvance = Number(obj.AckAdvance ?? 128);
1504
- const allowReconnect = obj.AllowReconnect !== false;
1505
- const isRealTime = obj.IsRealTime === true;
1506
- return { hostId, localId, ackPeriod, ackAdvance, allowReconnect, isRealTime };
1554
+ /** Called by system call handler when $sys.AckEnd is received from the client. */
1555
+ onAckEnd(_hostId) {
1556
+ this.disconnect();
1507
1557
  }
1508
- return null;
1509
- }
1510
- var RpcStream = class _RpcStream {
1511
1558
  /**
1512
- * Grace period (ms) given to a factory-form local source to honor its
1513
- * AbortSignal and exit naturally before the sender force-closes the
1514
- * iterator via `iterator.return()`.
1559
+ * Send a single item to the client at the current `_nextIndex`.
1515
1560
  *
1516
- * Only applies to sources provided as `(abortSignal) => AsyncIterable<T>`.
1517
- * Plain `AsyncIterable<T>` sources are force-closed immediately on disconnect.
1518
- */
1519
- static disconnectGracePeriodMs = 100;
1520
- id;
1521
- kind;
1522
- allowReconnect;
1523
- isRealTime;
1524
- canSkipTo;
1525
- ackPeriod;
1526
- ackAdvance;
1527
- /**
1528
- * Local-only ring buffer capacity hint for the sender. Undefined means
1529
- * "use ackAdvance as the buffer size". Not part of the wire ref.
1561
+ * Advances `_nextIndex` unconditionally — even if the peer is disconnected
1562
+ * the item is considered "sent" from the pump's perspective so the replay
1563
+ * buffer indexing stays consistent. If a connection is absent, the wire
1564
+ * message is dropped; the item will be resent from the replay buffer
1565
+ * after a reconnect (client sends `Ack(N, mustReset=true)`).
1530
1566
  */
1531
- bufferSize;
1532
- // Local (origin) mode
1533
- localSource;
1534
- // Remote (target) mode
1535
- peer;
1536
- // Local-only state
1537
- _sender;
1538
- _toRefValue;
1539
- _whenSent;
1540
- // Remote-only state
1541
- _buffer = new Denque();
1542
- _nextExpectedIndex = 0;
1543
- _consumerWaiting = null;
1544
- _completed = false;
1545
- _completionError = null;
1546
- _disposed = false;
1547
- _started = false;
1548
- _iterating = false;
1549
- _ackSentUpTo = 0;
1550
- _nextConsumedIndex = 0;
1551
- constructor(sourceOrRef, optionsOrPeer) {
1552
- if (_isStreamSource(sourceOrRef)) {
1553
- const options = optionsOrPeer ?? {};
1554
- this.kind = RpcObjectKind.Local;
1555
- this.localSource = sourceOrRef;
1556
- this.id = { hostId: "", localId: 0 };
1557
- this.allowReconnect = options.allowReconnect ?? true;
1558
- this.isRealTime = options.isRealTime ?? false;
1559
- this.canSkipTo = options.canSkipTo ?? (() => true);
1560
- this.ackPeriod = options.ackPeriod ?? DEFAULT_ACK_PERIOD2;
1561
- this.ackAdvance = options.ackAdvance ?? DEFAULT_ACK_ADVANCE2;
1562
- this.bufferSize = options.bufferSize;
1563
- } else {
1564
- const ref = sourceOrRef;
1565
- const peer = optionsOrPeer;
1566
- this.kind = RpcObjectKind.Remote;
1567
- this.id = { hostId: ref.hostId, localId: ref.localId };
1568
- this.peer = peer;
1569
- this.allowReconnect = ref.allowReconnect;
1570
- this.isRealTime = ref.isRealTime;
1571
- this.canSkipTo = () => true;
1572
- this.ackPeriod = ref.ackPeriod;
1573
- this.ackAdvance = ref.ackAdvance;
1567
+ sendItem(item) {
1568
+ if (this._ended) return;
1569
+ const conn = this.peer.connection;
1570
+ if (conn) {
1571
+ this.peer.hub.systemCallSender.item(
1572
+ conn,
1573
+ this.peer.serializationFormat,
1574
+ this.id.localId,
1575
+ this._nextIndex,
1576
+ item
1577
+ );
1578
+ this._pendingSendTimes.push({ index: this._nextIndex, sentAtMs: Date.now() });
1579
+ if (this._pendingSendTimes.length > MAX_PENDING_SEND_TIMES)
1580
+ this._pendingSendTimes.splice(0, MAX_PENDING_SEND_TIMES >> 1);
1574
1581
  }
1582
+ this._nextIndex++;
1575
1583
  }
1576
- /**
1577
- * For local streams: resolves when the sender finishes pumping (after `toRef()` was called).
1578
- * Rejects if the source throws. Throws if `toRef()` hasn't been called yet.
1579
- */
1580
- get whenSent() {
1581
- if (!this._whenSent)
1582
- throw new Error("toRef() must be called before awaiting whenSent.");
1583
- return this._whenSent;
1584
+ /** Send a batch of items to the client. See {@link sendItem} for disconnect semantics. */
1585
+ sendBatch(items) {
1586
+ if (this._ended || items.length === 0) return;
1587
+ const conn = this.peer.connection;
1588
+ if (conn) {
1589
+ this.peer.hub.systemCallSender.batch(
1590
+ conn,
1591
+ this.peer.serializationFormat,
1592
+ this.id.localId,
1593
+ this._nextIndex,
1594
+ items
1595
+ );
1596
+ }
1597
+ this._nextIndex += items.length;
1584
1598
  }
1585
- /** Local sender backing this stream after `toRef()` has been called.
1586
- * `undefined` for remote streams or before `toRef()`. */
1587
- get sender() {
1588
- return this._sender;
1599
+ /** Signal stream completion to the client. */
1600
+ sendEnd(error) {
1601
+ if (this._ended) return;
1602
+ this._ended = true;
1603
+ const conn = this.peer.connection;
1604
+ if (!conn) return;
1605
+ const errorInfo = error ? toExceptionInfo(error) : { TypeRef: "", Message: "" };
1606
+ this.peer.hub.systemCallSender.end(
1607
+ conn,
1608
+ this.peer.serializationFormat,
1609
+ this.id.localId,
1610
+ this._nextIndex,
1611
+ errorInfo
1612
+ );
1589
1613
  }
1590
1614
  /**
1591
- * For local streams: bind to a peer, create and register an `RpcStreamSender`,
1592
- * start pumping items from the local source in the background, and return the
1593
- * stream reference.
1594
- *
1595
- * For binary peers the ref is a MessagePack-compatible object
1596
- * (`{ SerializedId, AckPeriod, AckAdvance, AllowReconnect }`);
1597
- * for text peers it is a comma-separated string.
1615
+ * Consume an AsyncIterable and send all items to the client.
1598
1616
  *
1599
- * Can only be called once per stream.
1617
+ * Matches .NET `RpcSharedStream<T>.OnRun` at
1618
+ * src/ActualLab.Rpc/Infrastructure/RpcSharedStream.cs.
1600
1619
  */
1601
- toRef(peer) {
1602
- if (this.kind !== RpcObjectKind.Local)
1603
- throw new Error("toRef() is only valid for local streams.");
1604
- if (this._sender) {
1605
- if (this._sender.peer !== peer)
1606
- throw new Error("toRef() was already called with a different peer.");
1607
- return this._toRefValue;
1620
+ async writeFrom(source) {
1621
+ await this._started;
1622
+ if (this._ended) return;
1623
+ const iterator = source[Symbol.asyncIterator]();
1624
+ this._iterator = iterator;
1625
+ const state = { iteratorDone: false };
1626
+ try {
1627
+ await this._run(iterator, state);
1628
+ } finally {
1629
+ if (state.iteratorDone && this._iterator === iterator) {
1630
+ this._iterator = null;
1631
+ }
1608
1632
  }
1609
- const isFactory = typeof this.localSource === "function";
1610
- const sender = new RpcStreamSender(
1611
- peer,
1612
- this.ackPeriod,
1613
- this.ackAdvance,
1614
- this.allowReconnect,
1615
- this.isRealTime,
1616
- this.canSkipTo,
1617
- isFactory,
1618
- this.bufferSize
1619
- );
1620
- peer.sharedObjects.register(sender);
1621
- this._sender = sender;
1622
- const source = isFactory ? this.localSource(sender.abortSignal) : this.localSource;
1623
- this._whenSent = sender.writeFrom(source);
1624
- this._toRefValue = peer.serializationFormat.isBinary ? {
1625
- SerializedId: [sender.id.hostId, sender.id.localId],
1626
- AckPeriod: sender.ackPeriod,
1627
- AckAdvance: sender.ackAdvance,
1628
- AllowReconnect: sender.allowReconnect
1629
- } : sender.toRef();
1630
- return this._toRefValue;
1631
1633
  }
1632
- /** Called by system call handler when a single item arrives ($sys.I). */
1633
- onItem(index, item) {
1634
- if (this._disposed || this._completed) return;
1635
- if (index > this._nextExpectedIndex) {
1636
- warnLog4?.log(`item index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1637
- if (!this.allowReconnect) {
1638
- this._complete(this._gapError(index));
1639
- return;
1634
+ /**
1635
+ * ACK-driven pump. Non-real-time streams apply normal backpressure at
1636
+ * `ackAdvance`; real-time streams may compact the already-buffered unsent
1637
+ * suffix to the latest buffered `canSkipTo` item after the consumer ACKs.
1638
+ * With `bufferSize > ackAdvance` the local ring buffer holds more than the
1639
+ * in-flight window — real-time mode uses the extra space to pre-buffer.
1640
+ */
1641
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition -- _ended changes across awaits */
1642
+ async _run(iterator, state) {
1643
+ const buffer = new RingBuffer(this.bufferSize + 1);
1644
+ const isRealTime = this.isRealTime;
1645
+ let bufferStart = 0;
1646
+ let isFullyBuffered = false;
1647
+ let whenMovedNext = null;
1648
+ const pending = /* @__PURE__ */ Symbol("pending");
1649
+ const readNext = async () => {
1650
+ const next = whenMovedNext ?? iterator.next();
1651
+ whenMovedNext = null;
1652
+ return await next;
1653
+ };
1654
+ const tryReadReady = async () => {
1655
+ whenMovedNext ??= iterator.next();
1656
+ return await Promise.race([
1657
+ whenMovedNext,
1658
+ Promise.resolve(pending)
1659
+ ]);
1660
+ };
1661
+ const fireBuffered = () => {
1662
+ try {
1663
+ this.onBuffered?.(buffer.count);
1664
+ } catch {
1665
+ }
1666
+ };
1667
+ const bufferNext = async (onlyIfReady, prefetchNext) => {
1668
+ let r;
1669
+ try {
1670
+ r = onlyIfReady ? await tryReadReady() : await readNext();
1671
+ if (r === pending)
1672
+ return false;
1673
+ whenMovedNext = null;
1674
+ if (this._ended) return false;
1675
+ if (r.done) {
1676
+ buffer.pushTail(_endItem);
1677
+ isFullyBuffered = true;
1678
+ state.iteratorDone = true;
1679
+ } else {
1680
+ buffer.pushTail({ kind: "value", value: r.value });
1681
+ if (prefetchNext)
1682
+ whenMovedNext = iterator.next();
1683
+ }
1684
+ fireBuffered();
1685
+ return true;
1686
+ } catch (e) {
1687
+ whenMovedNext = null;
1688
+ state.iteratorDone = true;
1689
+ isFullyBuffered = true;
1690
+ buffer.pushTail({
1691
+ kind: "error",
1692
+ error: e instanceof Error ? e : new Error(String(e))
1693
+ });
1694
+ fireBuffered();
1695
+ return true;
1640
1696
  }
1641
- this._sendAck(this._nextExpectedIndex, true);
1642
- return;
1643
- }
1644
- if (index < this._nextExpectedIndex) {
1645
- this._maybeSendAck(Math.min(index + 1, this._nextConsumedIndex));
1646
- return;
1647
- }
1648
- this._buffer.push(item);
1649
- this._nextExpectedIndex = index + 1;
1650
- this._notifyConsumer();
1651
- }
1652
- /** Called by system call handler when a batch arrives ($sys.B). */
1653
- onBatch(index, items) {
1654
- if (this._disposed || this._completed) return;
1655
- if (index > this._nextExpectedIndex) {
1656
- warnLog4?.log(`batch index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1657
- if (!this.allowReconnect) {
1658
- this._complete(this._gapError(index));
1659
- return;
1697
+ };
1698
+ while (true) {
1699
+ let ack = this._tryProcessAcks();
1700
+ if (!ack) {
1701
+ await this._waitAckReady();
1702
+ if (this._ended) return;
1703
+ ack = this._tryProcessAcks();
1704
+ if (!ack) {
1705
+ warnLog5?.log("Something is off: couldn't read an acknowledgement");
1706
+ return;
1707
+ }
1660
1708
  }
1661
- this._sendAck(this._nextExpectedIndex, true);
1662
- return;
1663
- }
1664
- if (index < this._nextExpectedIndex) {
1665
- this._maybeSendAck(Math.min(index + items.length, this._nextConsumedIndex));
1666
- return;
1667
- }
1668
- for (const item of items) this._buffer.push(item);
1669
- this._nextExpectedIndex = index + items.length;
1670
- this._notifyConsumer();
1671
- }
1672
- /** Called by system call handler when the stream ends ($sys.End). */
1673
- onEnd(index, error) {
1674
- if (this._disposed || this._completed) return;
1675
- if (index > this._nextExpectedIndex) {
1676
- warnLog4?.log(`end index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
1677
- if (!this.allowReconnect) {
1678
- this._complete(this._gapError(index));
1709
+ this._lastAckedIndex = ack.nextIndex;
1710
+ if (isRealTime && ack.mustReset && !isFullyBuffered) {
1711
+ buffer.clear();
1712
+ bufferStart = this._nextIndex;
1713
+ while (true) {
1714
+ if (this._ended) return;
1715
+ let item;
1716
+ let accepted = false;
1717
+ try {
1718
+ const r = await readNext();
1719
+ if (this._ended) return;
1720
+ if (r.done) {
1721
+ item = _endItem;
1722
+ isFullyBuffered = true;
1723
+ state.iteratorDone = true;
1724
+ } else if (this.canSkipTo(r.value)) {
1725
+ item = { kind: "value", value: r.value };
1726
+ accepted = true;
1727
+ } else {
1728
+ continue;
1729
+ }
1730
+ } catch (e) {
1731
+ state.iteratorDone = true;
1732
+ isFullyBuffered = true;
1733
+ item = {
1734
+ kind: "error",
1735
+ error: e instanceof Error ? e : new Error(String(e))
1736
+ };
1737
+ }
1738
+ buffer.pushTail(item);
1739
+ fireBuffered();
1740
+ if (accepted || isFullyBuffered) break;
1741
+ }
1742
+ }
1743
+ const shift = _clamp(ack.nextIndex - bufferStart, 0, buffer.count);
1744
+ if (shift > 0) {
1745
+ buffer.moveHead(shift);
1746
+ bufferStart += shift;
1747
+ }
1748
+ if (this._nextIndex < bufferStart) {
1749
+ if (!this._ended)
1750
+ this.sendEnd(new Error("Stream position unavailable."));
1679
1751
  return;
1680
1752
  }
1681
- this._sendAck(this._nextExpectedIndex, true);
1682
- return;
1683
- }
1684
- if (index < this._nextExpectedIndex) {
1685
- this._maybeSendAck(Math.min(index, this._nextConsumedIndex));
1686
- return;
1687
- }
1688
- this._complete(error);
1689
- }
1690
- // -- IRpcObject --
1691
- reconnect() {
1692
- if (this.kind === RpcObjectKind.Local) return;
1693
- if (this.allowReconnect) {
1694
- this._sendAck(this._nextExpectedIndex, true);
1695
- } else {
1696
- this.disconnect();
1697
- }
1698
- }
1699
- disconnect() {
1700
- if (this.kind === RpcObjectKind.Local) {
1701
- this._sender?.disconnect();
1702
- return;
1703
- }
1704
- if (this._completed) return;
1705
- this._completed = true;
1706
- this._completionError = new Error("Peer disconnected.");
1707
- this._notifyConsumer();
1708
- }
1709
- // -- AsyncIterable --
1710
- [Symbol.asyncIterator]() {
1711
- if (this.kind === RpcObjectKind.Local) {
1712
- const source = typeof this.localSource === "function" ? this.localSource(new AbortController().signal) : this.localSource;
1713
- return source[Symbol.asyncIterator]();
1714
- }
1715
- if (this._iterating)
1716
- throw new Error("RpcStream can only be iterated once.");
1717
- this._iterating = true;
1718
- return {
1719
- next: async () => {
1720
- if (!this._started) {
1721
- this._started = true;
1722
- this.peer.remoteObjects.register(this);
1723
- this._sendAck(0, true);
1724
- }
1725
- this._maybeSendAck(this._nextConsumedIndex);
1726
- for (; ; ) {
1727
- if (!this._buffer.isEmpty()) {
1728
- const value = this._buffer.shift();
1729
- this._nextConsumedIndex++;
1730
- return { value, done: false };
1753
+ let bufferIndex = this._nextIndex - bufferStart;
1754
+ const maxIndex = ack.nextIndex + this.ackAdvance;
1755
+ while (this._nextIndex < maxIndex) {
1756
+ if (this._ended) return;
1757
+ if (isRealTime) {
1758
+ while (buffer.hasRemainingCapacity && !isFullyBuffered && !this._hasPendingAck) {
1759
+ if (!await bufferNext(true, true))
1760
+ break;
1731
1761
  }
1732
- if (this._completed) {
1733
- this.dispose();
1734
- if (this._completionError) throw this._completionError;
1735
- return { value: void 0, done: true };
1762
+ if (bufferIndex >= buffer.count && !isFullyBuffered && buffer.hasRemainingCapacity && !this._hasPendingAck) {
1763
+ await bufferNext(false, true);
1736
1764
  }
1737
- this._consumerWaiting = new PromiseSource4();
1738
- await this._consumerWaiting;
1765
+ if (ack.nextIndex > 0) {
1766
+ const result = _compactBufferedUnsentSuffix(
1767
+ buffer,
1768
+ bufferIndex,
1769
+ this.canSkipTo
1770
+ );
1771
+ bufferIndex = result.newIndex;
1772
+ if (result.skipped > 0)
1773
+ this._skipCount += result.skipped;
1774
+ }
1775
+ if (this._hasPendingAck) break;
1739
1776
  }
1740
- },
1741
- return: () => {
1742
- if (this._started && !this._completed)
1743
- this._sendAckEnd();
1744
- this.dispose();
1745
- return Promise.resolve({ value: void 0, done: true });
1777
+ while (this._nextIndex < maxIndex && bufferIndex < buffer.count) {
1778
+ const item = buffer.get(bufferIndex++);
1779
+ if (item.kind === "value") {
1780
+ this.sendItem(item.value);
1781
+ } else if (item.kind === "end") {
1782
+ if (!this._ended) this.sendEnd();
1783
+ return;
1784
+ } else {
1785
+ if (!this._ended) this.sendEnd(item.error);
1786
+ return;
1787
+ }
1788
+ }
1789
+ if (this._nextIndex >= maxIndex)
1790
+ break;
1791
+ if (isFullyBuffered) break;
1792
+ if (!buffer.hasRemainingCapacity) break;
1793
+ if (!isRealTime)
1794
+ await bufferNext(false, false);
1746
1795
  }
1747
- };
1748
- }
1749
- dispose() {
1750
- if (this._disposed) return;
1751
- this._disposed = true;
1752
- this._completed = true;
1753
- if (this.kind === RpcObjectKind.Remote) {
1754
- this.peer.remoteObjects.unregister(this);
1755
1796
  }
1756
- this._notifyConsumer();
1757
1797
  }
1758
- // -- Private --
1759
- // Mirrors .NET RpcStream.CloseFromLock: acknowledge the end to the sender
1760
- // (so its RpcSharedStream is released) and unregister immediately.
1761
- _complete(error) {
1762
- if (this._completed) return;
1763
- this._completed = true;
1764
- this._completionError = error;
1765
- this._sendAckEnd();
1766
- if (this.kind === RpcObjectKind.Remote)
1767
- this.peer.remoteObjects.unregister(this);
1768
- this._notifyConsumer();
1798
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
1799
+ get _hasPendingAck() {
1800
+ return this._ackIndex >= 0;
1769
1801
  }
1770
- _gapError(index) {
1771
- return new Error(
1772
- `Stream gap at index ${index} (expected ${this._nextExpectedIndex}); reconnect not allowed`
1773
- );
1802
+ /** Consume the coalesced ACK state (or null if nothing is pending).
1803
+ * A reconnect's reset ACK may be coalesced with the regular consumption
1804
+ * ACKs the client sends right after it, so `mustReset` accumulates across
1805
+ * the whole batch — the newest ACK alone can't represent it. */
1806
+ _tryProcessAcks() {
1807
+ const nextIndex = this._ackIndex;
1808
+ if (nextIndex < 0)
1809
+ return null;
1810
+ const mustReset = this._ackMustReset;
1811
+ this._ackIndex = -1;
1812
+ this._ackMustReset = false;
1813
+ if (mustReset || this._nextIndex < nextIndex)
1814
+ this._nextIndex = nextIndex;
1815
+ try {
1816
+ this.onAckProcessed?.();
1817
+ } catch {
1818
+ }
1819
+ return { nextIndex, mustReset };
1774
1820
  }
1775
- _notifyConsumer() {
1776
- if (this._consumerWaiting !== null) {
1777
- this._consumerWaiting.resolve();
1778
- this._consumerWaiting = null;
1821
+ /** Wait until an ACK is pending (or `_ended`). */
1822
+ async _waitAckReady() {
1823
+ if (this._hasPendingAck || this._ended)
1824
+ return;
1825
+ this._whenAckReady = new PromiseSource3();
1826
+ await this._whenAckReady;
1827
+ this._whenAckReady = null;
1828
+ }
1829
+ // One RTT sample per ack: time from sending the newest acked item to the
1830
+ // ack's arrival (a slight upper bound — includes the server's ack cadence).
1831
+ _sampleRtt(ackNextIndex, mustReset) {
1832
+ if (mustReset) {
1833
+ this._pendingSendTimes.length = 0;
1834
+ return;
1835
+ }
1836
+ let sentAtMs = -1;
1837
+ while (this._pendingSendTimes.length > 0 && this._pendingSendTimes[0].index < ackNextIndex) {
1838
+ sentAtMs = this._pendingSendTimes[0].sentAtMs;
1839
+ this._pendingSendTimes.shift();
1779
1840
  }
1841
+ if (sentAtMs < 0)
1842
+ return;
1843
+ const nowMs = Date.now();
1844
+ const cutoffMs = nowMs - RTT_WINDOW_MS;
1845
+ while (this._rttSamples.length > 0 && this._rttSamples[0].atMs < cutoffMs)
1846
+ this._rttSamples.shift();
1847
+ this._rttSamples.push({ atMs: nowMs, rttMs: nowMs - sentAtMs });
1848
+ }
1849
+ // -- IRpcObject --
1850
+ reconnect() {
1780
1851
  }
1781
- _maybeSendAck(nextIndex) {
1782
- if (this.ackPeriod <= 0) return;
1783
- const threshold = this._ackSentUpTo + this.ackPeriod;
1784
- if (nextIndex >= threshold) {
1785
- this._sendAck(nextIndex, false);
1852
+ disconnect() {
1853
+ if (this._ended) return;
1854
+ this._ended = true;
1855
+ this._abortController.abort();
1856
+ if (!this._started.isCompleted) {
1857
+ this._started.resolve();
1786
1858
  }
1787
- }
1788
- static _emptyGuid = "00000000-0000-0000-0000-000000000000";
1789
- _sendAck(nextIndex, mustReset) {
1790
- this._ackSentUpTo = nextIndex;
1791
- const conn = this.peer.isConnected ? this.peer.connection : void 0;
1792
- if (conn) {
1793
- const hostId = mustReset ? this.id.hostId : _RpcStream._emptyGuid;
1794
- this.peer.hub.systemCallSender.ack(
1795
- conn,
1796
- this.peer.serializationFormat,
1797
- this.id.localId,
1798
- nextIndex,
1799
- hostId
1800
- );
1859
+ if (this._whenAckReady) {
1860
+ this._whenAckReady.resolve();
1861
+ this._whenAckReady = null;
1862
+ }
1863
+ this.peer.sharedObjects.unregister(this);
1864
+ const iterator = this._iterator;
1865
+ if (!iterator) return;
1866
+ if (!this.sourceUsesAbortSignal) {
1867
+ this._iterator = null;
1868
+ this._forceCloseIterator(iterator);
1869
+ return;
1801
1870
  }
1871
+ setTimeout(() => {
1872
+ if (this._iterator === iterator) {
1873
+ this._iterator = null;
1874
+ this._forceCloseIterator(iterator);
1875
+ }
1876
+ }, RpcStream.disconnectGracePeriodMs);
1802
1877
  }
1803
- _sendAckEnd() {
1804
- const conn = this.peer.isConnected ? this.peer.connection : void 0;
1805
- if (conn) {
1806
- this.peer.hub.systemCallSender.ackEnd(
1807
- conn,
1808
- this.peer.serializationFormat,
1809
- this.id.localId,
1810
- this.id.hostId
1811
- );
1878
+ _forceCloseIterator(iterator) {
1879
+ try {
1880
+ const ret = iterator.return?.(void 0);
1881
+ if (ret) void ret.catch(() => {
1882
+ });
1883
+ } catch {
1812
1884
  }
1813
1885
  }
1814
1886
  };
1815
- function _isStreamSource(value) {
1816
- if (typeof value === "function") return true;
1817
- return value !== null && typeof value === "object" && Symbol.asyncIterator in value;
1887
+ var _endItem = { kind: "end" };
1888
+ function _clamp(value, min, max) {
1889
+ return value < min ? min : value > max ? max : value;
1818
1890
  }
1819
- function resolveStreamRefs(value, peer) {
1820
- if (value === null || value === void 0) return value;
1821
- if (typeof value === "string") {
1822
- const ref = parseStreamRef(value);
1823
- if (ref !== null) {
1824
- return new RpcStream(ref, peer);
1825
- }
1826
- return value;
1827
- }
1828
- if (Array.isArray(value)) {
1829
- for (let i = 0; i < value.length; i++) {
1830
- value[i] = resolveStreamRefs(value[i], peer);
1831
- }
1832
- return value;
1833
- }
1834
- if (typeof value === "object") {
1835
- if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer)
1836
- return value;
1837
- for (const key of Object.keys(value)) {
1838
- value[key] = resolveStreamRefs(
1839
- value[key],
1840
- peer
1841
- );
1842
- }
1843
- return value;
1891
+ function _compactBufferedUnsentSuffix(buffer, firstUnsentIndex, canSkipTo) {
1892
+ let skipToIndex = -1;
1893
+ for (let i = firstUnsentIndex; i < buffer.count; i++) {
1894
+ const item = buffer.get(i);
1895
+ if (item.kind === "value" && canSkipTo(item.value))
1896
+ skipToIndex = i;
1844
1897
  }
1845
- return value;
1898
+ if (skipToIndex <= firstUnsentIndex)
1899
+ return { newIndex: firstUnsentIndex, skipped: 0 };
1900
+ const cutFrom = firstUnsentIndex;
1901
+ const cutTo = skipToIndex;
1902
+ const items = buffer.toArray();
1903
+ buffer.clear();
1904
+ for (let i = 0; i < cutFrom; i++)
1905
+ buffer.pushTail(items[i]);
1906
+ for (let i = cutTo; i < items.length; i++)
1907
+ buffer.pushTail(items[i]);
1908
+ return { newIndex: firstUnsentIndex, skipped: skipToIndex - firstUnsentIndex };
1846
1909
  }
1847
1910
 
1848
- // src/base64.ts
1849
- function base64Encode(bytes) {
1850
- const chunkSize = 32768;
1851
- let binary = "";
1852
- for (let i = 0; i < bytes.length; i += chunkSize) {
1853
- binary += String.fromCharCode(
1854
- ...bytes.subarray(i, Math.min(bytes.length, i + chunkSize))
1855
- );
1911
+ // src/rpc-stream.ts
1912
+ var { warnLog: warnLog6 } = getLogs("RpcStream");
1913
+ var DEFAULT_ACK_PERIOD2 = 30;
1914
+ var DEFAULT_ACK_ADVANCE2 = 61;
1915
+ function parseStreamRef(value) {
1916
+ if (typeof value === "string") {
1917
+ const parts = value.split(",");
1918
+ if (parts.length < 4 || parts.length > 6) return null;
1919
+ const hostId = parts[0];
1920
+ const localId = parseInt(parts[1], 10);
1921
+ const ackPeriod = parseInt(parts[2], 10);
1922
+ const ackAdvance = parseInt(parts[3], 10);
1923
+ if (isNaN(localId) || isNaN(ackPeriod) || isNaN(ackAdvance))
1924
+ return null;
1925
+ const allowReconnect = parts.length < 5 || parts[4] !== "0";
1926
+ const isRealTime = parts.length >= 6 && parts[5] === "1";
1927
+ return { hostId, localId, ackPeriod, ackAdvance, allowReconnect, isRealTime };
1856
1928
  }
1857
- return btoa(binary);
1858
- }
1859
- function base64Decode(encoded) {
1860
- const binary = atob(encoded);
1861
- const out = new Uint8Array(binary.length);
1862
- for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
1863
- return out;
1864
- }
1865
-
1866
- // src/rpc-system-call-handler.ts
1867
- var { debugLog, warnLog: warnLog5 } = getLogs("RpcSystemCallHandler");
1868
- var RpcSystemCallHandler = class {
1869
- handle(message, args, peer) {
1870
- const method = message.Method;
1871
- const relatedId = message.RelatedId ?? 0;
1872
- debugLog?.log(`'${peer.ref}': handle ${method}#${relatedId}`);
1873
- switch (method) {
1874
- case RpcSystemCalls.ok: {
1875
- const call = peer.outboundCalls.get(relatedId);
1876
- if (call !== void 0) {
1877
- if (args.length > 1) {
1878
- peer.outboundCalls.remove(relatedId);
1879
- call.result.reject(polymorphicPayloadError(method, args.length, 1));
1880
- break;
1881
- }
1882
- call.completedStage |= RpcCallStage.ResultReady;
1883
- if (call.removeOnOk) {
1884
- peer.outboundCalls.remove(relatedId);
1885
- }
1886
- call.result.resolve(args[0]);
1887
- }
1888
- break;
1889
- }
1890
- case RpcSystemCalls.reconnect: {
1891
- this._handleReconnect(relatedId, args, peer);
1892
- break;
1893
- }
1894
- case RpcSystemCalls.error: {
1895
- const call = peer.outboundCalls.get(relatedId);
1896
- if (call !== void 0) {
1897
- call.completedStage |= RpcCallStage.ResultReady;
1898
- if (call.removeOnOk) {
1899
- peer.outboundCalls.remove(relatedId);
1900
- }
1901
- const errorInfo = args[0];
1902
- const msg = errorInfo?.Message ?? errorInfo?.message ?? "RPC error";
1903
- const typeName = extractTypeName(errorInfo?.TypeRef ?? errorInfo?.typeRef);
1904
- if (typeName === "ActualLab.Rpc.RpcRerouteException")
1905
- warnLog5?.log("Got RpcRerouteException from remote peer:", msg);
1906
- call.result.reject(new RpcError(msg, typeName));
1907
- }
1908
- break;
1909
- }
1910
- case RpcSystemCalls.cancel: {
1911
- const call = peer.inboundCalls.remove(relatedId);
1912
- call?.cancel();
1913
- break;
1914
- }
1915
- case RpcSystemCalls.keepAlive: {
1916
- peer.notifyKeepAliveReceived();
1917
- const keepAliveIds = args[0];
1918
- if (Array.isArray(keepAliveIds) && keepAliveIds.length > 0 && peer.connection !== void 0) {
1919
- const unknownIds = [];
1920
- for (const id of keepAliveIds)
1921
- if (peer.sharedObjects.get(id) === void 0)
1922
- unknownIds.push(id);
1923
- if (unknownIds.length > 0)
1924
- peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, unknownIds);
1925
- }
1926
- break;
1927
- }
1928
- case RpcSystemCalls.item: {
1929
- const stream = peer.remoteObjects.get(relatedId);
1930
- if (!stream)
1931
- debugLog?.log(`$sys.I: no stream for relatedId=${relatedId}`);
1932
- else if (args.length > 2)
1933
- stream.onEnd(args[0], polymorphicPayloadError(method, args.length, 2));
1934
- else
1935
- stream.onItem(
1936
- args[0],
1937
- resolveStreamRefs(args[1], peer)
1938
- );
1939
- break;
1940
- }
1941
- case RpcSystemCalls.batch: {
1942
- const stream = peer.remoteObjects.get(relatedId);
1943
- if (!stream)
1944
- debugLog?.log(`$sys.B: no stream for relatedId=${relatedId}`);
1945
- else if (args.length > 2)
1946
- stream.onEnd(args[0], polymorphicPayloadError(method, args.length, 2));
1947
- else if (Array.isArray(args[1])) {
1948
- const items = args[1];
1949
- for (let i = 0; i < items.length; i++)
1950
- items[i] = resolveStreamRefs(items[i], peer);
1951
- stream.onBatch(args[0], items);
1952
- }
1953
- break;
1954
- }
1955
- case RpcSystemCalls.end: {
1956
- const stream = peer.remoteObjects.get(relatedId);
1957
- if (!stream) {
1958
- debugLog?.log(`$sys.End: no stream for relatedId=${relatedId}`);
1959
- } else {
1960
- const errorInfo = args[1];
1961
- const typeName = extractTypeName(errorInfo?.TypeRef ?? errorInfo?.typeRef);
1962
- const msg = errorInfo?.Message ?? errorInfo?.message;
1963
- let error = null;
1964
- if (typeName || msg) {
1965
- error = new Error(msg ?? typeName ?? "RPC stream error");
1966
- if (typeName)
1967
- error.name = typeName;
1968
- }
1969
- stream.onEnd(args[0], error);
1970
- }
1971
- break;
1972
- }
1973
- case RpcSystemCalls.ack: {
1974
- const sender = peer.sharedObjects.get(relatedId);
1975
- if (sender)
1976
- sender.onAck(args[0], args[1]);
1977
- else if (peer.connection !== void 0)
1978
- peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1979
- break;
1980
- }
1981
- case RpcSystemCalls.ackEnd: {
1982
- const sender = peer.sharedObjects.get(relatedId);
1983
- if (sender)
1984
- sender.onAckEnd(args[0]);
1985
- else if (peer.connection !== void 0)
1986
- peer.hub.systemCallSender.disconnect(peer.connection, peer.serializationFormat, [relatedId]);
1987
- break;
1988
- }
1989
- case RpcSystemCalls.disconnect: {
1990
- const ids = args[0];
1991
- if (Array.isArray(ids)) {
1992
- for (const id of ids) {
1993
- const remoteObj = peer.remoteObjects.get(id);
1994
- if (remoteObj && typeof remoteObj.disconnect === "function") {
1995
- remoteObj.disconnect();
1996
- }
1997
- const sharedObj = peer.sharedObjects.get(id);
1998
- if (sharedObj && typeof sharedObj.disconnect === "function") {
1999
- sharedObj.disconnect();
2000
- }
2001
- }
2002
- }
2003
- break;
2004
- }
1929
+ if (typeof value === "object" && value !== null) {
1930
+ const obj = value;
1931
+ const serializedId = obj.SerializedId;
1932
+ if (!Array.isArray(serializedId) || serializedId.length < 2)
1933
+ return null;
1934
+ const rawHostId = serializedId[0];
1935
+ const hostId = String(rawHostId);
1936
+ const localId = Number(serializedId[1]);
1937
+ const ackPeriod = Number(obj.AckPeriod ?? 256);
1938
+ const ackAdvance = Number(obj.AckAdvance ?? 128);
1939
+ const allowReconnect = obj.AllowReconnect !== false;
1940
+ const isRealTime = obj.IsRealTime === true;
1941
+ return { hostId, localId, ackPeriod, ackAdvance, allowReconnect, isRealTime };
1942
+ }
1943
+ return null;
1944
+ }
1945
+ var RpcStream = class _RpcStream {
1946
+ /**
1947
+ * Grace period (ms) given to a factory-form local source to honor its
1948
+ * AbortSignal and exit naturally before the sender force-closes the
1949
+ * iterator via `iterator.return()`.
1950
+ *
1951
+ * Only applies to sources provided as `(abortSignal) => AsyncIterable<T>`.
1952
+ * Plain `AsyncIterable<T>` sources are force-closed immediately on disconnect.
1953
+ */
1954
+ static disconnectGracePeriodMs = 100;
1955
+ id;
1956
+ kind;
1957
+ allowReconnect;
1958
+ isRealTime;
1959
+ canSkipTo;
1960
+ ackPeriod;
1961
+ ackAdvance;
1962
+ /**
1963
+ * Local-only ring buffer capacity hint for the sender. Undefined means
1964
+ * "use ackAdvance as the buffer size". Not part of the wire ref.
1965
+ */
1966
+ bufferSize;
1967
+ // Local (origin) mode
1968
+ localSource;
1969
+ // Remote (target) mode
1970
+ peer;
1971
+ // Local-only state
1972
+ _sender;
1973
+ _toRefValue;
1974
+ _whenSent;
1975
+ // Remote-only state
1976
+ _buffer = new Denque();
1977
+ _nextExpectedIndex = 0;
1978
+ _consumerWaiting = null;
1979
+ _completed = false;
1980
+ _completionError = null;
1981
+ _disposed = false;
1982
+ _started = false;
1983
+ _iterating = false;
1984
+ _ackSentUpTo = 0;
1985
+ _nextConsumedIndex = 0;
1986
+ constructor(sourceOrRef, optionsOrPeer) {
1987
+ if (_isStreamSource(sourceOrRef)) {
1988
+ const options = optionsOrPeer ?? {};
1989
+ this.kind = RpcObjectKind.Local;
1990
+ this.localSource = sourceOrRef;
1991
+ this.id = { hostId: "", localId: 0 };
1992
+ this.allowReconnect = options.allowReconnect ?? true;
1993
+ this.isRealTime = options.isRealTime ?? false;
1994
+ this.canSkipTo = options.canSkipTo ?? (() => true);
1995
+ this.ackPeriod = options.ackPeriod ?? DEFAULT_ACK_PERIOD2;
1996
+ this.ackAdvance = options.ackAdvance ?? DEFAULT_ACK_ADVANCE2;
1997
+ this.bufferSize = options.bufferSize;
1998
+ } else {
1999
+ const ref = sourceOrRef;
2000
+ const peer = optionsOrPeer;
2001
+ this.kind = RpcObjectKind.Remote;
2002
+ this.id = { hostId: ref.hostId, localId: ref.localId };
2003
+ this.peer = peer;
2004
+ this.allowReconnect = ref.allowReconnect;
2005
+ this.isRealTime = ref.isRealTime;
2006
+ this.canSkipTo = () => true;
2007
+ this.ackPeriod = ref.ackPeriod;
2008
+ this.ackAdvance = ref.ackAdvance;
2005
2009
  }
2006
2010
  }
2007
2011
  /**
2008
- * Server-side handler for the `$sys.Reconnect` call. Parses the peer's
2009
- * report of in-flight call IDs (grouped by stage, IncreasingSeqCompressor-
2010
- * encoded, base64-wrapped for JSON transport), computes the subset this
2011
- * peer does not recognize, and replies with a compressed byte[] of the
2012
- * unknown IDs wrapped in `$sys.Ok`.
2012
+ * For local streams: resolves when the sender finishes pumping (after `toRef()` was called).
2013
+ * Rejects if the source throws. Throws if `toRef()` hasn't been called yet.
2014
+ */
2015
+ get whenSent() {
2016
+ if (!this._whenSent)
2017
+ throw new Error("toRef() must be called before awaiting whenSent.");
2018
+ return this._whenSent;
2019
+ }
2020
+ /** Local sender backing this stream after `toRef()` has been called.
2021
+ * `undefined` for remote streams or before `toRef()`. */
2022
+ get sender() {
2023
+ return this._sender;
2024
+ }
2025
+ /**
2026
+ * For local streams: bind to a peer, create and register an `RpcStreamSender`,
2027
+ * start pumping items from the local source in the background, and return the
2028
+ * stream reference.
2013
2029
  *
2014
- * @param relatedId The outbound call ID the client used for this
2015
- * `$sys.Reconnect` invocation — we send `$sys.Ok` back
2016
- * with the same `RelatedId`.
2030
+ * For binary peers the ref is a MessagePack-compatible object
2031
+ * (`{ SerializedId, AckPeriod, AckAdvance, AllowReconnect }`);
2032
+ * for text peers it is a comma-separated string.
2033
+ *
2034
+ * Can only be called once per stream.
2017
2035
  */
2018
- _handleReconnect(relatedId, args, peer) {
2019
- if (peer.connection === void 0) return;
2020
- const handshakeIndex = args[0];
2021
- if (typeof handshakeIndex === "number" && handshakeIndex !== peer.ownHandshakeIndex) {
2022
- peer.hub.systemCallSender.error(
2023
- peer.connection,
2024
- peer.serializationFormat,
2025
- relatedId,
2026
- new Error(
2027
- `TooLateToReconnect: own handshake index ${peer.ownHandshakeIndex} != ${handshakeIndex}`
2028
- )
2029
- );
2036
+ toRef(peer) {
2037
+ if (this.kind !== RpcObjectKind.Local)
2038
+ throw new Error("toRef() is only valid for local streams.");
2039
+ if (this._sender) {
2040
+ if (this._sender.peer !== peer)
2041
+ throw new Error("toRef() was already called with a different peer.");
2042
+ return this._toRefValue;
2043
+ }
2044
+ const isFactory = typeof this.localSource === "function";
2045
+ const sender = new RpcStreamSender(
2046
+ peer,
2047
+ this.ackPeriod,
2048
+ this.ackAdvance,
2049
+ this.allowReconnect,
2050
+ this.isRealTime,
2051
+ this.canSkipTo,
2052
+ isFactory,
2053
+ this.bufferSize
2054
+ );
2055
+ peer.sharedObjects.register(sender);
2056
+ this._sender = sender;
2057
+ const source = isFactory ? this.localSource(sender.abortSignal) : this.localSource;
2058
+ this._whenSent = sender.writeFrom(source);
2059
+ this._toRefValue = peer.serializationFormat.isBinary ? {
2060
+ SerializedId: [sender.id.hostId, sender.id.localId],
2061
+ AckPeriod: sender.ackPeriod,
2062
+ AckAdvance: sender.ackAdvance,
2063
+ AllowReconnect: sender.allowReconnect
2064
+ } : sender.toRef();
2065
+ return this._toRefValue;
2066
+ }
2067
+ /** Called by system call handler when a single item arrives ($sys.I). */
2068
+ onItem(index, item) {
2069
+ if (this._disposed || this._completed) return;
2070
+ if (!_isValidIndex(index)) {
2071
+ this._failProtocol(`invalid $sys.I index: ${String(index)}`);
2030
2072
  return;
2031
2073
  }
2032
- let unknownIds = [];
2033
- try {
2034
- const stagesRaw = args[1];
2035
- if (stagesRaw && typeof stagesRaw === "object" && !Array.isArray(stagesRaw)) {
2036
- const unknownSet = /* @__PURE__ */ new Set();
2037
- for (const [stageKey, rawValue] of Object.entries(stagesRaw)) {
2038
- const bytes = _toBytes(rawValue);
2039
- if (bytes === null) continue;
2040
- const completedStage = parseInt(stageKey, 10) || 0;
2041
- const callIds = IncreasingSeqCompressor.deserialize(bytes);
2042
- for (const callId of callIds) {
2043
- const call = peer.inboundCalls.get(callId);
2044
- if (call === void 0)
2045
- unknownSet.add(callId);
2046
- else if (completedStage < 1)
2047
- call.resendResult();
2048
- }
2049
- }
2050
- unknownIds = [...unknownSet].sort((a, b) => a - b);
2074
+ if (index > this._nextExpectedIndex) {
2075
+ warnLog6?.log(`item index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
2076
+ if (!this.allowReconnect) {
2077
+ this._complete(this._gapError(index));
2078
+ return;
2051
2079
  }
2052
- } catch {
2080
+ this._sendAck(this._nextExpectedIndex, true);
2081
+ return;
2053
2082
  }
2054
- const responseBytes = IncreasingSeqCompressor.serialize(unknownIds);
2055
- const responseValue = peer.serializationFormat.isBinary ? responseBytes : base64Encode(responseBytes);
2056
- peer.hub.systemCallSender.ok(peer.connection, peer.serializationFormat, relatedId, responseValue);
2083
+ if (index < this._nextExpectedIndex) {
2084
+ this._maybeSendAck(Math.min(index + 1, this._nextConsumedIndex));
2085
+ return;
2086
+ }
2087
+ if (this._isBeyondAckWindow(index + 1)) {
2088
+ this._failProtocol(this._ackWindowError(index + 1));
2089
+ return;
2090
+ }
2091
+ this._buffer.push(item);
2092
+ this._nextExpectedIndex = index + 1;
2093
+ this._notifyConsumer();
2057
2094
  }
2058
- };
2059
- function polymorphicPayloadError(method, got, expected) {
2060
- return new Error(
2061
- `${method}: expected ${expected} value(s), got ${got} \u2014 polymorphic payloads are not supported by the TS client`
2062
- );
2063
- }
2064
- function extractTypeName(typeRef) {
2065
- if (typeof typeRef === "string") {
2066
- if (typeRef.length === 0)
2067
- return void 0;
2068
- const commaIdx = typeRef.indexOf(",");
2069
- return commaIdx >= 0 ? typeRef.slice(0, commaIdx) : typeRef;
2095
+ /** Called by system call handler when a batch arrives ($sys.B). */
2096
+ onBatch(index, items) {
2097
+ if (this._disposed || this._completed) return;
2098
+ if (!_isValidIndex(index) || !_isValidIndex(items.length)) {
2099
+ this._failProtocol(`invalid $sys.B index/length: ${String(index)}/${String(items.length)}`);
2100
+ return;
2101
+ }
2102
+ if (index > this._nextExpectedIndex) {
2103
+ warnLog6?.log(`batch index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
2104
+ if (!this.allowReconnect) {
2105
+ this._complete(this._gapError(index));
2106
+ return;
2107
+ }
2108
+ this._sendAck(this._nextExpectedIndex, true);
2109
+ return;
2110
+ }
2111
+ if (index < this._nextExpectedIndex) {
2112
+ this._maybeSendAck(Math.min(index + items.length, this._nextConsumedIndex));
2113
+ return;
2114
+ }
2115
+ if (this._isBeyondAckWindow(index + items.length)) {
2116
+ this._failProtocol(this._ackWindowError(index + items.length));
2117
+ return;
2118
+ }
2119
+ for (const item of items) this._buffer.push(item);
2120
+ this._nextExpectedIndex = index + items.length;
2121
+ this._notifyConsumer();
2070
2122
  }
2071
- if (typeRef !== null && typeof typeRef === "object") {
2072
- const t = typeRef;
2073
- const aqn = t.AssemblyQualifiedName ?? t.assemblyQualifiedName;
2074
- if (typeof aqn === "string" && aqn.length > 0) {
2075
- const commaIdx = aqn.indexOf(",");
2076
- return commaIdx >= 0 ? aqn.slice(0, commaIdx) : aqn;
2123
+ /** Called by system call handler when the stream ends ($sys.End). */
2124
+ onEnd(index, error) {
2125
+ if (this._disposed || this._completed) return;
2126
+ if (!_isValidIndex(index)) {
2127
+ this._failProtocol(`invalid $sys.End index: ${String(index)}`);
2128
+ return;
2077
2129
  }
2078
- return t.TypeName ?? t.typeName;
2130
+ if (index > this._nextExpectedIndex) {
2131
+ warnLog6?.log(`end index gap: localId=${this.id.localId}, expected=${this._nextExpectedIndex}, received=${index}`);
2132
+ if (!this.allowReconnect) {
2133
+ this._complete(this._gapError(index));
2134
+ return;
2135
+ }
2136
+ this._sendAck(this._nextExpectedIndex, true);
2137
+ return;
2138
+ }
2139
+ if (index < this._nextExpectedIndex) {
2140
+ this._maybeSendAck(Math.min(index, this._nextConsumedIndex));
2141
+ return;
2142
+ }
2143
+ this._complete(error);
2079
2144
  }
2080
- return void 0;
2081
- }
2082
- function _toBytes(value) {
2083
- if (value instanceof Uint8Array) return value;
2084
- if (typeof value === "string") {
2085
- try {
2086
- return base64Decode(value);
2087
- } catch {
2088
- return null;
2145
+ // -- IRpcObject --
2146
+ reconnect() {
2147
+ if (this.kind === RpcObjectKind.Local) return;
2148
+ if (this.allowReconnect) {
2149
+ this._sendAck(this._nextExpectedIndex, true);
2150
+ } else {
2151
+ this.disconnect();
2089
2152
  }
2090
2153
  }
2091
- return null;
2092
- }
2093
-
2094
- // src/rpc-system-call-sender.ts
2095
- var { errorLog } = getLogs("RpcSystemCallSender");
2096
- var RpcSystemCallSender = class _RpcSystemCallSender {
2097
- // Reusable envelopes — one per hot-path method.
2098
- _itemEnv = {
2099
- Method: RpcSystemCalls.item,
2100
- RelatedId: 0
2101
- };
2102
- _batchEnv = {
2103
- Method: RpcSystemCalls.batch,
2104
- RelatedId: 0
2105
- };
2106
- _endEnv = {
2107
- Method: RpcSystemCalls.end,
2108
- RelatedId: 0
2109
- };
2110
- _ackEnv = {
2111
- Method: RpcSystemCalls.ack,
2112
- RelatedId: 0
2113
- };
2114
- _ackEndEnv = {
2115
- Method: RpcSystemCalls.ackEnd,
2116
- RelatedId: 0
2117
- };
2118
- // Reusable arg arrays.
2119
- _itemArgs = [0, null];
2120
- _batchArgs = [0, null];
2121
- _endArgs = [0, { TypeRef: "", Message: "" }];
2122
- _ackArgs = [0, ""];
2123
- _ackEndArgs = [""];
2124
- static NO_ERROR = Object.freeze({ TypeRef: "", Message: "" });
2125
- registry;
2126
- _send(conn, format, envelope, args) {
2127
- const wireData = format.serializeMessage(
2128
- envelope,
2129
- args,
2130
- conn.encoder,
2131
- this.registry
2132
- );
2133
- if (typeof wireData === "string") {
2134
- conn.send(wireData);
2135
- } else {
2136
- conn.sendBinary(wireData);
2154
+ disconnect() {
2155
+ if (this.kind === RpcObjectKind.Local) {
2156
+ this._sender?.disconnect();
2157
+ return;
2137
2158
  }
2159
+ if (this._completed) return;
2160
+ this._completed = true;
2161
+ this._completionError = new Error("Peer disconnected.");
2162
+ this._notifyConsumer();
2138
2163
  }
2139
- handshake(conn, format, peerId, hubId, index) {
2140
- const handshakeArg = format.isBinary ? [peerId, null, hubId, 2, index] : {
2141
- RemotePeerId: peerId,
2142
- RemoteApiVersionSet: null,
2143
- RemoteHubId: hubId,
2144
- ProtocolVersion: 2,
2145
- Index: index
2164
+ // -- AsyncIterable --
2165
+ [Symbol.asyncIterator]() {
2166
+ if (this.kind === RpcObjectKind.Local) {
2167
+ const source = typeof this.localSource === "function" ? this.localSource(new AbortController().signal) : this.localSource;
2168
+ return source[Symbol.asyncIterator]();
2169
+ }
2170
+ if (this._iterating)
2171
+ throw new Error("RpcStream can only be iterated once.");
2172
+ this._iterating = true;
2173
+ return {
2174
+ next: async () => {
2175
+ if (!this._started) {
2176
+ this._started = true;
2177
+ this.peer.remoteObjects.register(this);
2178
+ this._sendAck(0, true);
2179
+ }
2180
+ this._maybeSendAck(this._nextConsumedIndex);
2181
+ for (; ; ) {
2182
+ if (!this._buffer.isEmpty()) {
2183
+ const value = this._buffer.shift();
2184
+ this._nextConsumedIndex++;
2185
+ return { value, done: false };
2186
+ }
2187
+ if (this._completed) {
2188
+ this.dispose();
2189
+ if (this._completionError) throw this._completionError;
2190
+ return { value: void 0, done: true };
2191
+ }
2192
+ this._consumerWaiting = new PromiseSource4();
2193
+ await this._consumerWaiting;
2194
+ }
2195
+ },
2196
+ return: () => {
2197
+ if (this._started && !this._completed)
2198
+ this._sendAckEnd();
2199
+ this.dispose();
2200
+ return Promise.resolve({ value: void 0, done: true });
2201
+ }
2146
2202
  };
2147
- this._send(conn, format, { Method: RpcSystemCalls.handshake }, [
2148
- handshakeArg
2149
- ]);
2150
2203
  }
2151
- ok(conn, format, relatedId, result) {
2152
- try {
2153
- this._send(
2154
- conn,
2155
- format,
2156
- { Method: RpcSystemCalls.ok, RelatedId: relatedId },
2157
- [result]
2158
- );
2159
- } catch (e) {
2160
- errorLog?.log(`Failed to send Ok response for call #${relatedId}`, e);
2161
- this.error(conn, format, relatedId, e);
2204
+ dispose() {
2205
+ if (this._disposed) return;
2206
+ this._disposed = true;
2207
+ this._completed = true;
2208
+ if (this.kind === RpcObjectKind.Remote) {
2209
+ this.peer.remoteObjects.unregister(this);
2162
2210
  }
2211
+ this._notifyConsumer();
2163
2212
  }
2164
- error(conn, format, relatedId, error) {
2165
- this._send(
2166
- conn,
2167
- format,
2168
- { Method: RpcSystemCalls.error, RelatedId: relatedId },
2169
- [toExceptionInfo(error)]
2170
- );
2213
+ // -- Private --
2214
+ // Mirrors .NET RpcStream.CloseFromLock: acknowledge the end to the sender
2215
+ // (so its RpcSharedStream is released) and unregister immediately.
2216
+ _complete(error) {
2217
+ if (this._completed) return;
2218
+ this._completed = true;
2219
+ this._completionError = error;
2220
+ this._sendAckEnd();
2221
+ if (this.kind === RpcObjectKind.Remote)
2222
+ this.peer.remoteObjects.unregister(this);
2223
+ this._notifyConsumer();
2171
2224
  }
2172
- cancel(conn, format, relatedId) {
2173
- this._send(conn, format, {
2174
- Method: RpcSystemCalls.cancel,
2175
- RelatedId: relatedId
2176
- });
2225
+ // Terminates the stream the way the non-reconnectable gap path does: the
2226
+ // consumer sees the error and the sender gets $sys.AckEnd, so nothing is
2227
+ // left pumping into a stream we no longer read.
2228
+ _failProtocol(message) {
2229
+ warnLog6?.log(`localId=${this.id.localId}: ${message}`);
2230
+ this._complete(new Error(message));
2177
2231
  }
2178
- keepAlive(conn, format, activeCallIds) {
2179
- this._send(conn, format, { Method: RpcSystemCalls.keepAlive }, [
2180
- activeCallIds
2181
- ]);
2232
+ // The remote advertised `ackAdvance` as the furthest it may run ahead of our
2233
+ // last acknowledged index. The sending side is the only place that window
2234
+ // was ever enforced, which left a flooding remote free to grow our buffer
2235
+ // without bound (I6) — so we enforce our half of the contract here.
2236
+ _isBeyondAckWindow(nextExpectedIndex) {
2237
+ return this.ackAdvance > 0 && nextExpectedIndex > this._ackSentUpTo + this.ackAdvance;
2182
2238
  }
2183
- // Tells the remote peer the listed shared-object ids are gone on our side,
2184
- // so its matching remote objects (e.g. RpcStream consumers) fail fast
2185
- // instead of hanging. Mirrors .NET RpcSystemCallSender.Disconnect.
2186
- disconnect(conn, format, localIds) {
2187
- this._send(conn, format, { Method: RpcSystemCalls.disconnect }, [
2188
- localIds
2189
- ]);
2239
+ _ackWindowError(nextExpectedIndex) {
2240
+ return `Stream ran past its ack window: reached ${nextExpectedIndex}, acked ${this._ackSentUpTo}, ackAdvance ${this.ackAdvance}`;
2190
2241
  }
2191
- item(conn, format, localId, index, item) {
2192
- this._itemEnv.RelatedId = localId;
2193
- this._itemArgs[0] = index;
2194
- this._itemArgs[1] = item;
2195
- this._send(conn, format, this._itemEnv, this._itemArgs);
2196
- this._itemArgs[1] = null;
2242
+ _gapError(index) {
2243
+ return new Error(
2244
+ `Stream gap at index ${index} (expected ${this._nextExpectedIndex}); reconnect not allowed`
2245
+ );
2197
2246
  }
2198
- batch(conn, format, localId, index, items) {
2199
- this._batchEnv.RelatedId = localId;
2200
- this._batchArgs[0] = index;
2201
- this._batchArgs[1] = items;
2202
- this._send(conn, format, this._batchEnv, this._batchArgs);
2203
- this._batchArgs[1] = null;
2247
+ _notifyConsumer() {
2248
+ if (this._consumerWaiting !== null) {
2249
+ this._consumerWaiting.resolve();
2250
+ this._consumerWaiting = null;
2251
+ }
2204
2252
  }
2205
- end(conn, format, localId, index, error = _RpcSystemCallSender.NO_ERROR) {
2206
- this._endEnv.RelatedId = localId;
2207
- this._endArgs[0] = index;
2208
- this._endArgs[1] = error;
2209
- this._send(conn, format, this._endEnv, this._endArgs);
2253
+ _maybeSendAck(nextIndex) {
2254
+ if (this.ackPeriod <= 0) return;
2255
+ const threshold = this._ackSentUpTo + this.ackPeriod;
2256
+ if (nextIndex >= threshold) {
2257
+ this._sendAck(nextIndex, false);
2258
+ }
2210
2259
  }
2211
- ack(conn, format, localId, nextIndex, hostId) {
2212
- this._ackEnv.RelatedId = localId;
2213
- this._ackArgs[0] = nextIndex;
2214
- this._ackArgs[1] = hostId;
2215
- this._send(conn, format, this._ackEnv, this._ackArgs);
2260
+ static _emptyGuid = "00000000-0000-0000-0000-000000000000";
2261
+ _sendAck(nextIndex, mustReset) {
2262
+ this._ackSentUpTo = nextIndex;
2263
+ const conn = this.peer.isConnected ? this.peer.connection : void 0;
2264
+ if (conn) {
2265
+ const hostId = mustReset ? this.id.hostId : _RpcStream._emptyGuid;
2266
+ this.peer.hub.systemCallSender.ack(
2267
+ conn,
2268
+ this.peer.serializationFormat,
2269
+ this.id.localId,
2270
+ nextIndex,
2271
+ hostId
2272
+ );
2273
+ }
2216
2274
  }
2217
- ackEnd(conn, format, localId, hostId) {
2218
- this._ackEndEnv.RelatedId = localId;
2219
- this._ackEndArgs[0] = hostId;
2220
- this._send(conn, format, this._ackEndEnv, this._ackEndArgs);
2275
+ _sendAckEnd() {
2276
+ const conn = this.peer.isConnected ? this.peer.connection : void 0;
2277
+ if (conn) {
2278
+ this.peer.hub.systemCallSender.ackEnd(
2279
+ conn,
2280
+ this.peer.serializationFormat,
2281
+ this.id.localId,
2282
+ this.id.hostId
2283
+ );
2284
+ }
2221
2285
  }
2222
2286
  };
2287
+ function _isValidIndex(index) {
2288
+ return Number.isSafeInteger(index) && index >= 0;
2289
+ }
2290
+ function _isStreamSource(value) {
2291
+ if (typeof value === "function") return true;
2292
+ return value !== null && typeof value === "object" && Symbol.asyncIterator in value;
2293
+ }
2294
+ function toRpcStream(value, peer) {
2295
+ const ref = parseStreamRef(value);
2296
+ if (ref === null)
2297
+ return null;
2298
+ return new RpcStream(ref, peer);
2299
+ }
2223
2300
 
2224
2301
  // src/rpc-remote-object-tracker.ts
2225
2302
  var RpcRemoteObjectTracker = class {
@@ -2451,33 +2528,68 @@ function isMemoryPackKey(key) {
2451
2528
  return key.startsWith("mempack");
2452
2529
  }
2453
2530
 
2531
+ // src/reconnect-proof.ts
2532
+ var textEncoder2 = new TextEncoder();
2533
+ function isReconnectProofSupported() {
2534
+ return typeof crypto !== "undefined" && crypto.subtle !== void 0;
2535
+ }
2536
+ async function computeReconnectProof(secret, clientId, counterText) {
2537
+ const key = await crypto.subtle.importKey(
2538
+ "raw",
2539
+ textEncoder2.encode(secret),
2540
+ { name: "HMAC", hash: "SHA-256" },
2541
+ false,
2542
+ ["sign"]
2543
+ );
2544
+ const signature = await crypto.subtle.sign(
2545
+ "HMAC",
2546
+ key,
2547
+ textEncoder2.encode(`${clientId}
2548
+ ${counterText}`)
2549
+ );
2550
+ return base64UrlEncode(new Uint8Array(signature));
2551
+ }
2552
+
2454
2553
  // src/rpc-peer.ts
2455
2554
  function yieldToEventLoop() {
2456
2555
  return new Promise((resolve) => setTimeout(resolve, 0));
2457
2556
  }
2458
2557
  var RPC_CLOSE_CODE_UNSUPPORTED_FORMAT = 4001;
2459
- var { debugLog: debugLog2, infoLog, warnLog: warnLog6, errorLog: errorLog2 } = getLogs("RpcPeer");
2460
- var defaultConnectionUrlResolver = (peer) => {
2558
+ var { debugLog: debugLog2, infoLog, warnLog: warnLog7, errorLog: errorLog2 } = getLogs("RpcPeer");
2559
+ var defaultConnectionUrlResolver = async (peer) => {
2461
2560
  const formatKey = peer.serializationFormat.key;
2561
+ const proof = await peer.computeReconnectProof();
2462
2562
  try {
2463
2563
  const url = new URL(peer.ref);
2464
2564
  url.searchParams.set("clientId", peer.clientId);
2465
2565
  url.searchParams.set("f", formatKey);
2566
+ if (proof !== void 0) {
2567
+ url.searchParams.set("c", proof.counter);
2568
+ url.searchParams.set("p", proof.proof);
2569
+ }
2466
2570
  return url.toString();
2467
2571
  } catch {
2468
2572
  const sep = peer.ref.includes("?") ? "&" : "?";
2469
- return peer.ref + sep + `clientId=${peer.clientId}&f=${formatKey}`;
2573
+ let url = peer.ref + sep + `clientId=${peer.clientId}&f=${formatKey}`;
2574
+ if (proof !== void 0)
2575
+ url += `&c=${proof.counter}&p=${proof.proof}`;
2576
+ return url;
2470
2577
  }
2471
2578
  };
2579
+ var RedactedParameterNames = ["session", "clientId", "p"];
2472
2580
  var sanitizeUrl = (url) => {
2473
2581
  try {
2474
2582
  const parsed = new URL(url);
2475
- if (!parsed.searchParams.has("session"))
2476
- return url;
2477
- parsed.searchParams.set("session", "<redacted>");
2478
- return parsed.toString();
2583
+ let isRedacted = false;
2584
+ for (const name of RedactedParameterNames) {
2585
+ if (!parsed.searchParams.has(name))
2586
+ continue;
2587
+ parsed.searchParams.set(name, "<redacted>");
2588
+ isRedacted = true;
2589
+ }
2590
+ return isRedacted ? parsed.toString() : url;
2479
2591
  } catch {
2480
- return url.replace(/([?&]session=)[^&]*/i, "$1<redacted>");
2592
+ return url.replace(/([?&](?:session|clientId|p)=)[^&]*/gi, "$1<redacted>");
2481
2593
  }
2482
2594
  };
2483
2595
  function parseFormatFromUrl(url) {
@@ -2726,21 +2838,26 @@ var RpcPeer = class {
2726
2838
  /** Override in subclasses to handle the remote peer's handshake response. */
2727
2839
  _onHandshakeReceived(_handshake) {
2728
2840
  }
2841
+ // The try covers dispatch too, not just deserialization: a handler that throws
2842
+ // here would otherwise escape into the transport's onmessage - dropping every
2843
+ // remaining message of the frame, or killing a Node process outright.
2729
2844
  _handleMessage(received) {
2730
- let message;
2731
- let args;
2732
2845
  try {
2733
- if (received.kind === "binary") {
2734
- message = received.message;
2735
- args = received.args;
2736
- } else {
2737
- const parsed = deserializeMessage(received.raw);
2738
- message = parsed.message;
2739
- args = parsed.args;
2740
- }
2846
+ this._processMessage(received);
2741
2847
  } catch (e) {
2742
2848
  errorLog2?.log(`'${this.ref}': Failed to process inbound message:`, received, e);
2743
- return;
2849
+ }
2850
+ }
2851
+ _processMessage(received) {
2852
+ let message;
2853
+ let args;
2854
+ if (received.kind === "binary") {
2855
+ message = received.message;
2856
+ args = received.args;
2857
+ } else {
2858
+ const parsed = deserializeMessage(received.raw);
2859
+ message = parsed.message;
2860
+ args = parsed.args;
2744
2861
  }
2745
2862
  const method = message.Method ?? "";
2746
2863
  debugLog2?.log(`'${this.ref}': <- ${method}#${message.RelatedId ?? 0}`);
@@ -2752,12 +2869,14 @@ var RpcPeer = class {
2752
2869
  RemotePeerId: raw[0],
2753
2870
  RemoteHubId: raw[2],
2754
2871
  ProtocolVersion: raw[3],
2755
- Index: raw[4]
2872
+ Index: raw[4],
2873
+ Secret: raw[5]
2756
2874
  } : {
2757
2875
  RemotePeerId: obj.RemotePeerId ?? obj.remotePeerId,
2758
2876
  RemoteHubId: obj.RemoteHubId ?? obj.remoteHubId,
2759
2877
  ProtocolVersion: obj.ProtocolVersion ?? obj.protocolVersion,
2760
- Index: obj.Index ?? obj.index
2878
+ Index: obj.Index ?? obj.index,
2879
+ Secret: obj.Secret ?? obj.secret
2761
2880
  };
2762
2881
  this._onHandshakeReceived(handshake);
2763
2882
  }
@@ -2871,7 +2990,7 @@ var RpcPeer = class {
2871
2990
  this._keepAliveWatchdog = setTimeout(() => {
2872
2991
  this._keepAliveWatchdog = void 0;
2873
2992
  if (this._connection === void 0) return;
2874
- warnLog6?.log(
2993
+ warnLog7?.log(
2875
2994
  `'${this.ref}': no keep-alive for ${this.keepAliveTimeoutMs}ms \u2014 force-closing connection`
2876
2995
  );
2877
2996
  this._connection.close();
@@ -2919,6 +3038,16 @@ var RpcClientPeer = class extends RpcPeer {
2919
3038
  _lastRemotePeerId;
2920
3039
  _pendingHandshake;
2921
3040
  _reconnectsAt = 0;
3041
+ /** Server-issued reconnect secret, from the most recent handshake that
3042
+ * carried one. In-memory only — never localStorage/sessionStorage/cookie.
3043
+ * A tab reload mints a new `clientId`, so a persisted secret would be
3044
+ * useless anyway. */
3045
+ _reconnectSecret;
3046
+ /** Incremented once per *connect attempt*, in `computeReconnectProof()`. A
3047
+ * failed attempt still burns its value: the server may have advanced its
3048
+ * own counter at the gate before the connection died, and reusing the
3049
+ * value would then be rejected forever. */
3050
+ _reconnectCounter = 0;
2922
3051
  /** Base64url-encoded peer ID — matches .NET's RpcClientPeer.ClientId (Guid.ToBase64Url). */
2923
3052
  clientId;
2924
3053
  /** Builds the WebSocket URL for this peer. Replace to customize URL construction. */
@@ -2999,6 +3128,28 @@ var RpcClientPeer = class extends RpcPeer {
2999
3128
  start() {
3000
3129
  void this.run();
3001
3130
  }
3131
+ /** Builds the `c` / `p` connect-URL parameters proving possession of the
3132
+ * server-issued reconnect secret, or `undefined` when there is no secret
3133
+ * yet or WebCrypto is unavailable. Consumes one counter value per proof
3134
+ * produced, so call it exactly once per connect attempt. */
3135
+ async computeReconnectProof() {
3136
+ const secret = this._reconnectSecret;
3137
+ if (secret === void 0)
3138
+ return void 0;
3139
+ if (!isReconnectProofSupported()) {
3140
+ warnReconnectProofUnavailable(this.ref, "crypto.subtle is unavailable (insecure context?)");
3141
+ return void 0;
3142
+ }
3143
+ const counter = (this._reconnectCounter + 1).toString();
3144
+ try {
3145
+ const proof = await computeReconnectProof(secret, this.clientId, counter);
3146
+ this._reconnectCounter++;
3147
+ return { counter, proof };
3148
+ } catch (e) {
3149
+ warnReconnectProofUnavailable(this.ref, `HMAC-SHA256 failed: ${String(e)}`);
3150
+ return void 0;
3151
+ }
3152
+ }
3002
3153
  /** The reconnect loop itself. Exposed as `protected` so subclasses (and
3003
3154
  * `start()`) can drive it; public callers should use {@link start}. */
3004
3155
  run() {
@@ -3012,7 +3163,7 @@ var RpcClientPeer = class extends RpcPeer {
3012
3163
  this._setConnectionState(1 /* Connecting */);
3013
3164
  const retryDelay = this.hub.reconnectDelayer.getDelay(this._tryIndex, this._stopController.signal);
3014
3165
  if (retryDelay.isLimitExceeded) {
3015
- warnLog6?.log(`'${this.ref}': Reconnect limit exceeded - shutting down`);
3166
+ warnLog7?.log(`'${this.ref}': Reconnect limit exceeded - shutting down`);
3016
3167
  this._disposed = true;
3017
3168
  break;
3018
3169
  }
@@ -3061,7 +3212,7 @@ var RpcClientPeer = class extends RpcPeer {
3061
3212
  );
3062
3213
  } catch (e) {
3063
3214
  if (e instanceof TimeoutError) {
3064
- warnLog6?.log(`'${this.ref}': Connect timed out after ${connectTimeoutMs}ms`);
3215
+ warnLog7?.log(`'${this.ref}': Connect timed out after ${connectTimeoutMs}ms`);
3065
3216
  conn.close();
3066
3217
  }
3067
3218
  throw e;
@@ -3076,7 +3227,7 @@ var RpcClientPeer = class extends RpcPeer {
3076
3227
  ++this._ownHandshakeIndex
3077
3228
  );
3078
3229
  } catch (e) {
3079
- warnLog6?.log(`'${this.ref}': Failed to send handshake`, e);
3230
+ warnLog7?.log(`'${this.ref}': Failed to send handshake`, e);
3080
3231
  throw e;
3081
3232
  }
3082
3233
  const handshakeTimeoutMs = this.handshakeTimeoutMs;
@@ -3089,7 +3240,7 @@ var RpcClientPeer = class extends RpcPeer {
3089
3240
  );
3090
3241
  } catch (e) {
3091
3242
  if (e instanceof TimeoutError) {
3092
- warnLog6?.log(`'${this.ref}': Handshake timed out after ${handshakeTimeoutMs}ms`);
3243
+ warnLog7?.log(`'${this.ref}': Handshake timed out after ${handshakeTimeoutMs}ms`);
3093
3244
  conn.close();
3094
3245
  }
3095
3246
  throw e;
@@ -3097,6 +3248,8 @@ var RpcClientPeer = class extends RpcPeer {
3097
3248
  this._pendingHandshake = void 0;
3098
3249
  }
3099
3250
  this._remoteHandshakeIndex = remoteHandshake.Index ?? 0;
3251
+ if (remoteHandshake.Secret)
3252
+ this._reconnectSecret = remoteHandshake.Secret;
3100
3253
  const isPeerChanged = this._detectPeerChange(remoteHandshake);
3101
3254
  infoLog?.log(`'${this.ref}': Handshake succeeded, peerChanged=${isPeerChanged}`);
3102
3255
  this._setConnectionState(3 /* Connected */);
@@ -3109,7 +3262,7 @@ var RpcClientPeer = class extends RpcPeer {
3109
3262
  this._setConnectionState(0 /* Disconnected */);
3110
3263
  if (this._disposed) break;
3111
3264
  if (lastCloseCode === RPC_CLOSE_CODE_UNSUPPORTED_FORMAT) {
3112
- warnLog6?.log(`'${this.ref}': Unsupported serialization format - shutting down`);
3265
+ warnLog7?.log(`'${this.ref}': Unsupported serialization format - shutting down`);
3113
3266
  this.unsupportedFormat.trigger({ reason: lastCloseReason });
3114
3267
  this._disposed = true;
3115
3268
  break;
@@ -3355,8 +3508,16 @@ function guidToBase64Url(uuid) {
3355
3508
  bytes[7] = parseInt(hex.slice(12, 14), 16);
3356
3509
  for (let i = 8; i < 16; i++)
3357
3510
  bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
3358
- const binary = String.fromCharCode(...bytes);
3359
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
3511
+ return base64UrlEncode(bytes);
3512
+ }
3513
+ var isReconnectProofUnavailableWarned = false;
3514
+ function warnReconnectProofUnavailable(ref, reason) {
3515
+ if (isReconnectProofUnavailableWarned)
3516
+ return;
3517
+ isReconnectProofUnavailableWarned = true;
3518
+ warnLog7?.log(
3519
+ `'${ref}': ${reason} - connecting without a reconnect proof. This fails if the server requires one (RequireReconnectProof).`
3520
+ );
3360
3521
  }
3361
3522
  function _toBytes2(value) {
3362
3523
  if (value instanceof Uint8Array) return value;
@@ -3435,7 +3596,7 @@ var RpcClientPeerReconnectDelayer = class extends RetryDelayer {
3435
3596
  };
3436
3597
 
3437
3598
  // src/rpc-service-host.ts
3438
- var { warnLog: warnLog7 } = getLogs("RpcServiceHost");
3599
+ var { warnLog: warnLog8 } = getLogs("RpcServiceHost");
3439
3600
  var RpcServiceHost = class {
3440
3601
  _methods = /* @__PURE__ */ new Map();
3441
3602
  register(def, impl, receiver = impl) {
@@ -3452,7 +3613,7 @@ var RpcServiceHost = class {
3452
3613
  async dispatch(wireMethod, args, context) {
3453
3614
  const entry = this._methods.get(wireMethod);
3454
3615
  if (entry === void 0) {
3455
- warnLog7?.log(`Method not found: ${wireMethod}`);
3616
+ warnLog8?.log(`Method not found: ${wireMethod}`);
3456
3617
  throw new Error(`Method not found: ${wireMethod}`);
3457
3618
  }
3458
3619
  if (context !== void 0 && entry.def.callTypeId !== 0) {
@@ -3960,7 +4121,7 @@ function hashLongWithSeed(input, length, seed) {
3960
4121
  }
3961
4122
  return hashLong(input, length, secret, secret.length);
3962
4123
  }
3963
- var textEncoder2 = new TextEncoder();
4124
+ var textEncoder3 = new TextEncoder();
3964
4125
  function xxh3_64(input, seed = 0n) {
3965
4126
  const len = input.length;
3966
4127
  if (len <= XXH3_MIDSIZE_MAX) {
@@ -3969,10 +4130,10 @@ function xxh3_64(input, seed = 0n) {
3969
4130
  return hashLongWithSeed(input, len, u64(seed));
3970
4131
  }
3971
4132
  function xxh3_64str(input, seed = 0n) {
3972
- return xxh3_64(textEncoder2.encode(input), seed);
4133
+ return xxh3_64(textEncoder3.encode(input), seed);
3973
4134
  }
3974
4135
  function computeMethodHash(methodName) {
3975
- const utf8 = textEncoder2.encode(methodName);
4136
+ const utf8 = textEncoder3.encode(methodName);
3976
4137
  const prefixValue = Math.imul(67211, utf8.length) >>> 0;
3977
4138
  const prefixed = new Uint8Array(4 + utf8.length);
3978
4139
  prefixed[0] = prefixValue & 255;
@@ -3985,7 +4146,7 @@ function computeMethodHash(methodName) {
3985
4146
  }
3986
4147
 
3987
4148
  // src/rpc-method-registry.ts
3988
- var { warnLog: warnLog8 } = getLogs("RpcMethodRegistry");
4149
+ var { warnLog: warnLog9 } = getLogs("RpcMethodRegistry");
3989
4150
  var RpcMethodRegistry = class {
3990
4151
  _nameToHash = /* @__PURE__ */ new Map();
3991
4152
  _hashToName = /* @__PURE__ */ new Map();
@@ -3995,7 +4156,7 @@ var RpcMethodRegistry = class {
3995
4156
  const hash = computeMethodHash(methodName);
3996
4157
  const existing = this._hashToName.get(hash);
3997
4158
  if (existing !== void 0 && existing !== methodName) {
3998
- warnLog8?.log(`hash collision: "${methodName}" and "${existing}" both hash to ${hash}`);
4159
+ warnLog9?.log(`hash collision: "${methodName}" and "${existing}" both hash to ${hash}`);
3999
4160
  return;
4000
4161
  }
4001
4162
  this._nameToHash.set(methodName, hash);
@@ -4028,7 +4189,7 @@ var RpcMethodRegistry = class {
4028
4189
  };
4029
4190
 
4030
4191
  // src/rpc-hub.ts
4031
- var { warnLog: warnLog9 } = getLogs("RpcHub");
4192
+ var { warnLog: warnLog10 } = getLogs("RpcHub");
4032
4193
  var RpcHub = class {
4033
4194
  hubId;
4034
4195
  peers = /* @__PURE__ */ new Map();
@@ -4093,7 +4254,7 @@ var RpcHub = class {
4093
4254
  if (peer !== void 0) {
4094
4255
  peer.close();
4095
4256
  this.peers.delete(ref);
4096
- warnLog9?.log(`'${ref}': peer is removed from RpcHub`);
4257
+ warnLog10?.log(`'${ref}': peer is removed from RpcHub`);
4097
4258
  }
4098
4259
  }
4099
4260
  /** Get or create a peer by ref. If the peer does not exist:
@@ -4211,12 +4372,12 @@ var RpcHub = class {
4211
4372
  const callArgs = args.slice(0, methodDef.argCount);
4212
4373
  const outboundCall = peer.call(wireName, callArgs);
4213
4374
  const result = await outboundCall.result;
4214
- const ref = parseStreamRef(result);
4215
- if (!ref)
4375
+ const stream = toRpcStream(result, peer);
4376
+ if (!stream)
4216
4377
  throw new Error(
4217
4378
  `Expected stream reference, got: ${JSON.stringify(result)}`
4218
4379
  );
4219
- return new RpcStream(ref, peer);
4380
+ return stream;
4220
4381
  };
4221
4382
  }
4222
4383
  const callOptions = methodDef.callTypeId !== 0 || mode !== RpcRemoteExecutionMode.Default || methodDef.timeouts !== void 0 ? { callTypeId: methodDef.callTypeId, remoteExecutionMode: mode, timeouts: methodDef.timeouts } : void 0;
@@ -4225,8 +4386,7 @@ var RpcHub = class {
4225
4386
  throw new Error("Outbound call failed: not connected and AwaitForConnection is not set.");
4226
4387
  const callArgs = args.slice(0, methodDef.argCount);
4227
4388
  const outboundCall = peer.call(wireName, callArgs, callOptions);
4228
- const result = await outboundCall.result;
4229
- return resolveStreamRefs(result, peer);
4389
+ return await outboundCall.result;
4230
4390
  };
4231
4391
  }
4232
4392
  // Private methods
@@ -4583,6 +4743,7 @@ export {
4583
4743
  RpcWebSocketConnection,
4584
4744
  WebSocketState,
4585
4745
  computeMethodHash,
4746
+ computeReconnectProof,
4586
4747
  createBinaryEncoder,
4587
4748
  createMessageChannelPair,
4588
4749
  createRpcClient,
@@ -4593,15 +4754,16 @@ export {
4593
4754
  deserializeBinaryMessage,
4594
4755
  deserializeCompactBinaryMessage,
4595
4756
  deserializeMessage,
4757
+ genericErrorFilter,
4596
4758
  getLogs,
4597
4759
  getMethodsMeta,
4598
4760
  getServiceMeta,
4599
4761
  getStateDescription,
4600
4762
  isConnected,
4763
+ isReconnectProofSupported,
4601
4764
  likelyConnected,
4602
4765
  parseStreamRef,
4603
4766
  readPolymorphismMarker,
4604
- resolveStreamRefs,
4605
4767
  rpcMethod,
4606
4768
  rpcService,
4607
4769
  serializeBinaryFrame,
@@ -4613,6 +4775,7 @@ export {
4613
4775
  splitCompactBinaryFrame,
4614
4776
  splitFrame,
4615
4777
  toExceptionInfo,
4778
+ toRpcStream,
4616
4779
  wireMethodName,
4617
4780
  xxh3_64,
4618
4781
  xxh3_64str