@actuallab/rpc 12.3.31 → 12.3.49

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.cjs CHANGED
@@ -33,12 +33,14 @@ __export(index_exports, {
33
33
  ARG_DELIMITER: () => ARG_DELIMITER,
34
34
  ENVELOPE_DELIMITER: () => ENVELOPE_DELIMITER,
35
35
  FRAME_DELIMITER: () => FRAME_DELIMITER,
36
+ HANDSHAKE_TIMEOUT_MS: () => HANDSHAKE_TIMEOUT_MS,
36
37
  IncreasingSeqCompressor: () => IncreasingSeqCompressor,
37
38
  RPC_CLOSE_CODE_UNSUPPORTED_FORMAT: () => RPC_CLOSE_CODE_UNSUPPORTED_FORMAT,
38
39
  RpcCallStage: () => RpcCallStage,
39
40
  RpcCallTypeId: () => RpcCallTypeId,
40
41
  RpcClientPeer: () => RpcClientPeer,
41
42
  RpcClientPeerReconnectDelayer: () => RpcClientPeerReconnectDelayer,
43
+ RpcConnectionState: () => RpcConnectionState,
42
44
  RpcHub: () => RpcHub,
43
45
  RpcInboundCall: () => RpcInboundCall,
44
46
  RpcInboundCallTracker: () => RpcInboundCallTracker,
@@ -51,7 +53,7 @@ __export(index_exports, {
51
53
  RpcOutboundCall: () => RpcOutboundCall,
52
54
  RpcOutboundCallTracker: () => RpcOutboundCallTracker,
53
55
  RpcPeer: () => RpcPeer,
54
- RpcPeerConnectionKind: () => RpcPeerConnectionKind,
56
+ RpcPeerRefBuilder: () => RpcPeerRefBuilder,
55
57
  RpcPeerStateKind: () => RpcPeerStateKind,
56
58
  RpcPeerStateMonitor: () => RpcPeerStateMonitor,
57
59
  RpcRemoteExecutionMode: () => RpcRemoteExecutionMode,
@@ -1058,7 +1060,7 @@ var RpcStreamSender = class _RpcStreamSender {
1058
1060
  if (conn) {
1059
1061
  this.peer.hub.systemCallSender.item(
1060
1062
  conn,
1061
- this.peer.format,
1063
+ this.peer.serializationFormat,
1062
1064
  this.id.localId,
1063
1065
  this._nextIndex,
1064
1066
  item
@@ -1073,7 +1075,7 @@ var RpcStreamSender = class _RpcStreamSender {
1073
1075
  if (conn) {
1074
1076
  this.peer.hub.systemCallSender.batch(
1075
1077
  conn,
1076
- this.peer.format,
1078
+ this.peer.serializationFormat,
1077
1079
  this.id.localId,
1078
1080
  this._nextIndex,
1079
1081
  items
@@ -1090,7 +1092,7 @@ var RpcStreamSender = class _RpcStreamSender {
1090
1092
  const errorInfo = error ? { TypeRef: "System.Exception", Message: error.message } : { TypeRef: "", Message: "" };
1091
1093
  this.peer.hub.systemCallSender.end(
1092
1094
  conn,
1093
- this.peer.format,
1095
+ this.peer.serializationFormat,
1094
1096
  this.id.localId,
1095
1097
  this._nextIndex,
1096
1098
  errorInfo
@@ -1545,7 +1547,7 @@ var RpcStream = class _RpcStream {
1545
1547
  this._sender = sender;
1546
1548
  const source = isFactory ? this.localSource(sender.abortSignal) : this.localSource;
1547
1549
  this._whenSent = sender.writeFrom(source);
1548
- this._toRefValue = peer.format.isBinary ? {
1550
+ this._toRefValue = peer.serializationFormat.isBinary ? {
1549
1551
  SerializedId: [sender.id.hostId, sender.id.localId],
1550
1552
  AckPeriod: sender.ackPeriod,
1551
1553
  AckAdvance: sender.ackAdvance,
@@ -1695,7 +1697,7 @@ var RpcStream = class _RpcStream {
1695
1697
  const hostId = mustReset ? this.id.hostId : _RpcStream._emptyGuid;
1696
1698
  this.peer.hub.systemCallSender.ack(
1697
1699
  conn,
1698
- this.peer.format,
1700
+ this.peer.serializationFormat,
1699
1701
  this.id.localId,
1700
1702
  nextIndex,
1701
1703
  hostId
@@ -1707,7 +1709,7 @@ var RpcStream = class _RpcStream {
1707
1709
  if (conn) {
1708
1710
  this.peer.hub.systemCallSender.ackEnd(
1709
1711
  conn,
1710
- this.peer.format,
1712
+ this.peer.serializationFormat,
1711
1713
  this.id.localId,
1712
1714
  this.id.hostId
1713
1715
  );
@@ -1754,9 +1756,8 @@ function base64Encode(bytes) {
1754
1756
  const chunkSize = 32768;
1755
1757
  let binary = "";
1756
1758
  for (let i = 0; i < bytes.length; i += chunkSize) {
1757
- binary += String.fromCharCode.apply(
1758
- null,
1759
- bytes.subarray(i, Math.min(bytes.length, i + chunkSize))
1759
+ binary += String.fromCharCode(
1760
+ ...bytes.subarray(i, Math.min(bytes.length, i + chunkSize))
1760
1761
  );
1761
1762
  }
1762
1763
  return btoa(binary);
@@ -1777,11 +1778,11 @@ var RpcSystemCallHandler = class {
1777
1778
  debugLog?.log(`'${peer.ref}': handle ${method}#${relatedId}`);
1778
1779
  switch (method) {
1779
1780
  case RpcSystemCalls.ok: {
1780
- const call = peer.outbound.get(relatedId);
1781
+ const call = peer.outboundCalls.get(relatedId);
1781
1782
  if (call !== void 0) {
1782
1783
  call.completedStage |= RpcCallStage.ResultReady;
1783
1784
  if (call.removeOnOk) {
1784
- peer.outbound.remove(relatedId);
1785
+ peer.outboundCalls.remove(relatedId);
1785
1786
  }
1786
1787
  call.result.resolve(args[0]);
1787
1788
  }
@@ -1792,7 +1793,7 @@ var RpcSystemCallHandler = class {
1792
1793
  break;
1793
1794
  }
1794
1795
  case RpcSystemCalls.error: {
1795
- const call = peer.outbound.remove(relatedId);
1796
+ const call = peer.outboundCalls.remove(relatedId);
1796
1797
  if (call !== void 0) {
1797
1798
  const errorInfo = args[0];
1798
1799
  const msg = errorInfo?.Message ?? errorInfo?.message ?? "RPC error";
@@ -1807,10 +1808,11 @@ var RpcSystemCallHandler = class {
1807
1808
  break;
1808
1809
  }
1809
1810
  case RpcSystemCalls.cancel: {
1810
- peer.inbound.remove(relatedId);
1811
+ peer.inboundCalls.remove(relatedId);
1811
1812
  break;
1812
1813
  }
1813
1814
  case RpcSystemCalls.keepAlive: {
1815
+ peer.notifyKeepAliveReceived();
1814
1816
  break;
1815
1817
  }
1816
1818
  case RpcSystemCalls.item: {
@@ -1901,7 +1903,7 @@ var RpcSystemCallHandler = class {
1901
1903
  if (bytes === null) continue;
1902
1904
  const callIds = IncreasingSeqCompressor.deserialize(bytes);
1903
1905
  for (const callId of callIds) {
1904
- if (peer.inbound.get(callId) === void 0) {
1906
+ if (peer.inboundCalls.get(callId) === void 0) {
1905
1907
  unknownSet.add(callId);
1906
1908
  }
1907
1909
  }
@@ -1911,8 +1913,8 @@ var RpcSystemCallHandler = class {
1911
1913
  } catch {
1912
1914
  }
1913
1915
  const responseBytes = IncreasingSeqCompressor.serialize(unknownIds);
1914
- const responseValue = peer.format.isBinary ? responseBytes : base64Encode(responseBytes);
1915
- peer.hub.systemCallSender.ok(peer.connection, peer.format, relatedId, responseValue);
1916
+ const responseValue = peer.serializationFormat.isBinary ? responseBytes : base64Encode(responseBytes);
1917
+ peer.hub.systemCallSender.ok(peer.connection, peer.serializationFormat, relatedId, responseValue);
1916
1918
  }
1917
1919
  };
1918
1920
  function _toBytes(value) {
@@ -2107,16 +2109,7 @@ var RpcSharedObjectTracker = class {
2107
2109
  };
2108
2110
 
2109
2111
  // src/rpc-peer.ts
2110
- var import_core8 = require("@actuallab/core");
2111
-
2112
- // src/rpc-client-peer-reconnect-delayer.ts
2113
2112
  var import_core7 = require("@actuallab/core");
2114
- var RpcClientPeerReconnectDelayer = class extends import_core7.RetryDelayer {
2115
- constructor() {
2116
- super();
2117
- this.delays = import_core7.RetryDelaySeq.exp(1e3, 6e4);
2118
- }
2119
- };
2120
2113
 
2121
2114
  // src/rpc-serialization-format.ts
2122
2115
  var import_msgpack4 = require("@msgpack/msgpack");
@@ -2277,55 +2270,106 @@ var RpcSerializationFormatResolver = class _RpcSerializationFormatResolver {
2277
2270
 
2278
2271
  // src/rpc-peer.ts
2279
2272
  var RPC_CLOSE_CODE_UNSUPPORTED_FORMAT = 4001;
2273
+ var HANDSHAKE_TIMEOUT_MS = 1e4;
2274
+ var KEEP_ALIVE_PERIOD_MS = 15e3;
2275
+ var KEEP_ALIVE_TIMEOUT_MS = 55e3;
2280
2276
  var { debugLog: debugLog2, infoLog, warnLog: warnLog6, errorLog: errorLog2 } = getLogs("RpcPeer");
2281
2277
  var defaultConnectionUrlResolver = (peer) => {
2282
- const sep = peer.ref.includes("?") ? "&" : "?";
2283
- return peer.ref + sep + `clientId=${peer.clientId}&f=${peer.serializationFormat}`;
2278
+ const formatKey = peer.serializationFormat.key;
2279
+ try {
2280
+ const url = new URL(peer.ref);
2281
+ url.searchParams.set("clientId", peer.clientId);
2282
+ url.searchParams.set("f", formatKey);
2283
+ return url.toString();
2284
+ } catch {
2285
+ const sep = peer.ref.includes("?") ? "&" : "?";
2286
+ return peer.ref + sep + `clientId=${peer.clientId}&f=${formatKey}`;
2287
+ }
2284
2288
  };
2289
+ function parseFormatFromUrl(url) {
2290
+ try {
2291
+ const f = new URL(url).searchParams.get("f");
2292
+ return f ?? void 0;
2293
+ } catch {
2294
+ return void 0;
2295
+ }
2296
+ }
2285
2297
  var RpcPeer = class {
2298
+ hub;
2286
2299
  /** Routing/addressing key — used as key in hub.peers (URL for clients, "server://{uuid}" for servers). */
2287
2300
  ref;
2288
2301
  /** Auto-generated GUID — used in handshakes only. */
2289
2302
  id = crypto.randomUUID();
2290
- _hub;
2291
- _connection;
2292
- outbound = new RpcOutboundCallTracker();
2293
- inbound = new RpcInboundCallTracker();
2303
+ outboundCalls = new RpcOutboundCallTracker();
2304
+ inboundCalls = new RpcInboundCallTracker();
2294
2305
  remoteObjects = new RpcRemoteObjectTracker();
2295
2306
  sharedObjects = new RpcSharedObjectTracker();
2296
- connected = new import_core8.EventHandlerSet();
2297
- disconnected = new import_core8.EventHandlerSet();
2307
+ /** Fired on every state transition with the new value. Initial state is
2308
+ * `Disconnected`; `Connecting` is entered when the peer starts an
2309
+ * attempt (the reconnect delay is part of this phase); `Connected` is
2310
+ * entered only after the handshake has succeeded. */
2311
+ connectionStateChanged = new import_core7.EventHandlerSet();
2312
+ _connection;
2313
+ _connectionState = 0 /* Disconnected */;
2298
2314
  _pendingSends = [];
2299
2315
  _keepAliveTimer;
2300
- constructor(ref, hub) {
2316
+ _keepAliveWatchdog;
2317
+ /** Wall-clock time of the last inbound `$sys.KeepAlive`, or 0 if none yet
2318
+ * for the current connection. Useful for diagnostics. */
2319
+ _lastKeepAliveAt = 0;
2320
+ /** Outbound keep-alive send period (per-peer override for tests). */
2321
+ keepAlivePeriodMs = KEEP_ALIVE_PERIOD_MS;
2322
+ /** Inbound keep-alive silence tolerance (per-peer override for tests). */
2323
+ keepAliveTimeoutMs = KEEP_ALIVE_TIMEOUT_MS;
2324
+ constructor(hub, ref) {
2325
+ this.hub = hub;
2301
2326
  this.ref = ref;
2302
- this._hub = hub;
2303
- }
2304
- get hub() {
2305
- return this._hub;
2306
2327
  }
2307
2328
  get connection() {
2308
2329
  return this._connection;
2309
2330
  }
2331
+ get connectionState() {
2332
+ return this._connectionState;
2333
+ }
2310
2334
  get isConnected() {
2311
2335
  return this._connection?.isOpen ?? false;
2312
2336
  }
2337
+ _setConnectionState(value) {
2338
+ if (this._connectionState === value) return;
2339
+ this._connectionState = value;
2340
+ if (value === 2 /* Connected */)
2341
+ this._armKeepAliveWatchdog();
2342
+ else if (value === 0 /* Disconnected */)
2343
+ this._disarmKeepAliveWatchdog();
2344
+ this.connectionStateChanged.trigger(value);
2345
+ }
2346
+ /** Resolves on the next transition into `Connected` — or immediately if
2347
+ * already in that state. */
2348
+ whenConnected() {
2349
+ if (this._connectionState === 2 /* Connected */)
2350
+ return Promise.resolve();
2351
+ return new Promise((resolve) => {
2352
+ const handler = (state) => {
2353
+ if (state !== 2 /* Connected */) return;
2354
+ this.connectionStateChanged.remove(handler);
2355
+ resolve();
2356
+ };
2357
+ this.connectionStateChanged.add(handler);
2358
+ });
2359
+ }
2313
2360
  setupConnection(conn) {
2314
2361
  this._connection = conn;
2315
2362
  conn.messageReceived.add((raw) => this._handleMessage(raw));
2316
2363
  conn.closed.add((ev) => {
2317
2364
  this._connection = void 0;
2318
- this._stopKeepAlive();
2319
2365
  if (ev.reason)
2320
2366
  infoLog?.log(`'${this.ref}': Disconnected: ${ev.reason} (code=${ev.code})`);
2321
2367
  else
2322
2368
  infoLog?.log(`'${this.ref}': Disconnected`);
2323
- this.disconnected.trigger(ev);
2369
+ this._setConnectionState(0 /* Disconnected */);
2324
2370
  });
2325
2371
  void conn.whenConnected.then(() => {
2326
- infoLog?.log(`'${this.ref}': Connected`);
2327
- this.connected.trigger();
2328
- this._startKeepAlive();
2372
+ infoLog?.log(`'${this.ref}': WS open`);
2329
2373
  });
2330
2374
  }
2331
2375
  /**
@@ -2333,29 +2377,29 @@ var RpcPeer = class {
2333
2377
  * If not set explicitly, defaults to the resolver's default (JSON /
2334
2378
  * json5np). Subclasses can pre-assign in their constructor.
2335
2379
  */
2336
- _format;
2337
- get format() {
2338
- return this._format ?? RpcSerializationFormatResolver.Default.defaultFormat;
2380
+ _serializationFormat;
2381
+ get serializationFormat() {
2382
+ return this._serializationFormat ?? RpcSerializationFormatResolver.Default.defaultFormat;
2339
2383
  }
2340
- set format(value) {
2341
- this._format = value;
2384
+ set serializationFormat(value) {
2385
+ this._serializationFormat = value;
2342
2386
  }
2343
2387
  call(method, args, options) {
2344
- const callId = this.outbound.nextId();
2388
+ const callId = this.outboundCalls.nextId();
2345
2389
  const outboundCall = options?.outboundCallFactory ? options.outboundCallFactory(callId, method) : new RpcOutboundCall(callId, method, options?.remoteExecutionMode ?? 7);
2346
2390
  const envelope = {
2347
2391
  Method: method,
2348
2392
  RelatedId: callId,
2349
2393
  CallType: options?.callTypeId ?? 0
2350
2394
  };
2351
- outboundCall.serializedWireData = this.format.serializeMessage(
2395
+ outboundCall.serializedWireData = this.serializationFormat.serializeMessage(
2352
2396
  envelope,
2353
2397
  args,
2354
2398
  this._connection?.encoder,
2355
- this._hub.registry
2399
+ this.hub.registry
2356
2400
  );
2357
2401
  if (this._connection !== void 0) {
2358
- this.outbound.register(outboundCall);
2402
+ this.outboundCalls.register(outboundCall);
2359
2403
  this._sendWireData(outboundCall.serializedWireData);
2360
2404
  } else {
2361
2405
  this._pendingSends.push(outboundCall);
@@ -2363,11 +2407,11 @@ var RpcPeer = class {
2363
2407
  const signal = options?.signal;
2364
2408
  if (signal !== void 0) {
2365
2409
  const onAbort = () => {
2366
- if (this.outbound.remove(callId) !== void 0) {
2410
+ if (this.outboundCalls.remove(callId) !== void 0) {
2367
2411
  outboundCall.result.reject(new Error("Call cancelled."));
2368
2412
  outboundCall.onDisconnect();
2369
2413
  if (this._connection !== void 0)
2370
- this._hub.systemCallSender.cancel(this._connection, this.format, callId);
2414
+ this.hub.systemCallSender.cancel(this._connection, this.serializationFormat, callId);
2371
2415
  } else {
2372
2416
  const idx = this._pendingSends.findIndex(
2373
2417
  (c) => c.callId === callId
@@ -2389,17 +2433,16 @@ var RpcPeer = class {
2389
2433
  callNoWait(method, args) {
2390
2434
  if (this._connection === void 0) return;
2391
2435
  const envelope = { Method: method, RelatedId: 0 };
2392
- const wireData = this.format.serializeMessage(
2436
+ const wireData = this.serializationFormat.serializeMessage(
2393
2437
  envelope,
2394
2438
  args,
2395
2439
  this._connection.encoder,
2396
- this._hub.registry
2440
+ this.hub.registry
2397
2441
  );
2398
2442
  this._sendWireData(wireData);
2399
2443
  }
2400
2444
  close() {
2401
2445
  infoLog?.log(`'${this.ref}': Stopping`);
2402
- this._stopKeepAlive();
2403
2446
  this.remoteObjects.disconnectAll();
2404
2447
  this.sharedObjects.disconnectAll();
2405
2448
  for (const call of this._pendingSends) {
@@ -2408,10 +2451,11 @@ var RpcPeer = class {
2408
2451
  call.onDisconnect();
2409
2452
  }
2410
2453
  this._pendingSends.length = 0;
2411
- this.outbound.rejectAll(new Error("Peer closed."));
2412
- this.outbound.invalidateAll();
2454
+ this.outboundCalls.rejectAll(new Error("Peer closed."));
2455
+ this.outboundCalls.invalidateAll();
2413
2456
  this._connection?.close();
2414
- this._hub.peers.delete(this.ref);
2457
+ this.hub.peers.delete(this.ref);
2458
+ this._setConnectionState(0 /* Disconnected */);
2415
2459
  infoLog?.log(`'${this.ref}': Stopped`);
2416
2460
  }
2417
2461
  /** Send any messages buffered while disconnected. Call after connection + handshake are ready. */
@@ -2419,7 +2463,7 @@ var RpcPeer = class {
2419
2463
  if (this._pendingSends.length === 0 || this._connection === void 0)
2420
2464
  return;
2421
2465
  for (const call of this._pendingSends) {
2422
- this.outbound.register(call);
2466
+ this.outboundCalls.register(call);
2423
2467
  this._sendWireData(call.serializedWireData);
2424
2468
  }
2425
2469
  this._pendingSends.length = 0;
@@ -2468,7 +2512,7 @@ var RpcPeer = class {
2468
2512
  return;
2469
2513
  }
2470
2514
  if (method.startsWith("$sys")) {
2471
- this._hub.systemCallHandler.handle(message, args, this);
2515
+ this.hub.systemCallHandler.handle(message, args, this);
2472
2516
  return;
2473
2517
  }
2474
2518
  this._handleInbound(message, args);
@@ -2477,12 +2521,12 @@ var RpcPeer = class {
2477
2521
  const method = message.Method ?? "";
2478
2522
  const relatedId = message.RelatedId ?? 0;
2479
2523
  const call = new RpcInboundCall(relatedId, method, args);
2480
- const methodDef = this._hub.serviceHost.getMethodDef(method);
2524
+ const methodDef = this.hub.serviceHost.getMethodDef(method);
2481
2525
  const isNoWait = methodDef?.noWait === true;
2482
2526
  if (!isNoWait) {
2483
- this.inbound.register(call);
2527
+ this.inboundCalls.register(call);
2484
2528
  }
2485
- const serviceHost = this._hub.serviceHost;
2529
+ const serviceHost = this.hub.serviceHost;
2486
2530
  void (async () => {
2487
2531
  try {
2488
2532
  const context = this._connection !== void 0 ? {
@@ -2497,25 +2541,25 @@ var RpcPeer = class {
2497
2541
  );
2498
2542
  if (methodDef?.stream === true && this._connection !== void 0) {
2499
2543
  const stream = result instanceof RpcStream ? result : new RpcStream(result);
2500
- this._hub.systemCallSender.ok(
2544
+ this.hub.systemCallSender.ok(
2501
2545
  this._connection,
2502
- this.format,
2546
+ this.serializationFormat,
2503
2547
  relatedId,
2504
2548
  stream.toRef(this)
2505
2549
  );
2506
2550
  } else if (!isNoWait && this._connection !== void 0) {
2507
- this._hub.systemCallSender.ok(
2551
+ this.hub.systemCallSender.ok(
2508
2552
  this._connection,
2509
- this.format,
2553
+ this.serializationFormat,
2510
2554
  relatedId,
2511
2555
  result
2512
2556
  );
2513
2557
  }
2514
2558
  } catch (e) {
2515
2559
  if (!isNoWait && this._connection !== void 0) {
2516
- this._hub.systemCallSender.error(
2560
+ this.hub.systemCallSender.error(
2517
2561
  this._connection,
2518
- this.format,
2562
+ this.serializationFormat,
2519
2563
  relatedId,
2520
2564
  e
2521
2565
  );
@@ -2523,36 +2567,64 @@ var RpcPeer = class {
2523
2567
  debugLog2?.log(`'${this.ref}': Inbound call failed: ${method}#${relatedId}`, e);
2524
2568
  } finally {
2525
2569
  if (!isNoWait) {
2526
- this.inbound.remove(relatedId);
2570
+ this.inboundCalls.remove(relatedId);
2527
2571
  }
2528
2572
  }
2529
2573
  })();
2530
2574
  }
2531
- _startKeepAlive() {
2532
- this._keepAliveTimer = setInterval(() => {
2575
+ /** Start sending `$sys.KeepAlive` pings and arm the silence watchdog.
2576
+ * Called when the peer transitions into `Connected` (after handshake).
2577
+ * Each inbound `$sys.KeepAlive` re-arms the watchdog; if it fires, the
2578
+ * WebSocket is force-closed so the normal disconnect path runs
2579
+ * (`conn.closed` → `connectionState = Disconnected` → UI).
2580
+ * Mirrors .NET `RpcObjectTrackers.Maintain`'s `KeepAliveTimeout` branch. */
2581
+ _armKeepAliveWatchdog() {
2582
+ this._keepAliveTimer ??= setInterval(() => {
2533
2583
  if (this._connection !== void 0) {
2534
- this._hub.systemCallSender.keepAlive(this._connection, this.format, [
2584
+ this.hub.systemCallSender.keepAlive(this._connection, this.serializationFormat, [
2535
2585
  ...this.remoteObjects.keys()
2536
2586
  ]);
2537
2587
  }
2538
- }, 15e3);
2588
+ }, this.keepAlivePeriodMs);
2589
+ if (this._keepAliveWatchdog !== void 0)
2590
+ clearTimeout(this._keepAliveWatchdog);
2591
+ this._keepAliveWatchdog = setTimeout(() => {
2592
+ this._keepAliveWatchdog = void 0;
2593
+ if (this._connection === void 0) return;
2594
+ warnLog6?.log(
2595
+ `'${this.ref}': no keep-alive for ${this.keepAliveTimeoutMs}ms \u2014 force-closing connection`
2596
+ );
2597
+ this._connection.close();
2598
+ }, this.keepAliveTimeoutMs);
2539
2599
  }
2540
- _stopKeepAlive() {
2600
+ /** Stop sending keep-alives and clear the silence watchdog. Called when
2601
+ * the peer transitions into `Disconnected`. */
2602
+ _disarmKeepAliveWatchdog() {
2541
2603
  if (this._keepAliveTimer !== void 0) {
2542
2604
  clearInterval(this._keepAliveTimer);
2543
2605
  this._keepAliveTimer = void 0;
2544
2606
  }
2607
+ if (this._keepAliveWatchdog !== void 0) {
2608
+ clearTimeout(this._keepAliveWatchdog);
2609
+ this._keepAliveWatchdog = void 0;
2610
+ }
2611
+ }
2612
+ /** Called by `RpcSystemCallHandler` when a `$sys.KeepAlive` message
2613
+ * arrives — records the timestamp and re-arms the silence watchdog. */
2614
+ notifyKeepAliveReceived() {
2615
+ this._lastKeepAliveAt = Date.now();
2616
+ if (this._keepAliveWatchdog !== void 0)
2617
+ this._armKeepAliveWatchdog();
2545
2618
  }
2546
2619
  };
2547
- var RpcPeerConnectionKind = /* @__PURE__ */ ((RpcPeerConnectionKind2) => {
2548
- RpcPeerConnectionKind2[RpcPeerConnectionKind2["Disconnected"] = 0] = "Disconnected";
2549
- RpcPeerConnectionKind2[RpcPeerConnectionKind2["Connecting"] = 1] = "Connecting";
2550
- RpcPeerConnectionKind2[RpcPeerConnectionKind2["Connected"] = 2] = "Connected";
2551
- return RpcPeerConnectionKind2;
2552
- })(RpcPeerConnectionKind || {});
2620
+ var RpcConnectionState = /* @__PURE__ */ ((RpcConnectionState2) => {
2621
+ RpcConnectionState2[RpcConnectionState2["Disconnected"] = 0] = "Disconnected";
2622
+ RpcConnectionState2[RpcConnectionState2["Connecting"] = 1] = "Connecting";
2623
+ RpcConnectionState2[RpcConnectionState2["Connected"] = 2] = "Connected";
2624
+ return RpcConnectionState2;
2625
+ })(RpcConnectionState || {});
2553
2626
  var RpcClientPeer = class extends RpcPeer {
2554
2627
  _disposed = false;
2555
- _connectionKind = 0 /* Disconnected */;
2556
2628
  _tryIndex = 0;
2557
2629
  _handshakeIndex = 0;
2558
2630
  /** Index from the MOST RECENTLY RECEIVED server handshake — sent back
@@ -2566,19 +2638,21 @@ var RpcClientPeer = class extends RpcPeer {
2566
2638
  _reconnectsAt = 0;
2567
2639
  /** Base64url-encoded peer ID — matches .NET's RpcClientPeer.ClientId (Guid.ToBase64Url). */
2568
2640
  clientId;
2569
- /**
2570
- * Serialization format key sent to the server via `f=` query parameter.
2571
- * TS uses plain JSON.stringify (no polymorphic type wrapping), so `json5np`
2572
- * (no-polymorphism) is the correct default.
2573
- */
2574
- serializationFormat;
2575
2641
  /** Builds the WebSocket URL for this peer. Replace to customize URL construction. */
2576
2642
  connectionUrlResolver = defaultConnectionUrlResolver;
2577
- peerChanged = new import_core8.EventHandlerSet();
2643
+ /** Optional WebSocket factory. When set, `run()` uses this instead of the
2644
+ * global `new WebSocket(url)` constructor. Used by tests (to inject mock
2645
+ * sockets) and by the Node load-test harness (to attach `Session` /
2646
+ * `Cookie` headers to the WS upgrade, which the browser `WebSocket`
2647
+ * constructor can't do). Must be set before `start()`. */
2648
+ webSocketFactory;
2649
+ /** Max time to wait for the server's handshake response before aborting
2650
+ * the connection attempt. Mirrors .NET's `Hub.Limits.HandshakeTimeout`. */
2651
+ handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS;
2652
+ peerChanged = new import_core7.EventHandlerSet();
2578
2653
  /** Fired when the server rejects the connection due to an unsupported serialization format (close code 4010). */
2579
- unsupportedFormat = new import_core8.EventHandlerSet();
2580
- reconnectDelayer = new RpcClientPeerReconnectDelayer();
2581
- reconnectsAtChanged = new import_core8.EventHandlerSet();
2654
+ unsupportedFormat = new import_core7.EventHandlerSet();
2655
+ reconnectsAtChanged = new import_core7.EventHandlerSet();
2582
2656
  get reconnectsAt() {
2583
2657
  return this._reconnectsAt;
2584
2658
  }
@@ -2587,17 +2661,32 @@ var RpcClientPeer = class extends RpcPeer {
2587
2661
  this._reconnectsAt = value;
2588
2662
  this.reconnectsAtChanged.trigger();
2589
2663
  }
2590
- constructor(hub, url, serializationFormat) {
2591
- super(url, hub);
2664
+ /** The promise returned by the reconnect loop. `undefined` until
2665
+ * `start()` is invoked (which happens automatically when
2666
+ * `mustStart = true` is passed to the ctor). */
2667
+ whenRunning;
2668
+ /**
2669
+ * @param hub RpcHub instance this peer will be tracked by
2670
+ * @param url Full WebSocket URL — the serialization format is taken from
2671
+ * the `f=` query parameter (e.g. `wss://host/rpc/ws?f=msgpack6`);
2672
+ * falls back to the resolver's default when absent. Use
2673
+ * {@link RpcPeerRefBuilder.forClient} to compose URLs with a format.
2674
+ * @param mustStart Kick off the reconnect loop automatically at the end of
2675
+ * construction (default `true`). Pass `false` if you need to
2676
+ * tweak `reconnectDelayer`, `handshakeTimeoutMs`, or similar
2677
+ * options before the loop starts — then call `peer.start()`
2678
+ * manually.
2679
+ */
2680
+ constructor(hub, url, mustStart = true) {
2681
+ super(hub, url);
2592
2682
  this.clientId = guidToBase64Url(this.id);
2593
2683
  const resolver = RpcSerializationFormatResolver.Default;
2594
- this.serializationFormat = serializationFormat ?? resolver.defaultFormatKey;
2595
- this.format = resolver.get(this.serializationFormat);
2596
- }
2597
- get connectionKind() {
2598
- return this._connectionKind;
2684
+ this.serializationFormat = resolver.get(parseFormatFromUrl(url) ?? resolver.defaultFormatKey);
2685
+ if (mustStart)
2686
+ this.start();
2599
2687
  }
2600
2688
  /** One-shot connection for tests — no reconnection loop, no handshake.
2689
+ * @param conn Rpc connection
2601
2690
  * @param isPeerChanged Whether to treat this as a peer-identity change
2602
2691
  * (default `true` — no handshake was exchanged, so stage-3 compute calls
2603
2692
  * self-invalidate and remote/shared objects are disposed). Pass `false`
@@ -2606,97 +2695,137 @@ var RpcClientPeer = class extends RpcPeer {
2606
2695
  this.setupConnection(conn);
2607
2696
  void this._reconnect(isPeerChanged);
2608
2697
  }
2609
- /** Start the reconnection loop — runs until disposed. */
2610
- async run(wsFactory) {
2698
+ /** Kick off the reconnect loop. Idempotent — subsequent calls are
2699
+ * no-ops; await `peer.whenRunning` if you need the loop's completion
2700
+ * promise. Customize `peer.webSocketFactory` first if you need a
2701
+ * non-default WebSocket constructor. */
2702
+ start() {
2703
+ void this.run();
2704
+ }
2705
+ /** The reconnect loop itself. Exposed as `protected` so subclasses (and
2706
+ * `start()`) can drive it; public callers should use {@link start}. */
2707
+ run() {
2708
+ return this.whenRunning ??= this._runImpl();
2709
+ }
2710
+ async _runImpl() {
2711
+ if (this._disposed) return;
2611
2712
  const connUrl = this.connectionUrlResolver(this);
2612
2713
  infoLog?.log(`'${this.ref}': Started (Client)`);
2613
- while (!this._disposed) {
2614
- let lastCloseCode = 0;
2615
- let lastCloseReason = "";
2616
- try {
2617
- infoLog?.log(`'${this.ref}': Connecting to ${connUrl}`);
2618
- const ws = wsFactory?.(connUrl) ?? new WebSocket(connUrl);
2619
- const conn = new RpcWebSocketConnection(
2620
- ws,
2621
- this.format.isBinary,
2622
- this.format,
2623
- this._hub.registry
2624
- );
2625
- this._connectionKind = 1 /* Connecting */;
2626
- conn.closed.add((ev) => {
2627
- lastCloseCode = ev.code;
2628
- lastCloseReason = ev.reason;
2629
- });
2630
- this._pendingHandshake = new import_core8.PromiseSource();
2631
- this.setupConnection(conn);
2632
- this._connection = void 0;
2633
- const closedRejection = new Promise(
2634
- (_, reject) => conn.closed.add(
2635
- () => reject(new Error("Connection failed"))
2636
- )
2637
- );
2638
- closedRejection.catch(() => {
2639
- });
2640
- await Promise.race([conn.whenConnected, closedRejection]);
2714
+ try {
2715
+ while (!this._disposed) {
2716
+ this._setConnectionState(1 /* Connecting */);
2717
+ const retryDelay = this.hub.reconnectDelayer.getDelay(this._tryIndex);
2718
+ if (retryDelay.isLimitExceeded) {
2719
+ warnLog6?.log(`'${this.ref}': Reconnect limit exceeded - shutting down`);
2720
+ this._disposed = true;
2721
+ break;
2722
+ }
2723
+ this._setReconnectsAt(retryDelay.endsAt);
2724
+ try {
2725
+ await retryDelay.promise;
2726
+ } finally {
2727
+ this._setReconnectsAt(0);
2728
+ }
2729
+ if (this._disposed) break;
2730
+ let lastCloseCode = 0;
2731
+ let lastCloseReason = "";
2641
2732
  try {
2642
- this._hub.systemCallSender.handshake(
2643
- conn,
2644
- this.format,
2645
- this.id,
2646
- this._hub.hubId,
2647
- ++this._handshakeIndex
2733
+ infoLog?.log(`'${this.ref}': Connecting to ${connUrl}`);
2734
+ const ws = this.webSocketFactory?.(connUrl) ?? new WebSocket(connUrl);
2735
+ const conn = new RpcWebSocketConnection(
2736
+ ws,
2737
+ this.serializationFormat.isBinary,
2738
+ this.serializationFormat,
2739
+ this.hub.registry
2648
2740
  );
2741
+ const whenClosed = new import_core7.PromiseSource();
2742
+ const closedController = new AbortController();
2743
+ conn.closed.add((ev) => {
2744
+ lastCloseCode = ev.code;
2745
+ lastCloseReason = ev.reason;
2746
+ whenClosed.resolve(ev);
2747
+ closedController.abort();
2748
+ });
2749
+ const closedRejection = whenClosed.promise.then(() => {
2750
+ throw new Error("Connection failed");
2751
+ });
2752
+ closedRejection.catch(() => {
2753
+ });
2754
+ this._pendingHandshake = new import_core7.PromiseSource();
2755
+ this.setupConnection(conn);
2756
+ this._connection = void 0;
2757
+ await Promise.race([conn.whenConnected, closedRejection]);
2758
+ try {
2759
+ this.hub.systemCallSender.handshake(
2760
+ conn,
2761
+ this.serializationFormat,
2762
+ this.id,
2763
+ this.hub.hubId,
2764
+ ++this._handshakeIndex
2765
+ );
2766
+ } catch (e) {
2767
+ warnLog6?.log(`'${this.ref}': Failed to send handshake`, e);
2768
+ throw e;
2769
+ }
2770
+ const handshakeTimeoutMs = this.handshakeTimeoutMs;
2771
+ let handshakeTimer;
2772
+ const handshakeTimeout = new Promise((_, reject) => {
2773
+ handshakeTimer = setTimeout(() => {
2774
+ reject(new Error("Handshake timeout"));
2775
+ }, handshakeTimeoutMs);
2776
+ });
2777
+ handshakeTimeout.catch(() => {
2778
+ });
2779
+ let remoteHandshake;
2780
+ try {
2781
+ remoteHandshake = await Promise.race([
2782
+ this._pendingHandshake.promise,
2783
+ closedRejection,
2784
+ handshakeTimeout
2785
+ ]);
2786
+ } catch (e) {
2787
+ if (e instanceof Error && e.message === "Handshake timeout") {
2788
+ warnLog6?.log(`'${this.ref}': Handshake timed out after ${handshakeTimeoutMs}ms`);
2789
+ conn.close();
2790
+ }
2791
+ throw e;
2792
+ } finally {
2793
+ if (handshakeTimer !== void 0) clearTimeout(handshakeTimer);
2794
+ this._pendingHandshake = void 0;
2795
+ }
2796
+ this._remoteHandshakeIndex = remoteHandshake.Index ?? 0;
2797
+ const remoteHubId = remoteHandshake.RemoteHubId;
2798
+ let isPeerChanged = false;
2799
+ if (remoteHubId !== void 0) {
2800
+ isPeerChanged = this._lastRemoteHubId !== void 0 && this._lastRemoteHubId !== remoteHubId;
2801
+ if (isPeerChanged) {
2802
+ this.inboundCalls.clear();
2803
+ infoLog?.log(`'${this.ref}': Peer changed`);
2804
+ this.peerChanged.trigger();
2805
+ }
2806
+ this._lastRemoteHubId = remoteHubId;
2807
+ }
2808
+ infoLog?.log(`'${this.ref}': Handshake succeeded, peerChanged=${isPeerChanged}`);
2809
+ this._connection = conn;
2810
+ this._setConnectionState(2 /* Connected */);
2811
+ this._tryIndex = 0;
2812
+ await this._reconnect(isPeerChanged, closedController.signal);
2813
+ await whenClosed.promise;
2649
2814
  } catch (e) {
2650
- warnLog6?.log(`'${this.ref}': Failed to send handshake`, e);
2651
- throw e;
2815
+ debugLog2?.log(`'${this.ref}': run() iteration failed:`, e);
2652
2816
  }
2653
- const remoteHandshake = await Promise.race([
2654
- this._pendingHandshake.promise,
2655
- closedRejection
2656
- ]);
2657
- this._pendingHandshake = void 0;
2658
- this._remoteHandshakeIndex = remoteHandshake.Index ?? 0;
2659
- const remoteHubId = remoteHandshake.RemoteHubId;
2660
- let isPeerChanged = false;
2661
- if (remoteHubId !== void 0) {
2662
- isPeerChanged = this._lastRemoteHubId !== void 0 && this._lastRemoteHubId !== remoteHubId;
2663
- if (isPeerChanged) {
2664
- this.inbound.clear();
2665
- infoLog?.log(`'${this.ref}': Peer changed`);
2666
- this.peerChanged.trigger();
2667
- }
2668
- this._lastRemoteHubId = remoteHubId;
2817
+ this._setConnectionState(0 /* Disconnected */);
2818
+ if (this._disposed) break;
2819
+ if (lastCloseCode === RPC_CLOSE_CODE_UNSUPPORTED_FORMAT) {
2820
+ warnLog6?.log(`'${this.ref}': Unsupported serialization format - shutting down`);
2821
+ this.unsupportedFormat.trigger({ reason: lastCloseReason });
2822
+ this._disposed = true;
2823
+ break;
2669
2824
  }
2670
- infoLog?.log(`'${this.ref}': Handshake succeeded, peerChanged=${isPeerChanged}`);
2671
- this._connection = conn;
2672
- this._connectionKind = 2 /* Connected */;
2673
- this._tryIndex = 0;
2674
- await this._reconnect(isPeerChanged);
2675
- await new Promise((r) => conn.closed.add(() => r()));
2676
- } catch (e) {
2677
- debugLog2?.log(`'${this.ref}': run() iteration failed:`, e);
2678
- }
2679
- this._connectionKind = 0 /* Disconnected */;
2680
- if (this._disposed) break;
2681
- if (lastCloseCode === RPC_CLOSE_CODE_UNSUPPORTED_FORMAT) {
2682
- warnLog6?.log(`'${this.ref}': Unsupported serialization format - shutting down`);
2683
- this.unsupportedFormat.trigger({ reason: lastCloseReason });
2684
- this._disposed = true;
2685
- break;
2686
- }
2687
- this._tryIndex++;
2688
- const delay = this.reconnectDelayer.getDelay(this._tryIndex);
2689
- if (delay.isLimitExceeded) {
2690
- warnLog6?.log(`'${this.ref}': Reconnect limit exceeded - shutting down`);
2691
- this._disposed = true;
2692
- break;
2693
- }
2694
- this._setReconnectsAt(delay.endsAt);
2695
- try {
2696
- await delay.promise;
2697
- } finally {
2698
- this._setReconnectsAt(0);
2825
+ this._tryIndex++;
2699
2826
  }
2827
+ } finally {
2828
+ this._setConnectionState(0 /* Disconnected */);
2700
2829
  }
2701
2830
  }
2702
2831
  /**
@@ -2718,7 +2847,7 @@ var RpcClientPeer = class extends RpcPeer {
2718
2847
  * are always self-invalidated — TS doesn't track compute-call stages
2719
2848
  * across reconnect, so the safest behavior is to force a fresh compute.
2720
2849
  */
2721
- async _reconnect(isPeerChanged) {
2850
+ async _reconnect(isPeerChanged, closedSignal) {
2722
2851
  if (this._connection === void 0) return;
2723
2852
  if (isPeerChanged) {
2724
2853
  this.remoteObjects.disconnectAll();
@@ -2727,7 +2856,7 @@ var RpcClientPeer = class extends RpcPeer {
2727
2856
  this.remoteObjects.reconnectAll();
2728
2857
  }
2729
2858
  const eligible = [];
2730
- for (const call of [...this.outbound.values()]) {
2859
+ for (const call of [...this.outboundCalls.values()]) {
2731
2860
  const mode = call.remoteExecutionMode;
2732
2861
  if (!(mode & 2)) {
2733
2862
  if (!call.result.isCompleted)
@@ -2735,7 +2864,7 @@ var RpcClientPeer = class extends RpcPeer {
2735
2864
  "Outbound call failed: disconnected and AllowReconnect is not set."
2736
2865
  ));
2737
2866
  call.onDisconnect();
2738
- this.outbound.remove(call.callId);
2867
+ this.outboundCalls.remove(call.callId);
2739
2868
  continue;
2740
2869
  }
2741
2870
  if (isPeerChanged && !(mode & 4)) {
@@ -2744,19 +2873,19 @@ var RpcClientPeer = class extends RpcPeer {
2744
2873
  "Outbound call failed: reconnected to a different peer and AllowResend is not set."
2745
2874
  ));
2746
2875
  call.onDisconnect();
2747
- this.outbound.remove(call.callId);
2876
+ this.outboundCalls.remove(call.callId);
2748
2877
  continue;
2749
2878
  }
2750
2879
  eligible.push(call);
2751
2880
  }
2752
2881
  let unknownIds = null;
2753
2882
  if (!isPeerChanged && eligible.length > 0) {
2754
- unknownIds = await this._reconcileReconnect(eligible);
2883
+ unknownIds = await this._reconcileReconnect(eligible, closedSignal);
2755
2884
  }
2756
2885
  for (const call of eligible) {
2757
2886
  if (!call.removeOnOk && call.result.isCompleted) {
2758
2887
  call.onDisconnect();
2759
- this.outbound.remove(call.callId);
2888
+ this.outboundCalls.remove(call.callId);
2760
2889
  continue;
2761
2890
  }
2762
2891
  if (unknownIds !== null && !unknownIds.has(call.callId)) {
@@ -2779,7 +2908,7 @@ var RpcClientPeer = class extends RpcPeer {
2779
2908
  * native msgpack `map<int, bin>`, matching .NET MessagePack-CSharp's
2780
2909
  * default for `Dictionary<int, byte[]>`.
2781
2910
  */
2782
- async _reconcileReconnect(eligible) {
2911
+ async _reconcileReconnect(eligible, closedSignal) {
2783
2912
  try {
2784
2913
  const byStage = /* @__PURE__ */ new Map();
2785
2914
  for (const call of eligible) {
@@ -2795,7 +2924,7 @@ var RpcClientPeer = class extends RpcPeer {
2795
2924
  if (byStage.size === 0) {
2796
2925
  return new Set(eligible.map((c) => c.callId));
2797
2926
  }
2798
- const isBinary = this.format.isBinary;
2927
+ const isBinary = this.serializationFormat.isBinary;
2799
2928
  const stagesArg = isBinary ? /* @__PURE__ */ new Map() : {};
2800
2929
  for (const [stage, ids] of byStage) {
2801
2930
  ids.sort((a, b) => a - b);
@@ -2810,8 +2939,9 @@ var RpcClientPeer = class extends RpcPeer {
2810
2939
  RpcSystemCalls.reconnect,
2811
2940
  [this._remoteHandshakeIndex, stagesArg],
2812
2941
  {
2813
- remoteExecutionMode: 1
2942
+ remoteExecutionMode: 1,
2814
2943
  /* AwaitForConnection only; must not itself reconnect */
2944
+ signal: closedSignal
2815
2945
  }
2816
2946
  );
2817
2947
  const result = await reconnectCall.result.promise;
@@ -2831,11 +2961,14 @@ var RpcClientPeer = class extends RpcPeer {
2831
2961
  }
2832
2962
  };
2833
2963
  var RpcServerPeer = class extends RpcPeer {
2964
+ // Needed to widen the base `protected constructor` to public.
2965
+ // eslint-disable-next-line @typescript-eslint/no-useless-constructor
2834
2966
  constructor(hub, ref) {
2835
- super(ref, hub);
2967
+ super(hub, ref);
2836
2968
  }
2837
2969
  /** Accept an incoming connection — sets up message handling and handshake response. */
2838
2970
  accept(conn) {
2971
+ this._setConnectionState(1 /* Connecting */);
2839
2972
  this.setupConnection(conn);
2840
2973
  conn.closed.add(() => {
2841
2974
  this.sharedObjects.disconnectAll();
@@ -2843,13 +2976,14 @@ var RpcServerPeer = class extends RpcPeer {
2843
2976
  }
2844
2977
  _onHandshakeReceived(_handshake) {
2845
2978
  if (this._connection !== void 0) {
2846
- this._hub.systemCallSender.handshake(
2979
+ this.hub.systemCallSender.handshake(
2847
2980
  this._connection,
2848
- this.format,
2981
+ this.serializationFormat,
2849
2982
  this.id,
2850
- this._hub.hubId,
2983
+ this.hub.hubId,
2851
2984
  0
2852
2985
  );
2986
+ this._setConnectionState(2 /* Connected */);
2853
2987
  }
2854
2988
  }
2855
2989
  };
@@ -2881,6 +3015,15 @@ function _toBytes2(value) {
2881
3015
  return null;
2882
3016
  }
2883
3017
 
3018
+ // src/rpc-client-peer-reconnect-delayer.ts
3019
+ var import_core8 = require("@actuallab/core");
3020
+ var RpcClientPeerReconnectDelayer = class extends import_core8.RetryDelayer {
3021
+ constructor() {
3022
+ super();
3023
+ this.delays = import_core8.RetryDelaySeq.fixed(100);
3024
+ }
3025
+ };
3026
+
2884
3027
  // src/rpc-service-host.ts
2885
3028
  var { warnLog: warnLog7 } = getLogs("RpcServiceHost");
2886
3029
  var RpcServiceHost = class {
@@ -3483,6 +3626,16 @@ var RpcHub = class {
3483
3626
  /** Method registry for compact format hash ↔ name resolution.
3484
3627
  * Created lazily, populated by addService/addClient. */
3485
3628
  registry = new RpcMethodRegistry();
3629
+ /** Shared reconnect delayer used by every client peer in this hub. Swap in
3630
+ * a custom subclass (e.g. app-level signal-gated) before peers start. */
3631
+ reconnectDelayer = new RpcClientPeerReconnectDelayer();
3632
+ /** URL used by {@link defaultPeer} to resolve / create the default client
3633
+ * peer. Must be set before the first {@link defaultPeer} access. */
3634
+ defaultPeerUrl;
3635
+ /** Factory used by {@link defaultPeer} when the peer needs to be created.
3636
+ * If left undefined, `getPeer`'s built-in default (RpcClientPeer with
3637
+ * format derived from URL or resolver default) is used. */
3638
+ defaultPeerFactory;
3486
3639
  constructor(hubId) {
3487
3640
  this.hubId = hubId ?? crypto.randomUUID();
3488
3641
  this.serviceHost = new RpcServiceHost();
@@ -3491,7 +3644,20 @@ var RpcHub = class {
3491
3644
  this.registry.register(methodName);
3492
3645
  }
3493
3646
  }
3647
+ /** Shortcut for the hub's default client peer. Fails if
3648
+ * {@link defaultPeerUrl} is not set. */
3649
+ get defaultPeer() {
3650
+ if (this.defaultPeerUrl === void 0)
3651
+ throw new Error("RpcHub.defaultPeerUrl is not set.");
3652
+ return this.getClientPeer(this.defaultPeerUrl, this.defaultPeerFactory);
3653
+ }
3654
+ /** Register a peer under its ref. If a different peer is already registered
3655
+ * at the same ref, close it first — the hub guarantees at most one live
3656
+ * peer per ref, and no peer exists outside the hub. */
3494
3657
  addPeer(peer) {
3658
+ const existing = this.peers.get(peer.ref);
3659
+ if (existing !== void 0 && existing !== peer)
3660
+ existing.close();
3495
3661
  this.peers.set(peer.ref, peer);
3496
3662
  }
3497
3663
  removePeer(ref) {
@@ -3502,21 +3668,27 @@ var RpcHub = class {
3502
3668
  warnLog9?.log(`'${ref}': peer is removed from RpcHub`);
3503
3669
  }
3504
3670
  }
3505
- /** Get or create a peer by ref. Server peers have "server://" prefix, everything else is a client peer. */
3506
- getPeer(ref) {
3507
- let peer = this.peers.get(ref);
3508
- if (peer) return peer;
3509
- peer = ref.startsWith("server://") ? new RpcServerPeer(this, ref) : new RpcClientPeer(this, ref);
3671
+ /** Get or create a peer by ref. If the peer does not exist:
3672
+ * - with `factory` provided: it is constructed via `factory(this, ref)`;
3673
+ * - otherwise: server peers (ref starts with `server://`) get a plain
3674
+ * RpcServerPeer, anything else gets a default-configured RpcClientPeer.
3675
+ * The created peer is always registered in this hub. */
3676
+ getPeer(ref, factory) {
3677
+ const existing = this.peers.get(ref);
3678
+ if (existing) return existing;
3679
+ const peer = factory ? factory(this, ref) : ref.startsWith("server://") ? new RpcServerPeer(this, ref) : new RpcClientPeer(this, ref);
3510
3680
  this.addPeer(peer);
3511
3681
  return peer;
3512
3682
  }
3513
- /** Get or create a client peer for the given URL. */
3514
- getClientPeer(ref) {
3515
- return this.getPeer(ref);
3683
+ /** Get or create a client peer for the given URL. See {@link getPeer} for
3684
+ * the factory semantics. */
3685
+ getClientPeer(ref, factory) {
3686
+ return this.getPeer(ref, factory);
3516
3687
  }
3517
- /** Get or create a server peer for the given ref. */
3518
- getServerPeer(ref) {
3519
- return this.getPeer(ref);
3688
+ /** Get or create a server peer for the given ref. See {@link getPeer} for
3689
+ * the factory semantics. */
3690
+ getServerPeer(ref, factory) {
3691
+ return this.getPeer(ref, factory);
3520
3692
  }
3521
3693
  /** Register a service with optional server method wrapping for custom call types. */
3522
3694
  addService(defOrContract, impl) {
@@ -3645,6 +3817,25 @@ var RpcHub = class {
3645
3817
  }
3646
3818
  };
3647
3819
 
3820
+ // src/rpc-peer-ref-builder.ts
3821
+ var RpcPeerRefBuilder = class {
3822
+ /** Build a client peer ref from a WebSocket URL, optionally baking in the
3823
+ * serialization format as a `f=` query parameter.
3824
+ * @param url Base WebSocket URL, e.g. `wss://host/rpc/ws`.
3825
+ * @param serializationFormat Optional format key, e.g. `msgpack6`. When
3826
+ * provided, replaces any existing `f=` in the URL. */
3827
+ static forClient(url, serializationFormat) {
3828
+ if (serializationFormat === void 0) return url;
3829
+ const u = new URL(url);
3830
+ u.searchParams.set("f", serializationFormat);
3831
+ return u.toString();
3832
+ }
3833
+ /** Build a server peer ref from a peer id — returns `server://{id}`. */
3834
+ static forServer(id) {
3835
+ return `server://${id}`;
3836
+ }
3837
+ };
3838
+
3648
3839
  // src/rpc-client.ts
3649
3840
  function createRpcClient(peer, serviceDef) {
3650
3841
  const overloads = /* @__PURE__ */ new Map();
@@ -3746,20 +3937,18 @@ var RpcPeerStateMonitor = class {
3746
3937
  _state;
3747
3938
  _timer;
3748
3939
  // Arrow functions to preserve `this` when used as event handlers
3749
- _onConnected = () => {
3750
- this._rawConnected = true;
3751
- this._connectedAt = Date.now();
3752
- this._lastError = void 0;
3753
- this._recompute();
3754
- };
3755
- _onDisconnected = (ev) => {
3756
- const wasConnected = this._rawConnected;
3757
- this._rawConnected = false;
3758
- this._lastError = new Error(
3759
- `Connection closed: ${ev.reason} (code ${ev.code})`
3760
- );
3761
- if (wasConnected) {
3762
- this._disconnectedAt = Date.now();
3940
+ _onConnectionStateChanged = (state) => {
3941
+ if (state === 2 /* Connected */) {
3942
+ this._rawConnected = true;
3943
+ this._connectedAt = Date.now();
3944
+ this._lastError = void 0;
3945
+ } else {
3946
+ const wasConnected = this._rawConnected;
3947
+ this._rawConnected = false;
3948
+ this._lastError = new Error("Connection closed");
3949
+ if (wasConnected) {
3950
+ this._disconnectedAt = Date.now();
3951
+ }
3763
3952
  }
3764
3953
  this._recompute();
3765
3954
  };
@@ -3785,10 +3974,9 @@ var RpcPeerStateMonitor = class {
3785
3974
  reconnectsIn: 0
3786
3975
  };
3787
3976
  }
3788
- peer.connected.add(this._onConnected);
3789
- peer.disconnected.add(this._onDisconnected);
3977
+ peer.connectionStateChanged.add(this._onConnectionStateChanged);
3790
3978
  peer.reconnectsAtChanged.add(this._onReconnectsAtChanged);
3791
- peer.reconnectDelayer.cancelDelaysChanged.add(
3979
+ peer.hub.reconnectDelayer.cancelDelaysChanged.add(
3792
3980
  this._onCancelDelaysChanged
3793
3981
  );
3794
3982
  this._scheduleRecompute(
@@ -3804,10 +3992,9 @@ var RpcPeerStateMonitor = class {
3804
3992
  clearTimeout(this._timer);
3805
3993
  this._timer = void 0;
3806
3994
  }
3807
- this.peer.connected.remove(this._onConnected);
3808
- this.peer.disconnected.remove(this._onDisconnected);
3995
+ this.peer.connectionStateChanged.remove(this._onConnectionStateChanged);
3809
3996
  this.peer.reconnectsAtChanged.remove(this._onReconnectsAtChanged);
3810
- this.peer.reconnectDelayer.cancelDelaysChanged.remove(
3997
+ this.peer.hub.reconnectDelayer.cancelDelaysChanged.remove(
3811
3998
  this._onCancelDelaysChanged
3812
3999
  );
3813
4000
  infoLog2?.log(`'${this.peer.ref}': monitor stopped`);
@@ -3890,12 +4077,14 @@ var RpcPeerStateMonitor = class {
3890
4077
  ARG_DELIMITER,
3891
4078
  ENVELOPE_DELIMITER,
3892
4079
  FRAME_DELIMITER,
4080
+ HANDSHAKE_TIMEOUT_MS,
3893
4081
  IncreasingSeqCompressor,
3894
4082
  RPC_CLOSE_CODE_UNSUPPORTED_FORMAT,
3895
4083
  RpcCallStage,
3896
4084
  RpcCallTypeId,
3897
4085
  RpcClientPeer,
3898
4086
  RpcClientPeerReconnectDelayer,
4087
+ RpcConnectionState,
3899
4088
  RpcHub,
3900
4089
  RpcInboundCall,
3901
4090
  RpcInboundCallTracker,
@@ -3908,7 +4097,7 @@ var RpcPeerStateMonitor = class {
3908
4097
  RpcOutboundCall,
3909
4098
  RpcOutboundCallTracker,
3910
4099
  RpcPeer,
3911
- RpcPeerConnectionKind,
4100
+ RpcPeerRefBuilder,
3912
4101
  RpcPeerStateKind,
3913
4102
  RpcPeerStateMonitor,
3914
4103
  RpcRemoteExecutionMode,