@johnhenry/wsh 0.3.0 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
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
- tempSessionId,
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
- // We need a session ID for the transcript. Use the nonce-derived ID
392
- // or a fresh random placeholder if the server hasn't provided one.
393
- // (A fixed literal here would make the transcript's session-id
394
- // component identical across every connection taking this path,
395
- // leaving only the nonce to bind the signature to this connection.)
396
- tempSessionId = tempSessionId || crypto.randomUUID();
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
- tempSessionId,
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, tempSessionId, challengeMsg.nonce, { username }
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
- // See connect()'s CHALLENGE branch for why this must be random
1454
- // rather than a fixed literal.
1455
- tempSessionId = tempSessionId || crypto.randomUUID();
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, tempSessionId, firstResponse.nonce, { username }
1462
+ keyPair.privateKey, keyPair.publicKey, firstResponse.session_id, firstResponse.nonce, { username }
1458
1463
  );
1459
1464
 
1460
1465
  await this.#transport.sendControl(authMsg({
@@ -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
 
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { frameEncode, FrameDecoder, FrameSizeError } from './cbor.mjs';
19
- import { WshTransport } from './transport.mjs';
19
+ import { WshTransport, dispatchSerially, SerialQueue } from './transport.mjs';
20
20
 
21
21
  // ── Frame type constants ─────────────────────────────────────────────
22
22
  //
@@ -207,25 +207,15 @@ export class WebSocketTransport extends WshTransport {
207
207
  #closedByUs = false;
208
208
 
209
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[]}
210
+ * Serializes dispatch of raw inbound WebSocket messages. Needed because
211
+ * some `WebSocket` implementations (notably Node's `ws` package parsing
212
+ * several frames out of one TCP read) can fire multiple synchronous
213
+ * `message` events within a single JS task, all before any microtask
214
+ * gets to run — see `SerialQueue`'s doc comment in transport.mjs for
215
+ * why dispatching those inline would be unsafe.
216
+ * @type {SerialQueue}
226
217
  */
227
- #messageQueue = [];
228
- #draining = false;
218
+ #inbox = new SerialQueue((raw) => this.#handleMessage(raw));
229
219
 
230
220
  // ── Lifecycle ──────────────────────────────────────────────────────
231
221
 
@@ -236,8 +226,7 @@ export class WebSocketTransport extends WshTransport {
236
226
  this.#nextLocalId = 1;
237
227
  this.#openResolvers.clear();
238
228
  this.#closedByUs = false;
239
- this.#messageQueue.length = 0;
240
- this.#draining = false;
229
+ this.#inbox.clear();
241
230
 
242
231
  return new Promise((resolve, reject) => {
243
232
  // Normalize URL scheme: wsh:// → wss://, http:// → ws://.
@@ -265,8 +254,7 @@ export class WebSocketTransport extends WshTransport {
265
254
  });
266
255
 
267
256
  ws.addEventListener('message', (ev) => {
268
- this.#messageQueue.push(ev.data);
269
- this.#drainMessageQueue();
257
+ this.#inbox.push(ev.data);
270
258
  });
271
259
  });
272
260
  }
