@johnhenry/wsh 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.mjs CHANGED
@@ -52,7 +52,50 @@ const STATE_CLOSED = 'closed';
52
52
 
53
53
  const DEFAULT_AUTH_TIMEOUT = 10_000; // ms
54
54
  const DEFAULT_OPEN_TIMEOUT = 10_000; // ms
55
+
56
+ /**
57
+ * ServerHello feature advertising that McpResult echoes McpCall's call_id.
58
+ *
59
+ * Negotiated rather than assumed: McpCallPayload is `deny_unknown_fields` on
60
+ * the Rust side, so sending call_id to a server predating it does not
61
+ * degrade -- it makes the server reject the call.
62
+ */
63
+ export const MCP_CALL_ID_FEATURE = 'mcp-call-id';
64
+
65
+ /**
66
+ * Reject a missing argument that maps to a `required: true` wire field.
67
+ *
68
+ * JavaScript will happily let an omitted argument through, and
69
+ * `cborEncode` turns the resulting `undefined` into CBOR null rather
70
+ * than dropping the key -- so the message goes out looking well-formed,
71
+ * gets rejected by a spec-conformant server for a reason that has
72
+ * nothing to do with what the caller did wrong, and the local stack
73
+ * trace is long gone by then. This is exactly how `resumeSession()`
74
+ * shipped broken for two releases (CHANGELOG 0.14.0: `last_seq`
75
+ * "previously always sent as `undefined`, which the wire's
76
+ * `required: true` field never tolerated"). Failing here instead names
77
+ * the argument at the call site.
78
+ *
79
+ * @param {string} method - Method name, for the message.
80
+ * @param {Record<string, *>} args - Argument name -> value.
81
+ */
82
+ function requireArgs(method, args) {
83
+ for (const [name, value] of Object.entries(args)) {
84
+ if (value === undefined || value === null) {
85
+ throw new TypeError(
86
+ `${method}: "${name}" is required -- the corresponding wire field is not optional, ` +
87
+ 'and omitting it produces a message a conformant server will reject'
88
+ );
89
+ }
90
+ }
91
+ }
55
92
  const DEFAULT_PING_INTERVAL = 30_000; // ms
93
+ /*
94
+ * How long a peer may go without answering a ping before it is treated as
95
+ * gone. A multiple of the interval, so an ordinary missed pong is tolerated
96
+ * and a peer that has genuinely stopped answering is not.
97
+ */
98
+ const DEFAULT_PONG_TIMEOUT = 90_000; // ms
56
99
  const DEFAULT_EXEC_TIMEOUT = 60_000; // ms
57
100
  const FILE_CHUNK_SIZE = 65_536;
58
101
 
