@actuallab/rpc 12.3.33 → 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.js CHANGED
@@ -955,7 +955,7 @@ var RpcStreamSender = class _RpcStreamSender {
955
955
  if (conn) {
956
956
  this.peer.hub.systemCallSender.item(
957
957
  conn,
958
- this.peer.format,
958
+ this.peer.serializationFormat,
959
959
  this.id.localId,
960
960
  this._nextIndex,
961
961
  item
@@ -970,7 +970,7 @@ var RpcStreamSender = class _RpcStreamSender {
970
970
  if (conn) {
971
971
  this.peer.hub.systemCallSender.batch(
972
972
  conn,
973
- this.peer.format,
973
+ this.peer.serializationFormat,
974
974
  this.id.localId,
975
975
  this._nextIndex,
976
976
  items
@@ -987,7 +987,7 @@ var RpcStreamSender = class _RpcStreamSender {
987
987
  const errorInfo = error ? { TypeRef: "System.Exception", Message: error.message } : { TypeRef: "", Message: "" };
988
988
  this.peer.hub.systemCallSender.end(
989
989
  conn,
990
- this.peer.format,
990
+ this.peer.serializationFormat,
991
991
  this.id.localId,
992
992
  this._nextIndex,
993
993
  errorInfo
@@ -1442,7 +1442,7 @@ var RpcStream = class _RpcStream {
1442
1442
  this._sender = sender;
1443
1443
  const source = isFactory ? this.localSource(sender.abortSignal) : this.localSource;
1444
1444
  this._whenSent = sender.writeFrom(source);
1445
- this._toRefValue = peer.format.isBinary ? {
1445
+ this._toRefValue = peer.serializationFormat.isBinary ? {
1446
1446
  SerializedId: [sender.id.hostId, sender.id.localId],
1447
1447
  AckPeriod: sender.ackPeriod,
1448
1448
  AckAdvance: sender.ackAdvance,
@@ -1592,7 +1592,7 @@ var RpcStream = class _RpcStream {
1592
1592
  const hostId = mustReset ? this.id.hostId : _RpcStream._emptyGuid;
1593
1593
  this.peer.hub.systemCallSender.ack(
1594
1594
  conn,
1595
- this.peer.format,
1595
+ this.peer.serializationFormat,
1596
1596
  this.id.localId,
1597
1597
  nextIndex,
1598
1598
  hostId
@@ -1604,7 +1604,7 @@ var RpcStream = class _RpcStream {
1604
1604
  if (conn) {
1605
1605
  this.peer.hub.systemCallSender.ackEnd(
1606
1606
  conn,
1607
- this.peer.format,
1607
+ this.peer.serializationFormat,
1608
1608
  this.id.localId,
1609
1609
  this.id.hostId
1610
1610
  );
@@ -1651,9 +1651,8 @@ function base64Encode(bytes) {
1651
1651
  const chunkSize = 32768;
1652
1652
  let binary = "";
1653
1653
  for (let i = 0; i < bytes.length; i += chunkSize) {
1654
- binary += String.fromCharCode.apply(
1655
- null,
1656
- bytes.subarray(i, Math.min(bytes.length, i + chunkSize))
1654
+ binary += String.fromCharCode(
1655
+ ...bytes.subarray(i, Math.min(bytes.length, i + chunkSize))
1657
1656
  );
1658
1657
  }
1659
1658
  return btoa(binary);
@@ -1674,11 +1673,11 @@ var RpcSystemCallHandler = class {
1674
1673
  debugLog?.log(`'${peer.ref}': handle ${method}#${relatedId}`);
1675
1674
  switch (method) {
1676
1675
  case RpcSystemCalls.ok: {
1677
- const call = peer.outbound.get(relatedId);
1676
+ const call = peer.outboundCalls.get(relatedId);
1678
1677
  if (call !== void 0) {
1679
1678
  call.completedStage |= RpcCallStage.ResultReady;
1680
1679
  if (call.removeOnOk) {
1681
- peer.outbound.remove(relatedId);
1680
+ peer.outboundCalls.remove(relatedId);
1682
1681
  }
1683
1682
  call.result.resolve(args[0]);
1684
1683
  }
@@ -1689,7 +1688,7 @@ var RpcSystemCallHandler = class {
1689
1688
  break;
1690
1689
  }
1691
1690
  case RpcSystemCalls.error: {
1692
- const call = peer.outbound.remove(relatedId);
1691
+ const call = peer.outboundCalls.remove(relatedId);
1693
1692
  if (call !== void 0) {
1694
1693
  const errorInfo = args[0];
1695
1694
  const msg = errorInfo?.Message ?? errorInfo?.message ?? "RPC error";
@@ -1704,10 +1703,11 @@ var RpcSystemCallHandler = class {
1704
1703
  break;
1705
1704
  }
1706
1705
  case RpcSystemCalls.cancel: {
1707
- peer.inbound.remove(relatedId);
1706
+ peer.inboundCalls.remove(relatedId);
1708
1707
  break;
1709
1708
  }
1710
1709
  case RpcSystemCalls.keepAlive: {
1710
+ peer.notifyKeepAliveReceived();
1711
1711
  break;
1712
1712
  }
1713
1713
  case RpcSystemCalls.item: {
@@ -1798,7 +1798,7 @@ var RpcSystemCallHandler = class {
1798
1798
  if (bytes === null) continue;
1799
1799
  const callIds = IncreasingSeqCompressor.deserialize(bytes);
1800
1800
  for (const callId of callIds) {
1801
- if (peer.inbound.get(callId) === void 0) {
1801
+ if (peer.inboundCalls.get(callId) === void 0) {
1802
1802
  unknownSet.add(callId);
1803
1803
  }
1804
1804
  }
@@ -1808,8 +1808,8 @@ var RpcSystemCallHandler = class {
1808
1808
  } catch {
1809
1809
  }
1810
1810
  const responseBytes = IncreasingSeqCompressor.serialize(unknownIds);
1811
- const responseValue = peer.format.isBinary ? responseBytes : base64Encode(responseBytes);
1812
- peer.hub.systemCallSender.ok(peer.connection, peer.format, relatedId, responseValue);
1811
+ const responseValue = peer.serializationFormat.isBinary ? responseBytes : base64Encode(responseBytes);
1812
+ peer.hub.systemCallSender.ok(peer.connection, peer.serializationFormat, relatedId, responseValue);
1813
1813
  }
1814
1814
  };
1815
1815
  function _toBytes(value) {
@@ -2006,15 +2006,6 @@ var RpcSharedObjectTracker = class {
2006
2006
  // src/rpc-peer.ts
2007
2007
  import { EventHandlerSet as EventHandlerSet3, PromiseSource as PromiseSource5 } from "@actuallab/core";
2008
2008
 
2009
- // src/rpc-client-peer-reconnect-delayer.ts
2010
- import { RetryDelaySeq, RetryDelayer } from "@actuallab/core";
2011
- var RpcClientPeerReconnectDelayer = class extends RetryDelayer {
2012
- constructor() {
2013
- super();
2014
- this.delays = RetryDelaySeq.exp(1e3, 6e4);
2015
- }
2016
- };
2017
-
2018
2009
  // src/rpc-serialization-format.ts
2019
2010
  import { Decoder as Decoder3 } from "@msgpack/msgpack";
2020
2011
  var RpcSerializationFormat = class {
@@ -2174,55 +2165,106 @@ var RpcSerializationFormatResolver = class _RpcSerializationFormatResolver {
2174
2165
 
2175
2166
  // src/rpc-peer.ts
2176
2167
  var RPC_CLOSE_CODE_UNSUPPORTED_FORMAT = 4001;
2168
+ var HANDSHAKE_TIMEOUT_MS = 1e4;
2169
+ var KEEP_ALIVE_PERIOD_MS = 15e3;
2170
+ var KEEP_ALIVE_TIMEOUT_MS = 55e3;
2177
2171
  var { debugLog: debugLog2, infoLog, warnLog: warnLog6, errorLog: errorLog2 } = getLogs("RpcPeer");
2178
2172
  var defaultConnectionUrlResolver = (peer) => {
2179
- const sep = peer.ref.includes("?") ? "&" : "?";
2180
- return peer.ref + sep + `clientId=${peer.clientId}&f=${peer.serializationFormat}`;
2173
+ const formatKey = peer.serializationFormat.key;
2174
+ try {
2175
+ const url = new URL(peer.ref);
2176
+ url.searchParams.set("clientId", peer.clientId);
2177
+ url.searchParams.set("f", formatKey);
2178
+ return url.toString();
2179
+ } catch {
2180
+ const sep = peer.ref.includes("?") ? "&" : "?";
2181
+ return peer.ref + sep + `clientId=${peer.clientId}&f=${formatKey}`;
2182
+ }
2181
2183
  };
2184
+ function parseFormatFromUrl(url) {
2185
+ try {
2186
+ const f = new URL(url).searchParams.get("f");
2187
+ return f ?? void 0;
2188
+ } catch {
2189
+ return void 0;
2190
+ }
2191
+ }
2182
2192
  var RpcPeer = class {
2193
+ hub;
2183
2194
  /** Routing/addressing key — used as key in hub.peers (URL for clients, "server://{uuid}" for servers). */
2184
2195
  ref;
2185
2196
  /** Auto-generated GUID — used in handshakes only. */
2186
2197
  id = crypto.randomUUID();
2187
- _hub;
2188
- _connection;
2189
- outbound = new RpcOutboundCallTracker();
2190
- inbound = new RpcInboundCallTracker();
2198
+ outboundCalls = new RpcOutboundCallTracker();
2199
+ inboundCalls = new RpcInboundCallTracker();
2191
2200
  remoteObjects = new RpcRemoteObjectTracker();
2192
2201
  sharedObjects = new RpcSharedObjectTracker();
2193
- connected = new EventHandlerSet3();
2194
- disconnected = new EventHandlerSet3();
2202
+ /** Fired on every state transition with the new value. Initial state is
2203
+ * `Disconnected`; `Connecting` is entered when the peer starts an
2204
+ * attempt (the reconnect delay is part of this phase); `Connected` is
2205
+ * entered only after the handshake has succeeded. */
2206
+ connectionStateChanged = new EventHandlerSet3();
2207
+ _connection;
2208
+ _connectionState = 0 /* Disconnected */;
2195
2209
  _pendingSends = [];
2196
2210
  _keepAliveTimer;
2197
- constructor(ref, hub) {
2211
+ _keepAliveWatchdog;
2212
+ /** Wall-clock time of the last inbound `$sys.KeepAlive`, or 0 if none yet
2213
+ * for the current connection. Useful for diagnostics. */
2214
+ _lastKeepAliveAt = 0;
2215
+ /** Outbound keep-alive send period (per-peer override for tests). */
2216
+ keepAlivePeriodMs = KEEP_ALIVE_PERIOD_MS;
2217
+ /** Inbound keep-alive silence tolerance (per-peer override for tests). */
2218
+ keepAliveTimeoutMs = KEEP_ALIVE_TIMEOUT_MS;
2219
+ constructor(hub, ref) {
2220
+ this.hub = hub;
2198
2221
  this.ref = ref;
2199
- this._hub = hub;
2200
- }
2201
- get hub() {
2202
- return this._hub;
2203
2222
  }
2204
2223
  get connection() {
2205
2224
  return this._connection;
2206
2225
  }
2226
+ get connectionState() {
2227
+ return this._connectionState;
2228
+ }
2207
2229
  get isConnected() {
2208
2230
  return this._connection?.isOpen ?? false;
2209
2231
  }
2232
+ _setConnectionState(value) {
2233
+ if (this._connectionState === value) return;
2234
+ this._connectionState = value;
2235
+ if (value === 2 /* Connected */)
2236
+ this._armKeepAliveWatchdog();
2237
+ else if (value === 0 /* Disconnected */)
2238
+ this._disarmKeepAliveWatchdog();
2239
+ this.connectionStateChanged.trigger(value);
2240
+ }
2241
+ /** Resolves on the next transition into `Connected` — or immediately if
2242
+ * already in that state. */
2243
+ whenConnected() {
2244
+ if (this._connectionState === 2 /* Connected */)
2245
+ return Promise.resolve();
2246
+ return new Promise((resolve) => {
2247
+ const handler = (state) => {
2248
+ if (state !== 2 /* Connected */) return;
2249
+ this.connectionStateChanged.remove(handler);
2250
+ resolve();
2251
+ };
2252
+ this.connectionStateChanged.add(handler);
2253
+ });
2254
+ }
2210
2255
  setupConnection(conn) {
2211
2256
  this._connection = conn;
2212
2257
  conn.messageReceived.add((raw) => this._handleMessage(raw));
2213
2258
  conn.closed.add((ev) => {
2214
2259
  this._connection = void 0;
2215
- this._stopKeepAlive();
2216
2260
  if (ev.reason)
2217
2261
  infoLog?.log(`'${this.ref}': Disconnected: ${ev.reason} (code=${ev.code})`);
2218
2262
  else
2219
2263
  infoLog?.log(`'${this.ref}': Disconnected`);
2220
- this.disconnected.trigger(ev);
2264
+ this._setConnectionState(0 /* Disconnected */);
2221
2265
  });
2222
2266
  void conn.whenConnected.then(() => {
2223
- infoLog?.log(`'${this.ref}': Connected`);
2224
- this.connected.trigger();
2225
- this._startKeepAlive();
2267
+ infoLog?.log(`'${this.ref}': WS open`);
2226
2268
  });
2227
2269
  }
2228
2270
  /**
@@ -2230,29 +2272,29 @@ var RpcPeer = class {
2230
2272
  * If not set explicitly, defaults to the resolver's default (JSON /
2231
2273
  * json5np). Subclasses can pre-assign in their constructor.
2232
2274
  */
2233
- _format;
2234
- get format() {
2235
- return this._format ?? RpcSerializationFormatResolver.Default.defaultFormat;
2275
+ _serializationFormat;
2276
+ get serializationFormat() {
2277
+ return this._serializationFormat ?? RpcSerializationFormatResolver.Default.defaultFormat;
2236
2278
  }
2237
- set format(value) {
2238
- this._format = value;
2279
+ set serializationFormat(value) {
2280
+ this._serializationFormat = value;
2239
2281
  }
2240
2282
  call(method, args, options) {
2241
- const callId = this.outbound.nextId();
2283
+ const callId = this.outboundCalls.nextId();
2242
2284
  const outboundCall = options?.outboundCallFactory ? options.outboundCallFactory(callId, method) : new RpcOutboundCall(callId, method, options?.remoteExecutionMode ?? 7);
2243
2285
  const envelope = {
2244
2286
  Method: method,
2245
2287
  RelatedId: callId,
2246
2288
  CallType: options?.callTypeId ?? 0
2247
2289
  };
2248
- outboundCall.serializedWireData = this.format.serializeMessage(
2290
+ outboundCall.serializedWireData = this.serializationFormat.serializeMessage(
2249
2291
  envelope,
2250
2292
  args,
2251
2293
  this._connection?.encoder,
2252
- this._hub.registry
2294
+ this.hub.registry
2253
2295
  );
2254
2296
  if (this._connection !== void 0) {
2255
- this.outbound.register(outboundCall);
2297
+ this.outboundCalls.register(outboundCall);
2256
2298
  this._sendWireData(outboundCall.serializedWireData);
2257
2299
  } else {
2258
2300
  this._pendingSends.push(outboundCall);
@@ -2260,11 +2302,11 @@ var RpcPeer = class {
2260
2302
  const signal = options?.signal;
2261
2303
  if (signal !== void 0) {
2262
2304
  const onAbort = () => {
2263
- if (this.outbound.remove(callId) !== void 0) {
2305
+ if (this.outboundCalls.remove(callId) !== void 0) {
2264
2306
  outboundCall.result.reject(new Error("Call cancelled."));
2265
2307
  outboundCall.onDisconnect();
2266
2308
  if (this._connection !== void 0)
2267
- this._hub.systemCallSender.cancel(this._connection, this.format, callId);
2309
+ this.hub.systemCallSender.cancel(this._connection, this.serializationFormat, callId);
2268
2310
  } else {
2269
2311
  const idx = this._pendingSends.findIndex(
2270
2312
  (c) => c.callId === callId
@@ -2286,17 +2328,16 @@ var RpcPeer = class {
2286
2328
  callNoWait(method, args) {
2287
2329
  if (this._connection === void 0) return;
2288
2330
  const envelope = { Method: method, RelatedId: 0 };
2289
- const wireData = this.format.serializeMessage(
2331
+ const wireData = this.serializationFormat.serializeMessage(
2290
2332
  envelope,
2291
2333
  args,
2292
2334
  this._connection.encoder,
2293
- this._hub.registry
2335
+ this.hub.registry
2294
2336
  );
2295
2337
  this._sendWireData(wireData);
2296
2338
  }
2297
2339
  close() {
2298
2340
  infoLog?.log(`'${this.ref}': Stopping`);
2299
- this._stopKeepAlive();
2300
2341
  this.remoteObjects.disconnectAll();
2301
2342
  this.sharedObjects.disconnectAll();
2302
2343
  for (const call of this._pendingSends) {
@@ -2305,10 +2346,11 @@ var RpcPeer = class {
2305
2346
  call.onDisconnect();
2306
2347
  }
2307
2348
  this._pendingSends.length = 0;
2308
- this.outbound.rejectAll(new Error("Peer closed."));
2309
- this.outbound.invalidateAll();
2349
+ this.outboundCalls.rejectAll(new Error("Peer closed."));
2350
+ this.outboundCalls.invalidateAll();
2310
2351
  this._connection?.close();
2311
- this._hub.peers.delete(this.ref);
2352
+ this.hub.peers.delete(this.ref);
2353
+ this._setConnectionState(0 /* Disconnected */);
2312
2354
  infoLog?.log(`'${this.ref}': Stopped`);
2313
2355
  }
2314
2356
  /** Send any messages buffered while disconnected. Call after connection + handshake are ready. */
@@ -2316,7 +2358,7 @@ var RpcPeer = class {
2316
2358
  if (this._pendingSends.length === 0 || this._connection === void 0)
2317
2359
  return;
2318
2360
  for (const call of this._pendingSends) {
2319
- this.outbound.register(call);
2361
+ this.outboundCalls.register(call);
2320
2362
  this._sendWireData(call.serializedWireData);
2321
2363
  }
2322
2364
  this._pendingSends.length = 0;
@@ -2365,7 +2407,7 @@ var RpcPeer = class {
2365
2407
  return;
2366
2408
  }
2367
2409
  if (method.startsWith("$sys")) {
2368
- this._hub.systemCallHandler.handle(message, args, this);
2410
+ this.hub.systemCallHandler.handle(message, args, this);
2369
2411
  return;
2370
2412
  }
2371
2413
  this._handleInbound(message, args);
@@ -2374,12 +2416,12 @@ var RpcPeer = class {
2374
2416
  const method = message.Method ?? "";
2375
2417
  const relatedId = message.RelatedId ?? 0;
2376
2418
  const call = new RpcInboundCall(relatedId, method, args);
2377
- const methodDef = this._hub.serviceHost.getMethodDef(method);
2419
+ const methodDef = this.hub.serviceHost.getMethodDef(method);
2378
2420
  const isNoWait = methodDef?.noWait === true;
2379
2421
  if (!isNoWait) {
2380
- this.inbound.register(call);
2422
+ this.inboundCalls.register(call);
2381
2423
  }
2382
- const serviceHost = this._hub.serviceHost;
2424
+ const serviceHost = this.hub.serviceHost;
2383
2425
  void (async () => {
2384
2426
  try {
2385
2427
  const context = this._connection !== void 0 ? {
@@ -2394,25 +2436,25 @@ var RpcPeer = class {
2394
2436
  );
2395
2437
  if (methodDef?.stream === true && this._connection !== void 0) {
2396
2438
  const stream = result instanceof RpcStream ? result : new RpcStream(result);
2397
- this._hub.systemCallSender.ok(
2439
+ this.hub.systemCallSender.ok(
2398
2440
  this._connection,
2399
- this.format,
2441
+ this.serializationFormat,
2400
2442
  relatedId,
2401
2443
  stream.toRef(this)
2402
2444
  );
2403
2445
  } else if (!isNoWait && this._connection !== void 0) {
2404
- this._hub.systemCallSender.ok(
2446
+ this.hub.systemCallSender.ok(
2405
2447
  this._connection,
2406
- this.format,
2448
+ this.serializationFormat,
2407
2449
  relatedId,
2408
2450
  result
2409
2451
  );
2410
2452
  }
2411
2453
  } catch (e) {
2412
2454
  if (!isNoWait && this._connection !== void 0) {
2413
- this._hub.systemCallSender.error(
2455
+ this.hub.systemCallSender.error(
2414
2456
  this._connection,
2415
- this.format,
2457
+ this.serializationFormat,
2416
2458
  relatedId,
2417
2459
  e
2418
2460
  );
@@ -2420,36 +2462,64 @@ var RpcPeer = class {
2420
2462
  debugLog2?.log(`'${this.ref}': Inbound call failed: ${method}#${relatedId}`, e);
2421
2463
  } finally {
2422
2464
  if (!isNoWait) {
2423
- this.inbound.remove(relatedId);
2465
+ this.inboundCalls.remove(relatedId);
2424
2466
  }
2425
2467
  }
2426
2468
  })();
2427
2469
  }
2428
- _startKeepAlive() {
2429
- this._keepAliveTimer = setInterval(() => {
2470
+ /** Start sending `$sys.KeepAlive` pings and arm the silence watchdog.
2471
+ * Called when the peer transitions into `Connected` (after handshake).
2472
+ * Each inbound `$sys.KeepAlive` re-arms the watchdog; if it fires, the
2473
+ * WebSocket is force-closed so the normal disconnect path runs
2474
+ * (`conn.closed` → `connectionState = Disconnected` → UI).
2475
+ * Mirrors .NET `RpcObjectTrackers.Maintain`'s `KeepAliveTimeout` branch. */
2476
+ _armKeepAliveWatchdog() {
2477
+ this._keepAliveTimer ??= setInterval(() => {
2430
2478
  if (this._connection !== void 0) {
2431
- this._hub.systemCallSender.keepAlive(this._connection, this.format, [
2479
+ this.hub.systemCallSender.keepAlive(this._connection, this.serializationFormat, [
2432
2480
  ...this.remoteObjects.keys()
2433
2481
  ]);
2434
2482
  }
2435
- }, 15e3);
2483
+ }, this.keepAlivePeriodMs);
2484
+ if (this._keepAliveWatchdog !== void 0)
2485
+ clearTimeout(this._keepAliveWatchdog);
2486
+ this._keepAliveWatchdog = setTimeout(() => {
2487
+ this._keepAliveWatchdog = void 0;
2488
+ if (this._connection === void 0) return;
2489
+ warnLog6?.log(
2490
+ `'${this.ref}': no keep-alive for ${this.keepAliveTimeoutMs}ms \u2014 force-closing connection`
2491
+ );
2492
+ this._connection.close();
2493
+ }, this.keepAliveTimeoutMs);
2436
2494
  }
2437
- _stopKeepAlive() {
2495
+ /** Stop sending keep-alives and clear the silence watchdog. Called when
2496
+ * the peer transitions into `Disconnected`. */
2497
+ _disarmKeepAliveWatchdog() {
2438
2498
  if (this._keepAliveTimer !== void 0) {
2439
2499
  clearInterval(this._keepAliveTimer);
2440
2500
  this._keepAliveTimer = void 0;
2441
2501
  }
2502
+ if (this._keepAliveWatchdog !== void 0) {
2503
+ clearTimeout(this._keepAliveWatchdog);
2504
+ this._keepAliveWatchdog = void 0;
2505
+ }
2506
+ }
2507
+ /** Called by `RpcSystemCallHandler` when a `$sys.KeepAlive` message
2508
+ * arrives — records the timestamp and re-arms the silence watchdog. */
2509
+ notifyKeepAliveReceived() {
2510
+ this._lastKeepAliveAt = Date.now();
2511
+ if (this._keepAliveWatchdog !== void 0)
2512
+ this._armKeepAliveWatchdog();
2442
2513
  }
2443
2514
  };
2444
- var RpcPeerConnectionKind = /* @__PURE__ */ ((RpcPeerConnectionKind2) => {
2445
- RpcPeerConnectionKind2[RpcPeerConnectionKind2["Disconnected"] = 0] = "Disconnected";
2446
- RpcPeerConnectionKind2[RpcPeerConnectionKind2["Connecting"] = 1] = "Connecting";
2447
- RpcPeerConnectionKind2[RpcPeerConnectionKind2["Connected"] = 2] = "Connected";
2448
- return RpcPeerConnectionKind2;
2449
- })(RpcPeerConnectionKind || {});
2515
+ var RpcConnectionState = /* @__PURE__ */ ((RpcConnectionState2) => {
2516
+ RpcConnectionState2[RpcConnectionState2["Disconnected"] = 0] = "Disconnected";
2517
+ RpcConnectionState2[RpcConnectionState2["Connecting"] = 1] = "Connecting";
2518
+ RpcConnectionState2[RpcConnectionState2["Connected"] = 2] = "Connected";
2519
+ return RpcConnectionState2;
2520
+ })(RpcConnectionState || {});
2450
2521
  var RpcClientPeer = class extends RpcPeer {
2451
2522
  _disposed = false;
2452
- _connectionKind = 0 /* Disconnected */;
2453
2523
  _tryIndex = 0;
2454
2524
  _handshakeIndex = 0;
2455
2525
  /** Index from the MOST RECENTLY RECEIVED server handshake — sent back
@@ -2463,18 +2533,20 @@ var RpcClientPeer = class extends RpcPeer {
2463
2533
  _reconnectsAt = 0;
2464
2534
  /** Base64url-encoded peer ID — matches .NET's RpcClientPeer.ClientId (Guid.ToBase64Url). */
2465
2535
  clientId;
2466
- /**
2467
- * Serialization format key sent to the server via `f=` query parameter.
2468
- * TS uses plain JSON.stringify (no polymorphic type wrapping), so `json5np`
2469
- * (no-polymorphism) is the correct default.
2470
- */
2471
- serializationFormat;
2472
2536
  /** Builds the WebSocket URL for this peer. Replace to customize URL construction. */
2473
2537
  connectionUrlResolver = defaultConnectionUrlResolver;
2538
+ /** Optional WebSocket factory. When set, `run()` uses this instead of the
2539
+ * global `new WebSocket(url)` constructor. Used by tests (to inject mock
2540
+ * sockets) and by the Node load-test harness (to attach `Session` /
2541
+ * `Cookie` headers to the WS upgrade, which the browser `WebSocket`
2542
+ * constructor can't do). Must be set before `start()`. */
2543
+ webSocketFactory;
2544
+ /** Max time to wait for the server's handshake response before aborting
2545
+ * the connection attempt. Mirrors .NET's `Hub.Limits.HandshakeTimeout`. */
2546
+ handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS;
2474
2547
  peerChanged = new EventHandlerSet3();
2475
2548
  /** Fired when the server rejects the connection due to an unsupported serialization format (close code 4010). */
2476
2549
  unsupportedFormat = new EventHandlerSet3();
2477
- reconnectDelayer = new RpcClientPeerReconnectDelayer();
2478
2550
  reconnectsAtChanged = new EventHandlerSet3();
2479
2551
  get reconnectsAt() {
2480
2552
  return this._reconnectsAt;
@@ -2484,17 +2556,32 @@ var RpcClientPeer = class extends RpcPeer {
2484
2556
  this._reconnectsAt = value;
2485
2557
  this.reconnectsAtChanged.trigger();
2486
2558
  }
2487
- constructor(hub, url, serializationFormat) {
2488
- super(url, hub);
2559
+ /** The promise returned by the reconnect loop. `undefined` until
2560
+ * `start()` is invoked (which happens automatically when
2561
+ * `mustStart = true` is passed to the ctor). */
2562
+ whenRunning;
2563
+ /**
2564
+ * @param hub RpcHub instance this peer will be tracked by
2565
+ * @param url Full WebSocket URL — the serialization format is taken from
2566
+ * the `f=` query parameter (e.g. `wss://host/rpc/ws?f=msgpack6`);
2567
+ * falls back to the resolver's default when absent. Use
2568
+ * {@link RpcPeerRefBuilder.forClient} to compose URLs with a format.
2569
+ * @param mustStart Kick off the reconnect loop automatically at the end of
2570
+ * construction (default `true`). Pass `false` if you need to
2571
+ * tweak `reconnectDelayer`, `handshakeTimeoutMs`, or similar
2572
+ * options before the loop starts — then call `peer.start()`
2573
+ * manually.
2574
+ */
2575
+ constructor(hub, url, mustStart = true) {
2576
+ super(hub, url);
2489
2577
  this.clientId = guidToBase64Url(this.id);
2490
2578
  const resolver = RpcSerializationFormatResolver.Default;
2491
- this.serializationFormat = serializationFormat ?? resolver.defaultFormatKey;
2492
- this.format = resolver.get(this.serializationFormat);
2493
- }
2494
- get connectionKind() {
2495
- return this._connectionKind;
2579
+ this.serializationFormat = resolver.get(parseFormatFromUrl(url) ?? resolver.defaultFormatKey);
2580
+ if (mustStart)
2581
+ this.start();
2496
2582
  }
2497
2583
  /** One-shot connection for tests — no reconnection loop, no handshake.
2584
+ * @param conn Rpc connection
2498
2585
  * @param isPeerChanged Whether to treat this as a peer-identity change
2499
2586
  * (default `true` — no handshake was exchanged, so stage-3 compute calls
2500
2587
  * self-invalidate and remote/shared objects are disposed). Pass `false`
@@ -2503,97 +2590,137 @@ var RpcClientPeer = class extends RpcPeer {
2503
2590
  this.setupConnection(conn);
2504
2591
  void this._reconnect(isPeerChanged);
2505
2592
  }
2506
- /** Start the reconnection loop — runs until disposed. */
2507
- async run(wsFactory) {
2593
+ /** Kick off the reconnect loop. Idempotent — subsequent calls are
2594
+ * no-ops; await `peer.whenRunning` if you need the loop's completion
2595
+ * promise. Customize `peer.webSocketFactory` first if you need a
2596
+ * non-default WebSocket constructor. */
2597
+ start() {
2598
+ void this.run();
2599
+ }
2600
+ /** The reconnect loop itself. Exposed as `protected` so subclasses (and
2601
+ * `start()`) can drive it; public callers should use {@link start}. */
2602
+ run() {
2603
+ return this.whenRunning ??= this._runImpl();
2604
+ }
2605
+ async _runImpl() {
2606
+ if (this._disposed) return;
2508
2607
  const connUrl = this.connectionUrlResolver(this);
2509
2608
  infoLog?.log(`'${this.ref}': Started (Client)`);
2510
- while (!this._disposed) {
2511
- let lastCloseCode = 0;
2512
- let lastCloseReason = "";
2513
- try {
2514
- infoLog?.log(`'${this.ref}': Connecting to ${connUrl}`);
2515
- const ws = wsFactory?.(connUrl) ?? new WebSocket(connUrl);
2516
- const conn = new RpcWebSocketConnection(
2517
- ws,
2518
- this.format.isBinary,
2519
- this.format,
2520
- this._hub.registry
2521
- );
2522
- this._connectionKind = 1 /* Connecting */;
2523
- conn.closed.add((ev) => {
2524
- lastCloseCode = ev.code;
2525
- lastCloseReason = ev.reason;
2526
- });
2527
- this._pendingHandshake = new PromiseSource5();
2528
- this.setupConnection(conn);
2529
- this._connection = void 0;
2530
- const closedRejection = new Promise(
2531
- (_, reject) => conn.closed.add(
2532
- () => reject(new Error("Connection failed"))
2533
- )
2534
- );
2535
- closedRejection.catch(() => {
2536
- });
2537
- await Promise.race([conn.whenConnected, closedRejection]);
2609
+ try {
2610
+ while (!this._disposed) {
2611
+ this._setConnectionState(1 /* Connecting */);
2612
+ const retryDelay = this.hub.reconnectDelayer.getDelay(this._tryIndex);
2613
+ if (retryDelay.isLimitExceeded) {
2614
+ warnLog6?.log(`'${this.ref}': Reconnect limit exceeded - shutting down`);
2615
+ this._disposed = true;
2616
+ break;
2617
+ }
2618
+ this._setReconnectsAt(retryDelay.endsAt);
2619
+ try {
2620
+ await retryDelay.promise;
2621
+ } finally {
2622
+ this._setReconnectsAt(0);
2623
+ }
2624
+ if (this._disposed) break;
2625
+ let lastCloseCode = 0;
2626
+ let lastCloseReason = "";
2538
2627
  try {
2539
- this._hub.systemCallSender.handshake(
2540
- conn,
2541
- this.format,
2542
- this.id,
2543
- this._hub.hubId,
2544
- ++this._handshakeIndex
2628
+ infoLog?.log(`'${this.ref}': Connecting to ${connUrl}`);
2629
+ const ws = this.webSocketFactory?.(connUrl) ?? new WebSocket(connUrl);
2630
+ const conn = new RpcWebSocketConnection(
2631
+ ws,
2632
+ this.serializationFormat.isBinary,
2633
+ this.serializationFormat,
2634
+ this.hub.registry
2545
2635
  );
2636
+ const whenClosed = new PromiseSource5();
2637
+ const closedController = new AbortController();
2638
+ conn.closed.add((ev) => {
2639
+ lastCloseCode = ev.code;
2640
+ lastCloseReason = ev.reason;
2641
+ whenClosed.resolve(ev);
2642
+ closedController.abort();
2643
+ });
2644
+ const closedRejection = whenClosed.promise.then(() => {
2645
+ throw new Error("Connection failed");
2646
+ });
2647
+ closedRejection.catch(() => {
2648
+ });
2649
+ this._pendingHandshake = new PromiseSource5();
2650
+ this.setupConnection(conn);
2651
+ this._connection = void 0;
2652
+ await Promise.race([conn.whenConnected, closedRejection]);
2653
+ try {
2654
+ this.hub.systemCallSender.handshake(
2655
+ conn,
2656
+ this.serializationFormat,
2657
+ this.id,
2658
+ this.hub.hubId,
2659
+ ++this._handshakeIndex
2660
+ );
2661
+ } catch (e) {
2662
+ warnLog6?.log(`'${this.ref}': Failed to send handshake`, e);
2663
+ throw e;
2664
+ }
2665
+ const handshakeTimeoutMs = this.handshakeTimeoutMs;
2666
+ let handshakeTimer;
2667
+ const handshakeTimeout = new Promise((_, reject) => {
2668
+ handshakeTimer = setTimeout(() => {
2669
+ reject(new Error("Handshake timeout"));
2670
+ }, handshakeTimeoutMs);
2671
+ });
2672
+ handshakeTimeout.catch(() => {
2673
+ });
2674
+ let remoteHandshake;
2675
+ try {
2676
+ remoteHandshake = await Promise.race([
2677
+ this._pendingHandshake.promise,
2678
+ closedRejection,
2679
+ handshakeTimeout
2680
+ ]);
2681
+ } catch (e) {
2682
+ if (e instanceof Error && e.message === "Handshake timeout") {
2683
+ warnLog6?.log(`'${this.ref}': Handshake timed out after ${handshakeTimeoutMs}ms`);
2684
+ conn.close();
2685
+ }
2686
+ throw e;
2687
+ } finally {
2688
+ if (handshakeTimer !== void 0) clearTimeout(handshakeTimer);
2689
+ this._pendingHandshake = void 0;
2690
+ }
2691
+ this._remoteHandshakeIndex = remoteHandshake.Index ?? 0;
2692
+ const remoteHubId = remoteHandshake.RemoteHubId;
2693
+ let isPeerChanged = false;
2694
+ if (remoteHubId !== void 0) {
2695
+ isPeerChanged = this._lastRemoteHubId !== void 0 && this._lastRemoteHubId !== remoteHubId;
2696
+ if (isPeerChanged) {
2697
+ this.inboundCalls.clear();
2698
+ infoLog?.log(`'${this.ref}': Peer changed`);
2699
+ this.peerChanged.trigger();
2700
+ }
2701
+ this._lastRemoteHubId = remoteHubId;
2702
+ }
2703
+ infoLog?.log(`'${this.ref}': Handshake succeeded, peerChanged=${isPeerChanged}`);
2704
+ this._connection = conn;
2705
+ this._setConnectionState(2 /* Connected */);
2706
+ this._tryIndex = 0;
2707
+ await this._reconnect(isPeerChanged, closedController.signal);
2708
+ await whenClosed.promise;
2546
2709
  } catch (e) {
2547
- warnLog6?.log(`'${this.ref}': Failed to send handshake`, e);
2548
- throw e;
2710
+ debugLog2?.log(`'${this.ref}': run() iteration failed:`, e);
2549
2711
  }
2550
- const remoteHandshake = await Promise.race([
2551
- this._pendingHandshake.promise,
2552
- closedRejection
2553
- ]);
2554
- this._pendingHandshake = void 0;
2555
- this._remoteHandshakeIndex = remoteHandshake.Index ?? 0;
2556
- const remoteHubId = remoteHandshake.RemoteHubId;
2557
- let isPeerChanged = false;
2558
- if (remoteHubId !== void 0) {
2559
- isPeerChanged = this._lastRemoteHubId !== void 0 && this._lastRemoteHubId !== remoteHubId;
2560
- if (isPeerChanged) {
2561
- this.inbound.clear();
2562
- infoLog?.log(`'${this.ref}': Peer changed`);
2563
- this.peerChanged.trigger();
2564
- }
2565
- this._lastRemoteHubId = remoteHubId;
2712
+ this._setConnectionState(0 /* Disconnected */);
2713
+ if (this._disposed) break;
2714
+ if (lastCloseCode === RPC_CLOSE_CODE_UNSUPPORTED_FORMAT) {
2715
+ warnLog6?.log(`'${this.ref}': Unsupported serialization format - shutting down`);
2716
+ this.unsupportedFormat.trigger({ reason: lastCloseReason });
2717
+ this._disposed = true;
2718
+ break;
2566
2719
  }
2567
- infoLog?.log(`'${this.ref}': Handshake succeeded, peerChanged=${isPeerChanged}`);
2568
- this._connection = conn;
2569
- this._connectionKind = 2 /* Connected */;
2570
- this._tryIndex = 0;
2571
- await this._reconnect(isPeerChanged);
2572
- await new Promise((r) => conn.closed.add(() => r()));
2573
- } catch (e) {
2574
- debugLog2?.log(`'${this.ref}': run() iteration failed:`, e);
2575
- }
2576
- this._connectionKind = 0 /* Disconnected */;
2577
- if (this._disposed) break;
2578
- if (lastCloseCode === RPC_CLOSE_CODE_UNSUPPORTED_FORMAT) {
2579
- warnLog6?.log(`'${this.ref}': Unsupported serialization format - shutting down`);
2580
- this.unsupportedFormat.trigger({ reason: lastCloseReason });
2581
- this._disposed = true;
2582
- break;
2583
- }
2584
- this._tryIndex++;
2585
- const delay = this.reconnectDelayer.getDelay(this._tryIndex);
2586
- if (delay.isLimitExceeded) {
2587
- warnLog6?.log(`'${this.ref}': Reconnect limit exceeded - shutting down`);
2588
- this._disposed = true;
2589
- break;
2590
- }
2591
- this._setReconnectsAt(delay.endsAt);
2592
- try {
2593
- await delay.promise;
2594
- } finally {
2595
- this._setReconnectsAt(0);
2720
+ this._tryIndex++;
2596
2721
  }
2722
+ } finally {
2723
+ this._setConnectionState(0 /* Disconnected */);
2597
2724
  }
2598
2725
  }
2599
2726
  /**
@@ -2615,7 +2742,7 @@ var RpcClientPeer = class extends RpcPeer {
2615
2742
  * are always self-invalidated — TS doesn't track compute-call stages
2616
2743
  * across reconnect, so the safest behavior is to force a fresh compute.
2617
2744
  */
2618
- async _reconnect(isPeerChanged) {
2745
+ async _reconnect(isPeerChanged, closedSignal) {
2619
2746
  if (this._connection === void 0) return;
2620
2747
  if (isPeerChanged) {
2621
2748
  this.remoteObjects.disconnectAll();
@@ -2624,7 +2751,7 @@ var RpcClientPeer = class extends RpcPeer {
2624
2751
  this.remoteObjects.reconnectAll();
2625
2752
  }
2626
2753
  const eligible = [];
2627
- for (const call of [...this.outbound.values()]) {
2754
+ for (const call of [...this.outboundCalls.values()]) {
2628
2755
  const mode = call.remoteExecutionMode;
2629
2756
  if (!(mode & 2)) {
2630
2757
  if (!call.result.isCompleted)
@@ -2632,7 +2759,7 @@ var RpcClientPeer = class extends RpcPeer {
2632
2759
  "Outbound call failed: disconnected and AllowReconnect is not set."
2633
2760
  ));
2634
2761
  call.onDisconnect();
2635
- this.outbound.remove(call.callId);
2762
+ this.outboundCalls.remove(call.callId);
2636
2763
  continue;
2637
2764
  }
2638
2765
  if (isPeerChanged && !(mode & 4)) {
@@ -2641,19 +2768,19 @@ var RpcClientPeer = class extends RpcPeer {
2641
2768
  "Outbound call failed: reconnected to a different peer and AllowResend is not set."
2642
2769
  ));
2643
2770
  call.onDisconnect();
2644
- this.outbound.remove(call.callId);
2771
+ this.outboundCalls.remove(call.callId);
2645
2772
  continue;
2646
2773
  }
2647
2774
  eligible.push(call);
2648
2775
  }
2649
2776
  let unknownIds = null;
2650
2777
  if (!isPeerChanged && eligible.length > 0) {
2651
- unknownIds = await this._reconcileReconnect(eligible);
2778
+ unknownIds = await this._reconcileReconnect(eligible, closedSignal);
2652
2779
  }
2653
2780
  for (const call of eligible) {
2654
2781
  if (!call.removeOnOk && call.result.isCompleted) {
2655
2782
  call.onDisconnect();
2656
- this.outbound.remove(call.callId);
2783
+ this.outboundCalls.remove(call.callId);
2657
2784
  continue;
2658
2785
  }
2659
2786
  if (unknownIds !== null && !unknownIds.has(call.callId)) {
@@ -2676,7 +2803,7 @@ var RpcClientPeer = class extends RpcPeer {
2676
2803
  * native msgpack `map<int, bin>`, matching .NET MessagePack-CSharp's
2677
2804
  * default for `Dictionary<int, byte[]>`.
2678
2805
  */
2679
- async _reconcileReconnect(eligible) {
2806
+ async _reconcileReconnect(eligible, closedSignal) {
2680
2807
  try {
2681
2808
  const byStage = /* @__PURE__ */ new Map();
2682
2809
  for (const call of eligible) {
@@ -2692,7 +2819,7 @@ var RpcClientPeer = class extends RpcPeer {
2692
2819
  if (byStage.size === 0) {
2693
2820
  return new Set(eligible.map((c) => c.callId));
2694
2821
  }
2695
- const isBinary = this.format.isBinary;
2822
+ const isBinary = this.serializationFormat.isBinary;
2696
2823
  const stagesArg = isBinary ? /* @__PURE__ */ new Map() : {};
2697
2824
  for (const [stage, ids] of byStage) {
2698
2825
  ids.sort((a, b) => a - b);
@@ -2707,8 +2834,9 @@ var RpcClientPeer = class extends RpcPeer {
2707
2834
  RpcSystemCalls.reconnect,
2708
2835
  [this._remoteHandshakeIndex, stagesArg],
2709
2836
  {
2710
- remoteExecutionMode: 1
2837
+ remoteExecutionMode: 1,
2711
2838
  /* AwaitForConnection only; must not itself reconnect */
2839
+ signal: closedSignal
2712
2840
  }
2713
2841
  );
2714
2842
  const result = await reconnectCall.result.promise;
@@ -2728,11 +2856,14 @@ var RpcClientPeer = class extends RpcPeer {
2728
2856
  }
2729
2857
  };
2730
2858
  var RpcServerPeer = class extends RpcPeer {
2859
+ // Needed to widen the base `protected constructor` to public.
2860
+ // eslint-disable-next-line @typescript-eslint/no-useless-constructor
2731
2861
  constructor(hub, ref) {
2732
- super(ref, hub);
2862
+ super(hub, ref);
2733
2863
  }
2734
2864
  /** Accept an incoming connection — sets up message handling and handshake response. */
2735
2865
  accept(conn) {
2866
+ this._setConnectionState(1 /* Connecting */);
2736
2867
  this.setupConnection(conn);
2737
2868
  conn.closed.add(() => {
2738
2869
  this.sharedObjects.disconnectAll();
@@ -2740,13 +2871,14 @@ var RpcServerPeer = class extends RpcPeer {
2740
2871
  }
2741
2872
  _onHandshakeReceived(_handshake) {
2742
2873
  if (this._connection !== void 0) {
2743
- this._hub.systemCallSender.handshake(
2874
+ this.hub.systemCallSender.handshake(
2744
2875
  this._connection,
2745
- this.format,
2876
+ this.serializationFormat,
2746
2877
  this.id,
2747
- this._hub.hubId,
2878
+ this.hub.hubId,
2748
2879
  0
2749
2880
  );
2881
+ this._setConnectionState(2 /* Connected */);
2750
2882
  }
2751
2883
  }
2752
2884
  };
@@ -2778,6 +2910,15 @@ function _toBytes2(value) {
2778
2910
  return null;
2779
2911
  }
2780
2912
 
2913
+ // src/rpc-client-peer-reconnect-delayer.ts
2914
+ import { RetryDelaySeq, RetryDelayer } from "@actuallab/core";
2915
+ var RpcClientPeerReconnectDelayer = class extends RetryDelayer {
2916
+ constructor() {
2917
+ super();
2918
+ this.delays = RetryDelaySeq.fixed(100);
2919
+ }
2920
+ };
2921
+
2781
2922
  // src/rpc-service-host.ts
2782
2923
  var { warnLog: warnLog7 } = getLogs("RpcServiceHost");
2783
2924
  var RpcServiceHost = class {
@@ -3380,6 +3521,16 @@ var RpcHub = class {
3380
3521
  /** Method registry for compact format hash ↔ name resolution.
3381
3522
  * Created lazily, populated by addService/addClient. */
3382
3523
  registry = new RpcMethodRegistry();
3524
+ /** Shared reconnect delayer used by every client peer in this hub. Swap in
3525
+ * a custom subclass (e.g. app-level signal-gated) before peers start. */
3526
+ reconnectDelayer = new RpcClientPeerReconnectDelayer();
3527
+ /** URL used by {@link defaultPeer} to resolve / create the default client
3528
+ * peer. Must be set before the first {@link defaultPeer} access. */
3529
+ defaultPeerUrl;
3530
+ /** Factory used by {@link defaultPeer} when the peer needs to be created.
3531
+ * If left undefined, `getPeer`'s built-in default (RpcClientPeer with
3532
+ * format derived from URL or resolver default) is used. */
3533
+ defaultPeerFactory;
3383
3534
  constructor(hubId) {
3384
3535
  this.hubId = hubId ?? crypto.randomUUID();
3385
3536
  this.serviceHost = new RpcServiceHost();
@@ -3388,7 +3539,20 @@ var RpcHub = class {
3388
3539
  this.registry.register(methodName);
3389
3540
  }
3390
3541
  }
3542
+ /** Shortcut for the hub's default client peer. Fails if
3543
+ * {@link defaultPeerUrl} is not set. */
3544
+ get defaultPeer() {
3545
+ if (this.defaultPeerUrl === void 0)
3546
+ throw new Error("RpcHub.defaultPeerUrl is not set.");
3547
+ return this.getClientPeer(this.defaultPeerUrl, this.defaultPeerFactory);
3548
+ }
3549
+ /** Register a peer under its ref. If a different peer is already registered
3550
+ * at the same ref, close it first — the hub guarantees at most one live
3551
+ * peer per ref, and no peer exists outside the hub. */
3391
3552
  addPeer(peer) {
3553
+ const existing = this.peers.get(peer.ref);
3554
+ if (existing !== void 0 && existing !== peer)
3555
+ existing.close();
3392
3556
  this.peers.set(peer.ref, peer);
3393
3557
  }
3394
3558
  removePeer(ref) {
@@ -3399,21 +3563,27 @@ var RpcHub = class {
3399
3563
  warnLog9?.log(`'${ref}': peer is removed from RpcHub`);
3400
3564
  }
3401
3565
  }
3402
- /** Get or create a peer by ref. Server peers have "server://" prefix, everything else is a client peer. */
3403
- getPeer(ref) {
3404
- let peer = this.peers.get(ref);
3405
- if (peer) return peer;
3406
- peer = ref.startsWith("server://") ? new RpcServerPeer(this, ref) : new RpcClientPeer(this, ref);
3566
+ /** Get or create a peer by ref. If the peer does not exist:
3567
+ * - with `factory` provided: it is constructed via `factory(this, ref)`;
3568
+ * - otherwise: server peers (ref starts with `server://`) get a plain
3569
+ * RpcServerPeer, anything else gets a default-configured RpcClientPeer.
3570
+ * The created peer is always registered in this hub. */
3571
+ getPeer(ref, factory) {
3572
+ const existing = this.peers.get(ref);
3573
+ if (existing) return existing;
3574
+ const peer = factory ? factory(this, ref) : ref.startsWith("server://") ? new RpcServerPeer(this, ref) : new RpcClientPeer(this, ref);
3407
3575
  this.addPeer(peer);
3408
3576
  return peer;
3409
3577
  }
3410
- /** Get or create a client peer for the given URL. */
3411
- getClientPeer(ref) {
3412
- return this.getPeer(ref);
3578
+ /** Get or create a client peer for the given URL. See {@link getPeer} for
3579
+ * the factory semantics. */
3580
+ getClientPeer(ref, factory) {
3581
+ return this.getPeer(ref, factory);
3413
3582
  }
3414
- /** Get or create a server peer for the given ref. */
3415
- getServerPeer(ref) {
3416
- return this.getPeer(ref);
3583
+ /** Get or create a server peer for the given ref. See {@link getPeer} for
3584
+ * the factory semantics. */
3585
+ getServerPeer(ref, factory) {
3586
+ return this.getPeer(ref, factory);
3417
3587
  }
3418
3588
  /** Register a service with optional server method wrapping for custom call types. */
3419
3589
  addService(defOrContract, impl) {
@@ -3542,6 +3712,25 @@ var RpcHub = class {
3542
3712
  }
3543
3713
  };
3544
3714
 
3715
+ // src/rpc-peer-ref-builder.ts
3716
+ var RpcPeerRefBuilder = class {
3717
+ /** Build a client peer ref from a WebSocket URL, optionally baking in the
3718
+ * serialization format as a `f=` query parameter.
3719
+ * @param url Base WebSocket URL, e.g. `wss://host/rpc/ws`.
3720
+ * @param serializationFormat Optional format key, e.g. `msgpack6`. When
3721
+ * provided, replaces any existing `f=` in the URL. */
3722
+ static forClient(url, serializationFormat) {
3723
+ if (serializationFormat === void 0) return url;
3724
+ const u = new URL(url);
3725
+ u.searchParams.set("f", serializationFormat);
3726
+ return u.toString();
3727
+ }
3728
+ /** Build a server peer ref from a peer id — returns `server://{id}`. */
3729
+ static forServer(id) {
3730
+ return `server://${id}`;
3731
+ }
3732
+ };
3733
+
3545
3734
  // src/rpc-client.ts
3546
3735
  function createRpcClient(peer, serviceDef) {
3547
3736
  const overloads = /* @__PURE__ */ new Map();
@@ -3643,20 +3832,18 @@ var RpcPeerStateMonitor = class {
3643
3832
  _state;
3644
3833
  _timer;
3645
3834
  // Arrow functions to preserve `this` when used as event handlers
3646
- _onConnected = () => {
3647
- this._rawConnected = true;
3648
- this._connectedAt = Date.now();
3649
- this._lastError = void 0;
3650
- this._recompute();
3651
- };
3652
- _onDisconnected = (ev) => {
3653
- const wasConnected = this._rawConnected;
3654
- this._rawConnected = false;
3655
- this._lastError = new Error(
3656
- `Connection closed: ${ev.reason} (code ${ev.code})`
3657
- );
3658
- if (wasConnected) {
3659
- this._disconnectedAt = Date.now();
3835
+ _onConnectionStateChanged = (state) => {
3836
+ if (state === 2 /* Connected */) {
3837
+ this._rawConnected = true;
3838
+ this._connectedAt = Date.now();
3839
+ this._lastError = void 0;
3840
+ } else {
3841
+ const wasConnected = this._rawConnected;
3842
+ this._rawConnected = false;
3843
+ this._lastError = new Error("Connection closed");
3844
+ if (wasConnected) {
3845
+ this._disconnectedAt = Date.now();
3846
+ }
3660
3847
  }
3661
3848
  this._recompute();
3662
3849
  };
@@ -3682,10 +3869,9 @@ var RpcPeerStateMonitor = class {
3682
3869
  reconnectsIn: 0
3683
3870
  };
3684
3871
  }
3685
- peer.connected.add(this._onConnected);
3686
- peer.disconnected.add(this._onDisconnected);
3872
+ peer.connectionStateChanged.add(this._onConnectionStateChanged);
3687
3873
  peer.reconnectsAtChanged.add(this._onReconnectsAtChanged);
3688
- peer.reconnectDelayer.cancelDelaysChanged.add(
3874
+ peer.hub.reconnectDelayer.cancelDelaysChanged.add(
3689
3875
  this._onCancelDelaysChanged
3690
3876
  );
3691
3877
  this._scheduleRecompute(
@@ -3701,10 +3887,9 @@ var RpcPeerStateMonitor = class {
3701
3887
  clearTimeout(this._timer);
3702
3888
  this._timer = void 0;
3703
3889
  }
3704
- this.peer.connected.remove(this._onConnected);
3705
- this.peer.disconnected.remove(this._onDisconnected);
3890
+ this.peer.connectionStateChanged.remove(this._onConnectionStateChanged);
3706
3891
  this.peer.reconnectsAtChanged.remove(this._onReconnectsAtChanged);
3707
- this.peer.reconnectDelayer.cancelDelaysChanged.remove(
3892
+ this.peer.hub.reconnectDelayer.cancelDelaysChanged.remove(
3708
3893
  this._onCancelDelaysChanged
3709
3894
  );
3710
3895
  infoLog2?.log(`'${this.peer.ref}': monitor stopped`);
@@ -3786,12 +3971,14 @@ export {
3786
3971
  ARG_DELIMITER,
3787
3972
  ENVELOPE_DELIMITER,
3788
3973
  FRAME_DELIMITER,
3974
+ HANDSHAKE_TIMEOUT_MS,
3789
3975
  IncreasingSeqCompressor,
3790
3976
  RPC_CLOSE_CODE_UNSUPPORTED_FORMAT,
3791
3977
  RpcCallStage,
3792
3978
  RpcCallTypeId,
3793
3979
  RpcClientPeer,
3794
3980
  RpcClientPeerReconnectDelayer,
3981
+ RpcConnectionState,
3795
3982
  RpcHub,
3796
3983
  RpcInboundCall,
3797
3984
  RpcInboundCallTracker,
@@ -3804,7 +3991,7 @@ export {
3804
3991
  RpcOutboundCall,
3805
3992
  RpcOutboundCallTracker,
3806
3993
  RpcPeer,
3807
- RpcPeerConnectionKind,
3994
+ RpcPeerRefBuilder,
3808
3995
  RpcPeerStateKind,
3809
3996
  RpcPeerStateMonitor,
3810
3997
  RpcRemoteExecutionMode,