@@ -320,33 +308,14 @@ export class WebSocketTransport extends WshTransport {
320
308
  // ── Inbound message dispatch ───────────────────────────────────────
321
309
 
322
310
  /**
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
-
345
- /**
346
- * Handle a raw WebSocket message (ArrayBuffer).
311
+ * Handle a raw WebSocket message (ArrayBuffer). Called serially by
312
+ * `#inbox` — never invoked directly from the `message` event listener.
313
+ * Async (and awaited by `#inbox`'s SerialQueue) because the
314
+ * FRAME_CONTROL case can itself dispatch several decoded messages —
315
+ * see `#handleControlFrame`.
347
316
  * @param {ArrayBuffer} raw
348
317
  */
349
- #handleMessage(raw) {
318
+ async #handleMessage(raw) {
350
319
  if (typeof raw === 'string') {
351
320
  this._emitError(new Error('Received text WebSocket frame; expected binary'));
352
321
  return;
@@ -363,7 +332,7 @@ export class WebSocketTransport extends WshTransport {
363
332
 
364
333
  switch (frame.type) {
365
334
  case FRAME_CONTROL:
366
- this.#handleControlFrame(frame.payload);
335
+ await this.#handleControlFrame(frame.payload);
367
336
  break;
368
337
  case FRAME_DATA:
369
338
  this.#handleDataFrame(frame.streamId, frame.payload);
@@ -380,10 +349,14 @@ export class WebSocketTransport extends WshTransport {
380
349
  }
381
350
 
382
351
  /**
383
- * Decode CBOR-framed control messages from stream 0.
352
+ * Decode CBOR-framed control messages from stream 0. A single mux
353
+ * frame's payload can decode into more than one protocol message (the
354
+ * CBOR decoder is stateful/streaming), so dispatch uses
355
+ * `dispatchSerially` — see its doc comment for why a plain for-loop
356
+ * here would be unsafe.
384
357
  * @param {Uint8Array} payload
385
358
  */
386
- #handleControlFrame(payload) {
359
+ async #handleControlFrame(payload) {
387
360
  let messages;
388
361
  try {
389
362
  messages = this.#decoder.feed(payload);
@@ -396,9 +369,7 @@ export class WebSocketTransport extends WshTransport {
396
369
  }
397
370
  return;
398
371
  }
399
- for (const msg of messages) {
400
- this._emitControl(msg);
401
- }
372
+ await dispatchSerially(messages, (msg) => this._emitControl(msg));
402
373
  }
403
374
 
404
375
  /**
package/src/transport.mjs CHANGED
@@ -7,6 +7,97 @@
7
7
 
8
8
  import { frameEncode, FrameDecoder, cborEncode, FrameSizeError } from './cbor.mjs';
9
9
 
10
+ // ── Serial dispatch (shared by both transports) ───────────────────────
11
+ //
12
+ // A push-based transport can hand this library more than one decoded
13
+ // protocol message at once — either because several arrived in a single
14
+ // underlying read (e.g. multiple WebSocket `message` events fired
15
+ // synchronously from one TCP read, or one CBOR decoder call returning
16
+ // several frames from one already-received chunk) — with no guaranteed
17
+ // yield to the microtask queue between them. That matters because a
18
+ // message handler can be `async`: it resolves a pending waiter
19
+ // synchronously, but the code that runs *after* that resolution (e.g.
20
+ // registering the *next* waiter) only runs as a microtask continuation.
21
+ // Dispatching message N+1 before that continuation gets a turn means
22
+ // N+1 arrives with state that N's handling hasn't finished setting up
23
+ // yet — most visibly, a dropped CHALLENGE that arrived batched with
24
+ // SERVER_HELLO (see CHANGELOG 0.3.0). `dispatchSerially` and
25
+ // `SerialQueue` both fix this the same way: yield to the microtask
26
+ // queue after every single dispatch, so FIFO microtask ordering
27
+ // guarantees any continuation queued by message N runs before message
28
+ // N+1 is handled.
29
+
30
+ /**
31
+ * Invoke `handler` once per item in `items`, awaiting each call before
32
+ * starting the next. Use this for a *fixed* batch that's already fully
33
+ * available (e.g. everything a single decoder.feed() call returned) —
34
+ * see `SerialQueue` for items that arrive incrementally over time via a
35
+ * push callback.
36
+ *
37
+ * `handler` may be sync or async — either way, `await handler(item)`
38
+ * yields to the microtask queue at least once before the next item is
39
+ * dispatched (awaiting any value does, even a plain `undefined`), and if
40
+ * `handler` is itself async, this also waits for its *own* internal work
41
+ * (including any nested dispatchSerially/SerialQueue use) to fully
42
+ * settle first. That matters when a handler for item N does async work
43
+ * that must complete before N+1 is safe to dispatch — a fire-and-forget
44
+ * (non-awaited) handler call wouldn't provide that guarantee, only a
45
+ * same-tick yield.
46
+ * @param {Iterable<*>} items
47
+ * @param {(item: *) => (void | Promise<void>)} handler
48
+ */
49
+ export async function dispatchSerially(items, handler) {
50
+ for (const item of items) {
51
+ await handler(item);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * A queue drained one item at a time, awaiting each dispatch before
57
+ * starting the next (see `dispatchSerially`'s doc comment for exactly
58
+ * what that guarantees for sync vs. async handlers). Use this where
59
+ * items arrive incrementally over time via a push callback (e.g. a
60
+ * transport's raw `message` event) rather than as one fixed,
61
+ * already-available batch (see `dispatchSerially` for that case).
62
+ * `push()` is safe to call at any time, including while a previous
63
+ * drain is still in progress — the same drain loop just picks up the
64
+ * newly-pushed item in its next iteration.
65
+ */
66
+ export class SerialQueue {
67
+ #items = [];
68
+ #handler;
69
+ #draining = false;
70
+
71
+ /** @param {(item: *) => (void | Promise<void>)} handler */
72
+ constructor(handler) {
73
+ this.#handler = handler;
74
+ }
75
+
76
+ /** @param {*} item */
77
+ push(item) {
78
+ this.#items.push(item);
79
+ this.#drain();
80
+ }
81
+
82
+ /** Discard any queued-but-not-yet-dispatched items (e.g. on reconnect). */
83
+ clear() {
84
+ this.#items.length = 0;
85
+ }
86
+
87
+ async #drain() {
88
+ if (this.#draining) return;
89
+ this.#draining = true;
90
+ try {
91
+ while (this.#items.length > 0) {
92
+ const item = this.#items.shift();
93
+ await this.#handler(item);
94
+ }
95
+ } finally {
96
+ this.#draining = false;
97
+ }
98
+ }
99
+ }
100
+
10
101
  // ── Transport states ─────────────────────────────────────────────────
11
102
 
12
103
  const STATE_DISCONNECTED = 'disconnected';
@@ -278,23 +369,13 @@ export class WebTransportTransport extends WshTransport {
278
369
  const { done, value } = await reader.read();
279
370
  if (done) break;
280
371
 
372
+ // A QUIC stream has no per-message framing at the transport layer,
373
+ // so a single read() can return bytes for several messages at
374
+ // once (e.g. SERVER_HELLO immediately followed by CHALLENGE).
375
+ // dispatchSerially yields between each — see its doc comment for
376
+ // why that matters.
281
377
  const messages = this.#decoder.feed(value);
282
- for (const msg of messages) {
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();
297
- }
378
+ await dispatchSerially(messages, (msg) => this._emitControl(msg));
298
379
  }
299
380
  } catch (err) {
300
381
  if (!this.#abort.signal.aborted) {