@@ -181,9 +224,27 @@ export class WshClient {
181
224
  */
182
225
  #acceptedRelayPeers = new Set();
183
226
 
227
+ /**
228
+ * Observers registered through addControlListener() — see that method
229
+ * and #dispatchControlListeners.
230
+ * @type {Set<function(object): void>}
231
+ */
232
+ #controlListeners = new Set();
233
+
184
234
  /** @type {number} Monotonically increasing channel ID counter. */
185
235
  #channelCounter = 0;
186
236
 
237
+ /** @type {number} Monotonically increasing MCP call counter for call_id. */
238
+ #mcpCallCounter = 0;
239
+
240
+ /**
241
+ * Tail of the serialised MCP call chain, used only against servers that do
242
+ * not advertise `mcp-call-id`. Never rejects: each link swallows so one
243
+ * failed call does not poison the queue.
244
+ * @type {Promise<void>}
245
+ */
246
+ #mcpQueue = Promise.resolve();
247
+
187
248
  /**
188
249
  * Pending message waiters: Map<messageType, Array<{resolve, reject, timer}>>
189
250
  * Multiple waiters can exist for the same message type.
@@ -201,12 +262,21 @@ export class WshClient {
201
262
 
202
263
  /** @type {number|null} Timestamp of last pong received. */
203
264
  #lastPong = null;
265
+ #pingIntervalMs = DEFAULT_PING_INTERVAL;
266
+ #pongTimeoutMs = DEFAULT_PONG_TIMEOUT;
204
267
 
205
- constructor({ transportFactories } = {}) {
268
+ constructor({ transportFactories, pingIntervalMs, pongTimeoutMs } = {}) {
206
269
  this.#transportFactories = transportFactories || {
207
270
  wt: () => new WebTransportTransport(),
208
271
  ws: () => new WebSocketTransport(),
209
272
  };
273
+ /*
274
+ * Overridable so the keepalive is testable at all. Left as constants,
275
+ * the only way to observe a 90s timeout is to wait 90s or fake the
276
+ * clock -- which is why nothing observed it before.
277
+ */
278
+ this.#pingIntervalMs = pingIntervalMs ?? DEFAULT_PING_INTERVAL;
279
+ this.#pongTimeoutMs = pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT;
210
280
  }
211
281
 
212
282
  // ── Callbacks ───────────────────────────────────────────────────────
@@ -342,10 +412,18 @@ export class WshClient {
342
412
  * @param {CryptoKeyPair} [opts.keyPair] - Ed25519 key pair for pubkey auth
343
413
  * @param {string} [opts.password] - Password for password auth
344
414
  * @param {'wt'|'ws'|'auto'} [opts.transport] - Force a specific transport
415
+ * @param {object} [opts.webTransport] - Options forwarded to the
416
+ * `WebTransport` constructor when the WebTransport rung of the ladder
417
+ * is tried. Most usefully `serverCertificateHashes`, which pins a
418
+ * specific self-signed certificate by SHA-256 digest -- the one way
419
+ * page JavaScript can reach a server whose certificate no certificate
420
+ * authority signed. Values may be raw bytes, hex (colon-separated is
421
+ * fine), or base64. Ignored by the WebSocket rung, which has no
422
+ * equivalent mechanism.
345
423
  * @param {number} [opts.timeout] - Auth handshake timeout in ms
346
424
  * @returns {Promise<string>} The server-assigned session ID
347
425
  */
348
- async connect(url, { username, keyPair, password, transport: transportHint, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
426
+ async connect(url, { username, keyPair, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
349
427
  if (this.#state !== STATE_DISCONNECTED && this.#state !== STATE_CLOSED) {
350
428
  throw new Error(`Client already ${this.#state}`);
351
429
  }
@@ -365,7 +443,7 @@ export class WshClient {
365
443
 
366
444
  try {
367
445
  // ── Select and connect transport ──────────────────────────────
368
- const transport = await this.#connectTransport(url, transportHint);
446
+ const transport = await this.#connectTransport(url, transportHint, webTransport);
369
447
  this.#transport = transport;
370
448
  this.#state = STATE_CONNECTED;
371
449
 
@@ -834,6 +912,7 @@ export class WshClient {
834
912
  username,
835
913
  keyPair,
836
914
  password,
915
+ webTransport,
837
916
  expose = {},
838
917
  peerType = 'browser-shell',
839
918
  shellBackend,
@@ -843,7 +922,7 @@ export class WshClient {
843
922
  supportsTermSync,
844
923
  } = {}) {
845
924
  // Authenticate normally first.
846
- const sessionId = await this.connect(url, { username, keyPair, password });
925
+ const sessionId = await this.connect(url, { username, keyPair, password, webTransport });
847
926
 
848
927
  // Build capabilities list from expose options.
849
928
  const capabilities = [];
@@ -985,6 +1064,71 @@ export class WshClient {
985
1064
  await this.#transport.sendControl(msg);
986
1065
  }
987
1066
 
1067
+ // ── Raw control channel (used by WshMcpBridge / WshFileTransfer) ─────
1068
+ //
1069
+ // `WshMcpBridge` and `WshFileTransfer` both document their constructor
1070
+ // argument as "a WshClient", and both drive the connection through
1071
+ // `sendControl()` + `addControlListener()`/`removeControlListener()`
1072
+ // (`WshFileTransfer.list()` also needs `openStream()`). None of those
1073
+ // existed on this class, so every one of those code paths failed with
1074
+ // `TypeError: this.#client.sendControl is not a function` the moment a
1075
+ // real client was passed instead of a hand-written stand-in — the same
1076
+ // shape as the 0.14.0 `attachSession()`/`resumeSession()` bug, where a
1077
+ // whole method was unreachable while the suite stayed green because
1078
+ // every test spoke to a mock that answered whatever it was asked.
1079
+ //
1080
+ // These four members are thin, deliberate delegations rather than a new
1081
+ // subsystem: the transport already owns the socket and the framing, and
1082
+ // `#handleControl` already sees every inbound control message.
1083
+
1084
+ /**
1085
+ * Send a raw control message on the authenticated connection.
1086
+ *
1087
+ * Prefer the typed methods (`openSession`, `callTool`, `upload`, …).
1088
+ * This exists for the helper classes that compose their own messages —
1089
+ * see `WshMcpBridge` and `WshFileTransfer`.
1090
+ *
1091
+ * @param {object} msg
1092
+ * @returns {Promise<void>}
1093
+ */
1094
+ async sendControl(msg) {
1095
+ this.#assertAuthenticated('sendControl');
1096
+ await this.#transport.sendControl(msg);
1097
+ }
1098
+
1099
+ /**
1100
+ * Open a raw bidirectional stream on the underlying transport.
1101
+ *
1102
+ * @returns {Promise<{ readable: ReadableStream, writable: WritableStream }>}
1103
+ */
1104
+ async openStream() {
1105
+ this.#assertAuthenticated('openStream');
1106
+ return this.#transport.openStream();
1107
+ }
1108
+
1109
+ /**
1110
+ * Observe every inbound control message this client handles.
1111
+ *
1112
+ * Listeners are called after the RelayForward trust gate, so a relayed
1113
+ * message from an untrusted peer is never handed to one, and a trusted
1114
+ * RelayForward is delivered as its unwrapped inner message. A listener
1115
+ * that throws is logged and does not stop the client's own dispatch.
1116
+ *
1117
+ * @param {function(object): void} fn
1118
+ */
1119
+ addControlListener(fn) {
1120
+ if (typeof fn !== 'function') throw new TypeError('addControlListener requires a function');
1121
+ this.#controlListeners.add(fn);
1122
+ }
1123
+
1124
+ /**
1125
+ * Stop observing inbound control messages.
1126
+ * @param {function(object): void} fn
1127
+ */
1128
+ removeControlListener(fn) {
1129
+ this.#controlListeners.delete(fn);
1130
+ }
1131
+
988
1132
  /**
989
1133
  * Mark a peer fingerprint as an accepted reverse-connect bridge partner.
990
1134
  *
@@ -1135,22 +1279,60 @@ export class WshClient {
1135
1279
  /**
1136
1280
  * Call an MCP tool on the remote server.
1137
1281
  *
1282
+ * Safe to call concurrently. Against a server advertising the
1283
+ * `mcp-call-id` feature each call carries a correlation id and takes only
1284
+ * its own reply. Against an older server there is no id on the wire and
1285
+ * replies are indistinguishable, so calls are queued one at a time on this
1286
+ * connection -- slower, but never the wrong tool's result.
1287
+ *
1138
1288
  * @param {string} name - Tool name
1139
- * @param {object} args - Tool arguments
1289
+ * @param {object} [args={}] - Tool arguments. Defaults to `{}` rather
1290
+ * than being omitted: `McpCall.arguments` is a required wire field,
1291
+ * and a tool that takes no arguments is an ordinary thing to call.
1140
1292
  * @param {number} [timeout=30000]
1141
1293
  * @returns {Promise<*>} Tool result
1142
1294
  */
1143
- async callTool(name, args, timeout = 30_000) {
1295
+ async callTool(name, args = {}, timeout = 30_000) {
1144
1296
  this.#assertAuthenticated('callTool');
1297
+ requireArgs('callTool', { name });
1298
+
1299
+ if (this.hasFeature(MCP_CALL_ID_FEATURE)) {
1300
+ return this.#callToolCorrelated(name, args, timeout);
1301
+ }
1302
+ // No correlation available: serialise so a concurrent caller cannot be
1303
+ // handed someone else's result.
1304
+ const run = this.#mcpQueue.then(
1305
+ () => this.#callToolCorrelated(name, args, timeout),
1306
+ () => this.#callToolCorrelated(name, args, timeout),
1307
+ );
1308
+ this.#mcpQueue = run.then(() => undefined, () => undefined);
1309
+ return run;
1310
+ }
1311
+
1312
+ /**
1313
+ * Send one McpCall and wait for the McpResult that answers it.
1314
+ * @private
1315
+ */
1316
+ async #callToolCorrelated(name, args, timeout) {
1317
+ const correlate = this.hasFeature(MCP_CALL_ID_FEATURE);
1318
+ // Only sent when the peer understands it: McpCallPayload is
1319
+ // deny_unknown_fields on the Rust side, so an unsolicited call_id would
1320
+ // make an older server reject the call outright rather than ignore it.
1321
+ const callId = correlate ? `mcp-${++this.#mcpCallCounter}-${Date.now()}` : undefined;
1145
1322
 
1146
1323
  await this.#transport.sendControl(
1147
- mcpCallMsg({ tool: name, arguments: args })
1324
+ mcpCallMsg({ tool: name, arguments: args, callId })
1148
1325
  );
1149
1326
 
1150
1327
  const response = await this.#waitForMessage(
1151
1328
  [MSG.MCP_RESULT],
1152
1329
  timeout,
1153
- `Timed out waiting for MCP tool result (${name})`
1330
+ `Timed out waiting for MCP tool result (${name})`,
1331
+ // A responder that echoes no call_id gets matched on type, which is
1332
+ // the pre-correlation behaviour and all an older peer can offer.
1333
+ callId === undefined
1334
+ ? undefined
1335
+ : (msg) => msg.call_id === undefined || msg.call_id === callId,
1154
1336
  );
1155
1337
 
1156
1338
  return response.result;
@@ -1207,6 +1389,7 @@ export class WshClient {
1207
1389
  */
1208
1390
  async inviteGuest(sessionId, ttl, permissions = ['read'], timeout = DEFAULT_OPEN_TIMEOUT) {
1209
1391
  this.#assertAuthenticated('inviteGuest');
1392
+ requireArgs('inviteGuest', { sessionId, ttl });
1210
1393
  await this.#transport.sendControl(guestInviteMsg({ sessionId, ttl, permissions }));
1211
1394
  return this.#waitForMessage(
1212
1395
  [MSG.GUEST_INVITE],
@@ -1248,12 +1431,17 @@ export class WshClient {
1248
1431
  * Share a session for multi-attach.
1249
1432
  * @param {string} sessionId - Session to share
1250
1433
  * @param {string} [mode='read'] - Share mode
1251
- * @param {number} [ttl] - Share TTL in seconds
1434
+ * @param {number} ttl - Share lifetime in seconds. Not optional,
1435
+ * despite its position after a defaulted parameter:
1436
+ * `ShareSession.ttl` is a required wire field, and there is no
1437
+ * sensible default lifetime for a share link to invent on the
1438
+ * caller's behalf.
1252
1439
  * @param {number} [timeout=10000]
1253
1440
  * @returns {Promise<object>} Share response with share_id
1254
1441
  */
1255
1442
  async shareSession(sessionId, mode = 'read', ttl, timeout = DEFAULT_OPEN_TIMEOUT) {
1256
1443
  this.#assertAuthenticated('shareSession');
1444
+ requireArgs('shareSession', { sessionId, ttl });
1257
1445
  await this.#transport.sendControl(shareSessionMsg({ sessionId, mode, ttl }));
1258
1446
  return this.#waitForMessage(
1259
1447
  [MSG.SHARE_SESSION],
@@ -1301,6 +1489,7 @@ export class WshClient {
1301
1489
  */
1302
1490
  async setRateControl(sessionId, maxBytesPerSec, policy = 'pause') {
1303
1491
  this.#assertAuthenticated('setRateControl');
1492
+ requireArgs('setRateControl', { sessionId, maxBytesPerSec });
1304
1493
  await this.#transport.sendControl(rateControlMsg({ sessionId, maxBytesPerSec, policy }));
1305
1494
  }
1306
1495
 
@@ -1340,6 +1529,7 @@ export class WshClient {
1340
1529
  */
1341
1530
  async copilotAttach(sessionId, model, contextWindow) {
1342
1531
  this.#assertAuthenticated('copilotAttach');
1532
+ requireArgs('copilotAttach', { sessionId, model });
1343
1533
  await this.#transport.sendControl(
1344
1534
  copilotAttachMsg({ sessionId, model, contextWindow })
1345
1535
  );
@@ -1401,6 +1591,30 @@ export class WshClient {
1401
1591
  this.#assertAuthenticated('initiateE2E');
1402
1592
  const wantHybrid = algorithm === 'X25519+ML-KEM-768';
1403
1593
 
1594
+ // Register the waiter before ANY await, key generation included.
1595
+ //
1596
+ // Two peers establishing E2E both call this at once, and #handleControl
1597
+ // DROPS a KEY_EXCHANGE that no waiter is listening for. Whichever side
1598
+ // finishes its local key generation first sends its round-1 message into
1599
+ // the other's blind window -- the other is still inside generateKey /
1600
+ // exportKey below and has nothing registered. That message is gone, and
1601
+ // the side that lost it then waits the full timeout for something the
1602
+ // peer has already sent and will not send again.
1603
+ //
1604
+ // Registering after the crypto, or merely before sendControl, leaves the
1605
+ // window open: measured at ~10% of runs before, and still ~5% with the
1606
+ // waiter registered after key generation. The only safe point is here,
1607
+ // before this function yields for the first time.
1608
+ const peerMsgPromise = this.#waitForMessage(
1609
+ [MSG.KEY_EXCHANGE],
1610
+ timeout,
1611
+ 'Timed out waiting for peer key exchange'
1612
+ );
1613
+ // Mark it handled so a failure in the key generation or send below does
1614
+ // not surface as an unhandled rejection when this waiter later times
1615
+ // out. Awaiting the promise still throws normally.
1616
+ peerMsgPromise.catch(() => {});
1617
+
1404
1618
  // Generate ephemeral X25519 key pair (and, for hybrid, a fresh
1405
1619
  // ML-KEM-768 key pair too).
1406
1620
  const ephemeral = await crypto.subtle.generateKey(
@@ -1417,11 +1631,30 @@ export class WshClient {
1417
1631
  keyExchangeMsg({ algorithm, publicKey: localPub, sessionId, kemPublicKey: localKem?.publicKey })
1418
1632
  );
1419
1633
 
1420
- const peerMsg = await this.#waitForMessage(
1421
- [MSG.KEY_EXCHANGE],
1422
- timeout,
1423
- 'Timed out waiting for peer key exchange'
1424
- );
1634
+ const peerMsg = await peerMsgPromise;
1635
+
1636
+ // Round 2 has the same shape as round 1, so it needs the same treatment.
1637
+ // Whether we encapsulate or decapsulate is decided entirely by data
1638
+ // already in hand, so decide it now and register the ciphertext waiter
1639
+ // BEFORE the derive below. Registering it inside the `else` branch, after
1640
+ // importKey and deriveBits, let the peer's ciphertext arrive while this
1641
+ // side was still deriving -- dropped, then a full-timeout wait for a
1642
+ // message already sent. That surfaced as "Timed out waiting for peer
1643
+ // ML-KEM-768 ciphertext", which retryOnKnownFlake retries and the test
1644
+ // file attributes to a Node provider stall.
1645
+ const hybridActive = wantHybrid && !!localKem && !!peerMsg.kem_public_key;
1646
+ const isEncapsulator = hybridActive
1647
+ && compareBytes(localPub, new Uint8Array(peerMsg.public_key)) < 0;
1648
+
1649
+ let ctMsgPromise = null;
1650
+ if (hybridActive && !isEncapsulator) {
1651
+ ctMsgPromise = this.#waitForMessage(
1652
+ [MSG.KEY_EXCHANGE],
1653
+ timeout,
1654
+ 'Timed out waiting for peer ML-KEM-768 ciphertext'
1655
+ );
1656
+ ctMsgPromise.catch(() => {});
1657
+ }
1425
1658
 
1426
1659
  // Import peer's public key and derive the classical shared secret.
1427
1660
  const peerKey = await crypto.subtle.importKey(
@@ -1437,12 +1670,10 @@ export class WshClient {
1437
1670
  256
1438
1671
  ));
1439
1672
 
1440
- const hybridActive = wantHybrid && !!localKem && !!peerMsg.kem_public_key;
1441
1673
  let combinedBits = sharedBits;
1442
1674
 
1443
1675
  if (hybridActive) {
1444
1676
  const peerKemPublicKey = new Uint8Array(peerMsg.kem_public_key);
1445
- const isEncapsulator = compareBytes(localPub, new Uint8Array(peerMsg.public_key)) < 0;
1446
1677
 
1447
1678
  let kemSharedSecret;
1448
1679
  if (isEncapsulator) {
@@ -1450,11 +1681,7 @@ export class WshClient {
1450
1681
  kemSharedSecret = sharedSecret;
1451
1682
  await this.#transport.sendControl(keyExchangeMsg({ algorithm, sessionId, kemCiphertext: ciphertext }));
1452
1683
  } else {
1453
- const ctMsg = await this.#waitForMessage(
1454
- [MSG.KEY_EXCHANGE],
1455
- timeout,
1456
- 'Timed out waiting for peer ML-KEM-768 ciphertext'
1457
- );
1684
+ const ctMsg = await ctMsgPromise;
1458
1685
  kemSharedSecret = await mlKemDecapsulate(localKem.secretKeySeed, new Uint8Array(ctMsg.kem_ciphertext));
1459
1686
  }
1460
1687
 
@@ -1542,6 +1769,7 @@ export class WshClient {
1542
1769
  */
1543
1770
  async updatePolicy(policyId, rules, version) {
1544
1771
  this.#assertAuthenticated('updatePolicy');
1772
+ requireArgs('updatePolicy', { policyId, rules, version });
1545
1773
  await this.#transport.sendControl(
1546
1774
  policyUpdateMsg({ policyId, rules, version })
1547
1775
  );
@@ -1554,17 +1782,23 @@ export class WshClient {
1554
1782
  *
1555
1783
  * @param {string} url
1556
1784
  * @param {'wt'|'ws'|'auto'} [hint]
1785
+ * @param {object} [webTransport] - Options for the `WebTransport`
1786
+ * constructor, applied only to a `wt` attempt (see
1787
+ * `WebTransportTransport`). The `ws` rung of the ladder ignores them:
1788
+ * a `serverCertificateHashes` pin has no WebSocket equivalent, so a
1789
+ * `wss:` fallback against that same self-signed certificate will
1790
+ * still fail the usual certificate-authority check.
1557
1791
  * @returns {Promise<import('./transport.mjs').WshTransport>}
1558
1792
  * @private
1559
1793
  */
1560
- async #connectTransport(url, hint) {
1794
+ async #connectTransport(url, hint, webTransport) {
1561
1795
  const attempts = this.#buildTransportAttempts(url, hint);
1562
1796
  const errors = [];
1563
1797
 
1564
1798
  for (const attempt of attempts) {
1565
1799
  const transport = this.#createTransport(attempt.kind);
1566
1800
  try {
1567
- await transport.connect(attempt.url);
1801
+ await transport.connect(attempt.url, attempt.kind === 'wt' ? webTransport : undefined);
1568
1802
  this.#attachTransportHandlers(transport);
1569
1803
  return transport;
1570
1804
  } catch (err) {
@@ -1606,6 +1840,16 @@ export class WshClient {
1606
1840
  if (hint === 'ws') {
1607
1841
  return [{ kind: 'ws', url }];
1608
1842
  }
1843
+ if (hint !== undefined && hint !== null && hint !== 'auto') {
1844
+ // Anything else is a typo, and silently treating it as 'auto' is
1845
+ // the worst available answer: `transport: 'webtransport'` would
1846
+ // quietly fall back to WebSocket, dropping any
1847
+ // `webTransport.serverCertificateHashes` pin along with it and
1848
+ // connecting over a transport the caller explicitly tried to avoid.
1849
+ throw new Error(
1850
+ `Unknown transport hint: ${JSON.stringify(hint)} (expected 'wt', 'ws', or 'auto')`
1851
+ );
1852
+ }
1609
1853
  if (/^wss?:\/\//i.test(url)) {
1610
1854
  return [{ kind: 'ws', url }];
1611
1855
  }
@@ -1804,6 +2048,13 @@ export class WshClient {
1804
2048
  return;
1805
2049
  }
1806
2050
 
2051
+ // Observers registered via addControlListener() see the message here —
2052
+ // past the RelayForward trust gate above (so an untrusted peer's
2053
+ // message never reaches one, and a trusted one arrives unwrapped) and
2054
+ // before this client's own dispatch, so a helper waiting on a reply
2055
+ // cannot miss it to an ordering accident.
2056
+ this.#dispatchControlListeners(msg);
2057
+
1807
2058
  // OPEN_OK/OPEN_FAIL: handled as a dedicated case (not the generic
1808
2059
  // waiter mechanism below) so the WshSession gets constructed and
1809
2060
  // registered in #sessions synchronously, in the same dispatch step as
@@ -1864,10 +2115,13 @@ export class WshClient {
1864
2115
  }
1865
2116
 
1866
2117
  // First, check if any waiters are listening for this message type.
2118
+ // A waiter carrying a `match` predicate only takes messages it claims,
2119
+ // so the queue is scanned rather than shifted blindly.
1867
2120
  if (this.#waiters.has(type)) {
1868
2121
  const queue = this.#waiters.get(type);
1869
- if (queue.length > 0) {
1870
- const waiter = queue.shift();
2122
+ for (let i = 0; i < queue.length; i++) {
2123
+ if (queue[i].match && !queue[i].match(msg)) continue;
2124
+ const waiter = queue.splice(i, 1)[0];
1871
2125
  if (queue.length === 0) this.#waiters.delete(type);
1872
2126
  clearTimeout(waiter.timer);
1873
2127
  waiter.resolve(msg);
@@ -1879,13 +2133,13 @@ export class WshClient {
1879
2133
  for (const [key, queue] of this.#waiters) {
1880
2134
  if (typeof key === 'string' && key.startsWith('multi:')) {
1881
2135
  for (let i = 0; i < queue.length; i++) {
1882
- if (queue[i].types?.includes(type)) {
1883
- const waiter = queue.splice(i, 1)[0];
1884
- if (queue.length === 0) this.#waiters.delete(key);
1885
- clearTimeout(waiter.timer);
1886
- waiter.resolve(msg);
1887
- return;
1888
- }
2136
+ if (!queue[i].types?.includes(type)) continue;
2137
+ if (queue[i].match && !queue[i].match(msg)) continue;
2138
+ const waiter = queue.splice(i, 1)[0];
2139
+ if (queue.length === 0) this.#waiters.delete(key);
2140
+ clearTimeout(waiter.timer);
2141
+ waiter.resolve(msg);
2142
+ return;
1889
2143
  }
1890
2144
  }
1891
2145
  }
@@ -2063,7 +2317,7 @@ export class WshClient {
2063
2317
  * @returns {Promise<object>}
2064
2318
  * @private
2065
2319
  */
2066
- #waitForMessage(types, timeout, timeoutMessage) {
2320
+ #waitForMessage(types, timeout, timeoutMessage, match) {
2067
2321
  const typeArr = Array.isArray(types) ? types : [types];
2068
2322
 
2069
2323
  return new Promise((resolve, reject) => {
@@ -2073,7 +2327,10 @@ export class WshClient {
2073
2327
  reject(new Error(timeoutMessage));
2074
2328
  }, timeout);
2075
2329
 
2076
- const waiter = { resolve, reject, timer, types: typeArr };
2330
+ // `match` lets a waiter claim only the reply that belongs to it --
2331
+ // correlation, rather than "first waiter of this type wins". Waiters
2332
+ // without one match on type alone, as before.
2333
+ const waiter = { resolve, reject, timer, types: typeArr, match };
2077
2334
 
2078
2335
  // For multi-type waiting, use a synthetic key.
2079
2336
  const key = typeArr.length === 1
@@ -2138,12 +2395,37 @@ export class WshClient {
2138
2395
  return;
2139
2396
  }
2140
2397
 
2398
+ /*
2399
+ * Act on the pong, rather than merely recording it.
2400
+ *
2401
+ * `#lastPong` was written on every PONG and read nowhere -- `grep -arn
2402
+ * lastPong src/` returned the declaration and two assignments and no
2403
+ * third line. So the keepalive proved the connection was alive when it
2404
+ * was, and said nothing when it stopped being: pings went out forever
2405
+ * at every peer, dead or not, and a caller waiting for data waited
2406
+ * without a reason.
2407
+ *
2408
+ * Compared against a deadline rather than armed as a timer, which
2409
+ * matters: a timer re-armed on each tick is cleared before it can fire
2410
+ * whenever the interval is shorter than the timeout, and is then
2411
+ * incapable of firing at all. (browsermesh#32 shipped exactly that.) A
2412
+ * timestamp comparison has no such failure mode.
2413
+ */
2414
+ if (this.#lastPong !== null && Date.now() - this.#lastPong > this.#pongTimeoutMs) {
2415
+ const silentFor = Date.now() - this.#lastPong;
2416
+ this.#stopPing();
2417
+ this.#emitError(
2418
+ new Error(`wsh: peer stopped answering keepalive pings ${silentFor}ms ago`)
2419
+ );
2420
+ return;
2421
+ }
2422
+
2141
2423
  this.#transport?.sendControl(
2142
2424
  pingMsg({ id: ++this.#pingId })
2143
2425
  ).catch((err) => {
2144
2426
  console.warn('[wsh:client] Failed to send ping:', err.message);
2145
2427
  });
2146
- }, DEFAULT_PING_INTERVAL);
2428
+ }, this.#pingIntervalMs);
2147
2429
 
2148
2430
  // Don't let the ping timer prevent Node.js/Deno from exiting.
2149
2431
  if (typeof this.#pingTimer === 'object' && this.#pingTimer.unref) {
@@ -2199,6 +2481,28 @@ export class WshClient {
2199
2481
  }
2200
2482
  }
2201
2483
 
2484
+ /**
2485
+ * Hand a control message to every addControlListener() observer.
2486
+ *
2487
+ * Iterates a copy, so a listener that deregisters itself (the normal
2488
+ * case — a one-shot reply waiter) does not perturb the iteration, and
2489
+ * isolates throws so one bad observer cannot break the client's own
2490
+ * dispatch of the same message.
2491
+ *
2492
+ * @param {object} msg
2493
+ * @private
2494
+ */
2495
+ #dispatchControlListeners(msg) {
2496
+ if (this.#controlListeners.size === 0) return;
2497
+ for (const fn of [...this.#controlListeners]) {
2498
+ try {
2499
+ fn(msg);
2500
+ } catch (err) {
2501
+ console.error('[wsh:client] control listener error:', err);
2502
+ }
2503
+ }
2504
+ }
2505
+
2202
2506
  /**
2203
2507
  * Emit an error through the callback.
2204
2508
  * @param {Error} err