@johnhenry/wsh 0.2.0 → 0.4.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/package.json +1 -1
- package/src/client.mjs +20 -15
- package/src/messages.gen.mjs +2 -1
- package/src/transport-ws.mjs +48 -1
- package/src/transport.mjs +13 -0
package/package.json
CHANGED
package/src/client.mjs
CHANGED
|
@@ -357,11 +357,13 @@ export class WshClient {
|
|
|
357
357
|
return this.#sessionId;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
-
// Sign the challenge.
|
|
360
|
+
// Sign the challenge. Challenge.session_id (not the one from
|
|
361
|
+
// ServerHello) is authoritative for the transcript — see the
|
|
362
|
+
// comment in the CHALLENGE-first branch below for why.
|
|
361
363
|
const { signature, publicKeyRaw } = await signChallenge(
|
|
362
364
|
keyPair.privateKey,
|
|
363
365
|
keyPair.publicKey,
|
|
364
|
-
|
|
366
|
+
challengeMsg.session_id,
|
|
365
367
|
challengeMsg.nonce,
|
|
366
368
|
{ username }
|
|
367
369
|
);
|
|
@@ -388,17 +390,18 @@ export class WshClient {
|
|
|
388
390
|
throw new Error('Server sent CHALLENGE but no key pair was provided');
|
|
389
391
|
}
|
|
390
392
|
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
tempSessionId
|
|
397
|
-
|
|
393
|
+
// Challenge carries session_id directly (protocol requirement as
|
|
394
|
+
// of wsh-v1's Challenge.session_id field), so the transcript's
|
|
395
|
+
// session-id component is always the server's real, authoritative
|
|
396
|
+
// value regardless of whether ServerHello was sent, dropped, or
|
|
397
|
+
// arrived out of order. No synthesizing a placeholder here. Keep
|
|
398
|
+
// tempSessionId as a fallback for this.#sessionId below in case
|
|
399
|
+
// AUTH_OK's own session_id is ever absent.
|
|
400
|
+
tempSessionId = firstResponse.session_id;
|
|
398
401
|
const { signature, publicKeyRaw } = await signChallenge(
|
|
399
402
|
keyPair.privateKey,
|
|
400
403
|
keyPair.publicKey,
|
|
401
|
-
|
|
404
|
+
firstResponse.session_id,
|
|
402
405
|
firstResponse.nonce,
|
|
403
406
|
{ username }
|
|
404
407
|
);
|
|
@@ -1436,7 +1439,7 @@ export class WshClient {
|
|
|
1436
1439
|
}
|
|
1437
1440
|
|
|
1438
1441
|
const { signature, publicKeyRaw } = await signChallenge(
|
|
1439
|
-
keyPair.privateKey, keyPair.publicKey,
|
|
1442
|
+
keyPair.privateKey, keyPair.publicKey, challengeMsg.session_id, challengeMsg.nonce, { username }
|
|
1440
1443
|
);
|
|
1441
1444
|
|
|
1442
1445
|
await this.#transport.sendControl(authMsg({
|
|
@@ -1450,11 +1453,13 @@ export class WshClient {
|
|
|
1450
1453
|
} else if (firstResponse.type === MSG.CHALLENGE) {
|
|
1451
1454
|
if (!keyPair) throw new Error('Server sent CHALLENGE but no key pair provided');
|
|
1452
1455
|
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
|
|
1456
|
+
// Challenge carries session_id directly — see connect()'s
|
|
1457
|
+
// CHALLENGE branch for why. Keep tempSessionId as a fallback for
|
|
1458
|
+
// this.#sessionId below in case AUTH_OK's own session_id is ever
|
|
1459
|
+
// absent.
|
|
1460
|
+
tempSessionId = firstResponse.session_id;
|
|
1456
1461
|
const { signature, publicKeyRaw } = await signChallenge(
|
|
1457
|
-
keyPair.privateKey, keyPair.publicKey,
|
|
1462
|
+
keyPair.privateKey, keyPair.publicKey, firstResponse.session_id, firstResponse.nonce, { username }
|
|
1458
1463
|
);
|
|
1459
1464
|
|
|
1460
1465
|
await this.#transport.sendControl(authMsg({
|
package/src/messages.gen.mjs
CHANGED
|
@@ -200,10 +200,11 @@ export function serverHello({ sessionId, features = [], fingerprints = [] } = {}
|
|
|
200
200
|
};
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
export function challenge({ nonce } = {}) {
|
|
203
|
+
export function challenge({ nonce, sessionId } = {}) {
|
|
204
204
|
return {
|
|
205
205
|
type: MSG.CHALLENGE,
|
|
206
206
|
nonce,
|
|
207
|
+
session_id: sessionId,
|
|
207
208
|
};
|
|
208
209
|
}
|
|
209
210
|
|
package/src/transport-ws.mjs
CHANGED
|
@@ -206,6 +206,27 @@ export class WebSocketTransport extends WshTransport {
|
|
|
206
206
|
/** Tracks whether we initiated the close. */
|
|
207
207
|
#closedByUs = false;
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Queue of raw inbound messages awaiting dispatch, plus a drain-in-progress
|
|
211
|
+
* flag. Needed because some `WebSocket` implementations (notably Node's
|
|
212
|
+
* `ws` package parsing several frames out of one TCP read) can fire
|
|
213
|
+
* multiple synchronous `message` events within a single JS task, all
|
|
214
|
+
* before any microtask gets to run. If we dispatched each message inline,
|
|
215
|
+
* a handler that resolves a pending waiter (e.g. SERVER_HELLO resolving
|
|
216
|
+
* the "wait for SERVER_HELLO or CHALLENGE" promise) wouldn't get a chance
|
|
217
|
+
* to run its `await`'d continuation — which registers the *next* waiter
|
|
218
|
+
* (e.g. for CHALLENGE) — before the next queued message arrives. That
|
|
219
|
+
* continuation is a microtask; a same-task, no-yield dispatch loop starves
|
|
220
|
+
* it, so the next message's waiter isn't registered yet and gets dropped.
|
|
221
|
+
* Draining the queue with an `await Promise.resolve()` between each
|
|
222
|
+
* message yields to the microtask queue after every dispatch, and since
|
|
223
|
+
* microtasks run FIFO, any continuation queued by handling message N
|
|
224
|
+
* (which runs first) completes before message N+1 is dispatched.
|
|
225
|
+
* @type {ArrayBuffer[]}
|
|
226
|
+
*/
|
|
227
|
+
#messageQueue = [];
|
|
228
|
+
#draining = false;
|
|
229
|
+
|
|
209
230
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
210
231
|
|
|
211
232
|
/** @override */
|
|
@@ -215,6 +236,8 @@ export class WebSocketTransport extends WshTransport {
|
|
|
215
236
|
this.#nextLocalId = 1;
|
|
216
237
|
this.#openResolvers.clear();
|
|
217
238
|
this.#closedByUs = false;
|
|
239
|
+
this.#messageQueue.length = 0;
|
|
240
|
+
this.#draining = false;
|
|
218
241
|
|
|
219
242
|
return new Promise((resolve, reject) => {
|
|
220
243
|
// Normalize URL scheme: wsh:// → wss://, http:// → ws://.
|
|
@@ -242,7 +265,8 @@ export class WebSocketTransport extends WshTransport {
|
|
|
242
265
|
});
|
|
243
266
|
|
|
244
267
|
ws.addEventListener('message', (ev) => {
|
|
245
|
-
this.#
|
|
268
|
+
this.#messageQueue.push(ev.data);
|
|
269
|
+
this.#drainMessageQueue();
|
|
246
270
|
});
|
|
247
271
|
});
|
|
248
272
|
}
|
|
@@ -295,6 +319,29 @@ export class WebSocketTransport extends WshTransport {
|
|
|
295
319
|
|
|
296
320
|
// ── Inbound message dispatch ───────────────────────────────────────
|
|
297
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Drain `#messageQueue` one message at a time, yielding to the microtask
|
|
324
|
+
* queue between each dispatch. See the `#messageQueue` field doc for why:
|
|
325
|
+
* this is what lets a waiter registered by an earlier message's `await`
|
|
326
|
+
* continuation (e.g. registering the CHALLENGE waiter after SERVER_HELLO
|
|
327
|
+
* resolves) actually be in place before the next queued message arrives,
|
|
328
|
+
* even when the underlying WebSocket implementation delivered several
|
|
329
|
+
* messages synchronously in one task.
|
|
330
|
+
*/
|
|
331
|
+
async #drainMessageQueue() {
|
|
332
|
+
if (this.#draining) return;
|
|
333
|
+
this.#draining = true;
|
|
334
|
+
try {
|
|
335
|
+
while (this.#messageQueue.length > 0) {
|
|
336
|
+
const raw = this.#messageQueue.shift();
|
|
337
|
+
this.#handleMessage(raw);
|
|
338
|
+
await Promise.resolve();
|
|
339
|
+
}
|
|
340
|
+
} finally {
|
|
341
|
+
this.#draining = false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
298
345
|
/**
|
|
299
346
|
* Handle a raw WebSocket message (ArrayBuffer).
|
|
300
347
|
* @param {ArrayBuffer} raw
|
package/src/transport.mjs
CHANGED
|
@@ -281,6 +281,19 @@ export class WebTransportTransport extends WshTransport {
|
|
|
281
281
|
const messages = this.#decoder.feed(value);
|
|
282
282
|
for (const msg of messages) {
|
|
283
283
|
this._emitControl(msg);
|
|
284
|
+
// Yield to the microtask queue between messages. A QUIC stream
|
|
285
|
+
// has no per-message framing at the transport layer, so a single
|
|
286
|
+
// read() can return bytes for several messages at once (e.g.
|
|
287
|
+
// SERVER_HELLO immediately followed by CHALLENGE). Emitting them
|
|
288
|
+
// in a tight synchronous loop starves any `await`'d continuation
|
|
289
|
+
// triggered by handling an earlier message (e.g. registering the
|
|
290
|
+
// CHALLENGE waiter after SERVER_HELLO resolves) — that
|
|
291
|
+
// continuation is a microtask, and without a yield here it never
|
|
292
|
+
// gets to run before the next message is dispatched. Since
|
|
293
|
+
// microtasks run FIFO, a continuation queued while handling msg
|
|
294
|
+
// N always completes before this yield's own continuation
|
|
295
|
+
// resumes to dispatch msg N+1.
|
|
296
|
+
await Promise.resolve();
|
|
284
297
|
}
|
|
285
298
|
}
|
|
286
299
|
} catch (err) {
|