@johnhenry/wsh 0.16.0 → 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/README.md +208 -10
- package/package.json +9 -1
- package/src/auth.mjs +63 -1
- package/src/client.mjs +339 -35
- package/src/control-listener.mjs +130 -0
- package/src/e2e-frame.mjs +32 -1
- package/src/file-transfer.mjs +11 -45
- package/src/index.d.ts +136 -6
- package/src/index.mjs +6 -2
- package/src/mcp-bridge.mjs +70 -51
- package/src/messages.gen.mjs +8 -7
- package/src/mlkem.mjs +27 -0
- package/src/session.mjs +127 -23
- package/src/transport-ws.mjs +16 -2
- package/src/transport.mjs +201 -7
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}
|
|
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
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
|
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
|
-
|
|
1870
|
-
|
|
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
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|