@johnhenry/wsh 0.0.0 → 0.3.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 +2 -0
- package/package.json +8 -2
- package/src/auth.mjs +51 -12
- package/src/cbor.mjs +47 -0
- package/src/client.mjs +14 -7
- package/src/index.d.ts +23 -0
- package/src/index.mjs +1 -1
- package/src/messages.gen.mjs +1 -1
- package/src/transport-ws.mjs +69 -3
- package/src/transport.mjs +19 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# wsh-upon-star
|
|
2
2
|
|
|
3
|
+
Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.me/wsh/)
|
|
4
|
+
|
|
3
5
|
> Previously published as `wsh-upon-star` (last release: 0.1.1, now deprecated).
|
|
4
6
|
> Renamed to `@johnhenry/wsh` and restarted at 0.0.0 on import into the
|
|
5
7
|
> @johnhenry family — a new name and era, not a maturity signal.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/wsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
"README.md"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
-
"test": "node --test test/*.test.mjs"
|
|
17
|
+
"test": "node --test test/*.test.mjs",
|
|
18
|
+
"examples": "for f in examples/0*.mjs; do echo \"== $f\" && node \"$f\" || exit 1; done",
|
|
19
|
+
"example:01": "node examples/01-frames-survive-fragmented-transport.mjs",
|
|
20
|
+
"example:02": "node examples/02-tampered-challenge-fails-authentication.mjs",
|
|
21
|
+
"example:03": "node examples/03-handshake-to-exec-over-in-process-pipe.mjs",
|
|
22
|
+
"example:04": "node examples/04-recording-replays-with-original-timing.mjs",
|
|
23
|
+
"example:05": "node examples/05-remote-mcp-tools-called-through-bridge.mjs"
|
|
18
24
|
},
|
|
19
25
|
"keywords": [
|
|
20
26
|
"wsh",
|
package/src/auth.mjs
CHANGED
|
@@ -110,27 +110,62 @@ export async function verify(publicKey, signature, data) {
|
|
|
110
110
|
|
|
111
111
|
// ── Authentication Transcript ─────────────────────────────────────────
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Length-prefix a byte string with a 4-byte big-endian length, so two
|
|
115
|
+
* variable-length fields concatenated in sequence can't collide (e.g.
|
|
116
|
+
* username="ab", session="c" must hash differently from username="a",
|
|
117
|
+
* session="bc").
|
|
118
|
+
* @param {Uint8Array} bytes
|
|
119
|
+
* @returns {Uint8Array}
|
|
120
|
+
*/
|
|
121
|
+
function lengthPrefixed(bytes) {
|
|
122
|
+
const out = new Uint8Array(4 + bytes.length);
|
|
123
|
+
new DataView(out.buffer).setUint32(0, bytes.length);
|
|
124
|
+
out.set(bytes, 4);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
113
128
|
/**
|
|
114
129
|
* Build the authentication transcript hash for challenge-response signing.
|
|
115
130
|
*
|
|
116
|
-
* transcript = SHA-256(
|
|
131
|
+
* transcript = SHA-256(
|
|
132
|
+
* "wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding
|
|
133
|
+
* )
|
|
134
|
+
*
|
|
135
|
+
* `username` is bound so a signature produced for one username can't be
|
|
136
|
+
* replayed/relabeled as another. `session_id` and `username` are each
|
|
137
|
+
* length-prefixed (lp) since both are variable-length.
|
|
138
|
+
*
|
|
139
|
+
* `channelBinding` is reserved for a transport-layer identity binding (e.g.
|
|
140
|
+
* a TLS exporter value) — no caller currently populates it. Note this is
|
|
141
|
+
* NOT currently a binding to the *server's* identity: the protocol has no
|
|
142
|
+
* server host-identity concept today (SERVER_HELLO.fingerprints lists
|
|
143
|
+
* client keys the server authorizes, not the server's own key), and TLS
|
|
144
|
+
* exporter values aren't obtainable from a browser WebSocket/WebTransport
|
|
145
|
+
* client at all. See the wsh-modernization tracking issue for the
|
|
146
|
+
* follow-up covering server host-identity + TOFU pinning.
|
|
117
147
|
*
|
|
118
148
|
* @param {string} sessionId
|
|
119
149
|
* @param {Uint8Array} nonce - 32-byte server nonce
|
|
120
|
-
* @param {
|
|
150
|
+
* @param {object} [opts]
|
|
151
|
+
* @param {string} [opts.username] - Username this transcript is bound to
|
|
152
|
+
* @param {Uint8Array} [opts.channelBinding] - Optional transport-layer binding
|
|
121
153
|
* @returns {Promise<Uint8Array>} 32-byte SHA-256 hash
|
|
122
154
|
*/
|
|
123
|
-
export async function buildTranscript(sessionId, nonce, channelBinding = new Uint8Array(0)) {
|
|
155
|
+
export async function buildTranscript(sessionId, nonce, { username = '', channelBinding = new Uint8Array(0) } = {}) {
|
|
124
156
|
const enc = new TextEncoder();
|
|
125
157
|
const versionBytes = enc.encode(PROTOCOL_VERSION + '\0');
|
|
126
|
-
const
|
|
158
|
+
const usernameField = lengthPrefixed(enc.encode(username));
|
|
159
|
+
const sessionField = lengthPrefixed(enc.encode(sessionId));
|
|
127
160
|
|
|
128
|
-
const total = versionBytes.length +
|
|
161
|
+
const total = versionBytes.length + usernameField.length + sessionField.length
|
|
162
|
+
+ nonce.length + channelBinding.length;
|
|
129
163
|
const data = new Uint8Array(total);
|
|
130
164
|
let offset = 0;
|
|
131
165
|
|
|
132
166
|
data.set(versionBytes, offset); offset += versionBytes.length;
|
|
133
|
-
data.set(
|
|
167
|
+
data.set(usernameField, offset); offset += usernameField.length;
|
|
168
|
+
data.set(sessionField, offset); offset += sessionField.length;
|
|
134
169
|
data.set(nonce, offset); offset += nonce.length;
|
|
135
170
|
data.set(channelBinding, offset);
|
|
136
171
|
|
|
@@ -148,11 +183,13 @@ export async function buildTranscript(sessionId, nonce, channelBinding = new Uin
|
|
|
148
183
|
* @param {CryptoKey} publicKey
|
|
149
184
|
* @param {string} sessionId
|
|
150
185
|
* @param {Uint8Array} nonce
|
|
151
|
-
* @param {
|
|
186
|
+
* @param {object} [opts]
|
|
187
|
+
* @param {string} [opts.username]
|
|
188
|
+
* @param {Uint8Array} [opts.channelBinding]
|
|
152
189
|
* @returns {Promise<{ signature: Uint8Array, publicKeyRaw: Uint8Array }>}
|
|
153
190
|
*/
|
|
154
|
-
export async function signChallenge(privateKey, publicKey, sessionId, nonce,
|
|
155
|
-
const transcript = await buildTranscript(sessionId, nonce,
|
|
191
|
+
export async function signChallenge(privateKey, publicKey, sessionId, nonce, opts) {
|
|
192
|
+
const transcript = await buildTranscript(sessionId, nonce, opts);
|
|
156
193
|
const [signature, publicKeyRaw] = await Promise.all([
|
|
157
194
|
sign(privateKey, transcript),
|
|
158
195
|
exportPublicKeyRaw(publicKey),
|
|
@@ -167,11 +204,13 @@ export async function signChallenge(privateKey, publicKey, sessionId, nonce, cha
|
|
|
167
204
|
* @param {Uint8Array} signature
|
|
168
205
|
* @param {string} sessionId
|
|
169
206
|
* @param {Uint8Array} nonce
|
|
170
|
-
* @param {
|
|
207
|
+
* @param {object} [opts]
|
|
208
|
+
* @param {string} [opts.username]
|
|
209
|
+
* @param {Uint8Array} [opts.channelBinding]
|
|
171
210
|
* @returns {Promise<boolean>}
|
|
172
211
|
*/
|
|
173
|
-
export async function verifyChallenge(publicKey, signature, sessionId, nonce,
|
|
174
|
-
const transcript = await buildTranscript(sessionId, nonce,
|
|
212
|
+
export async function verifyChallenge(publicKey, signature, sessionId, nonce, opts) {
|
|
213
|
+
const transcript = await buildTranscript(sessionId, nonce, opts);
|
|
175
214
|
return verify(publicKey, signature, transcript);
|
|
176
215
|
}
|
|
177
216
|
|
package/src/cbor.mjs
CHANGED
|
@@ -330,16 +330,57 @@ export function frameEncode(value) {
|
|
|
330
330
|
return frame;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Default cap on a single frame's claimed payload length (16 MiB).
|
|
335
|
+
*
|
|
336
|
+
* This channel only ever carries CBOR control-message metadata (session
|
|
337
|
+
* lists, MCP tool results, recording-export/command-journal entries,
|
|
338
|
+
* etc.) — bulk data such as file transfers and PTY output travel over
|
|
339
|
+
* separate raw data streams with their own small chunking, not through
|
|
340
|
+
* this decoder. 16 MiB is generous headroom for legitimate control
|
|
341
|
+
* payloads while still bounding memory far below the 4 GiB a hostile or
|
|
342
|
+
* buggy peer could otherwise claim via the 32-bit length prefix.
|
|
343
|
+
*/
|
|
344
|
+
export const DEFAULT_MAX_FRAME_SIZE = 16 * 1024 * 1024;
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Thrown by {@link FrameDecoder#feed} when a frame's claimed length
|
|
348
|
+
* exceeds the decoder's configured maximum.
|
|
349
|
+
*/
|
|
350
|
+
export class FrameSizeError extends Error {
|
|
351
|
+
constructor(len, maxFrameSize) {
|
|
352
|
+
super(`Frame claims length ${len}, which exceeds the maximum of ${maxFrameSize} bytes`);
|
|
353
|
+
this.name = 'FrameSizeError';
|
|
354
|
+
this.len = len;
|
|
355
|
+
this.maxFrameSize = maxFrameSize;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
333
359
|
/**
|
|
334
360
|
* Streaming frame decoder. Feed it chunks and it yields complete messages.
|
|
335
361
|
*/
|
|
336
362
|
export class FrameDecoder {
|
|
337
363
|
#buffer = new Uint8Array(0);
|
|
364
|
+
#maxFrameSize;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* @param {object} [opts]
|
|
368
|
+
* @param {number} [opts.maxFrameSize] - Maximum allowed claimed frame
|
|
369
|
+
* length, in bytes. Frames claiming more than this are rejected as
|
|
370
|
+
* soon as their length prefix is parsed, before any payload bytes
|
|
371
|
+
* are buffered. Defaults to {@link DEFAULT_MAX_FRAME_SIZE}.
|
|
372
|
+
*/
|
|
373
|
+
constructor({ maxFrameSize = DEFAULT_MAX_FRAME_SIZE } = {}) {
|
|
374
|
+
this.#maxFrameSize = maxFrameSize;
|
|
375
|
+
}
|
|
338
376
|
|
|
339
377
|
/**
|
|
340
378
|
* Feed bytes and return decoded messages.
|
|
341
379
|
* @param {Uint8Array} chunk
|
|
342
380
|
* @returns {Array} decoded JS values
|
|
381
|
+
* @throws {FrameSizeError} if a frame's claimed length exceeds the
|
|
382
|
+
* configured maximum. The decoder's buffer is cleared before
|
|
383
|
+
* throwing; callers should treat this as fatal for the connection.
|
|
343
384
|
*/
|
|
344
385
|
feed(chunk) {
|
|
345
386
|
this.#buffer = appendBuffer(this.#buffer, chunk);
|
|
@@ -347,6 +388,12 @@ export class FrameDecoder {
|
|
|
347
388
|
while (this.#buffer.length >= 4) {
|
|
348
389
|
const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset, 4);
|
|
349
390
|
const len = view.getUint32(0);
|
|
391
|
+
if (len > this.#maxFrameSize) {
|
|
392
|
+
// Reject before accumulating toward the (possibly huge) claimed
|
|
393
|
+
// length — do not wait for the payload to arrive.
|
|
394
|
+
this.#buffer = new Uint8Array(0);
|
|
395
|
+
throw new FrameSizeError(len, this.#maxFrameSize);
|
|
396
|
+
}
|
|
350
397
|
if (this.#buffer.length < 4 + len) break;
|
|
351
398
|
const payload = this.#buffer.slice(4, 4 + len);
|
|
352
399
|
this.#buffer = this.#buffer.slice(4 + len);
|
package/src/client.mjs
CHANGED
|
@@ -362,7 +362,8 @@ export class WshClient {
|
|
|
362
362
|
keyPair.privateKey,
|
|
363
363
|
keyPair.publicKey,
|
|
364
364
|
tempSessionId,
|
|
365
|
-
challengeMsg.nonce
|
|
365
|
+
challengeMsg.nonce,
|
|
366
|
+
{ username }
|
|
366
367
|
);
|
|
367
368
|
|
|
368
369
|
await transport.sendControl(
|
|
@@ -388,14 +389,18 @@ export class WshClient {
|
|
|
388
389
|
}
|
|
389
390
|
|
|
390
391
|
// We need a session ID for the transcript. Use the nonce-derived ID
|
|
391
|
-
// or a placeholder if the server hasn't provided one.
|
|
392
|
-
|
|
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();
|
|
393
397
|
|
|
394
398
|
const { signature, publicKeyRaw } = await signChallenge(
|
|
395
399
|
keyPair.privateKey,
|
|
396
400
|
keyPair.publicKey,
|
|
397
401
|
tempSessionId,
|
|
398
|
-
firstResponse.nonce
|
|
402
|
+
firstResponse.nonce,
|
|
403
|
+
{ username }
|
|
399
404
|
);
|
|
400
405
|
|
|
401
406
|
await transport.sendControl(
|
|
@@ -1431,7 +1436,7 @@ export class WshClient {
|
|
|
1431
1436
|
}
|
|
1432
1437
|
|
|
1433
1438
|
const { signature, publicKeyRaw } = await signChallenge(
|
|
1434
|
-
keyPair.privateKey, keyPair.publicKey, tempSessionId, challengeMsg.nonce
|
|
1439
|
+
keyPair.privateKey, keyPair.publicKey, tempSessionId, challengeMsg.nonce, { username }
|
|
1435
1440
|
);
|
|
1436
1441
|
|
|
1437
1442
|
await this.#transport.sendControl(authMsg({
|
|
@@ -1445,9 +1450,11 @@ export class WshClient {
|
|
|
1445
1450
|
} else if (firstResponse.type === MSG.CHALLENGE) {
|
|
1446
1451
|
if (!keyPair) throw new Error('Server sent CHALLENGE but no key pair provided');
|
|
1447
1452
|
|
|
1448
|
-
|
|
1453
|
+
// See connect()'s CHALLENGE branch for why this must be random
|
|
1454
|
+
// rather than a fixed literal.
|
|
1455
|
+
tempSessionId = tempSessionId || crypto.randomUUID();
|
|
1449
1456
|
const { signature, publicKeyRaw } = await signChallenge(
|
|
1450
|
-
keyPair.privateKey, keyPair.publicKey, tempSessionId, firstResponse.nonce
|
|
1457
|
+
keyPair.privateKey, keyPair.publicKey, tempSessionId, firstResponse.nonce, { username }
|
|
1451
1458
|
);
|
|
1452
1459
|
|
|
1453
1460
|
await this.#transport.sendControl(authMsg({
|
package/src/index.d.ts
CHANGED
|
@@ -27,12 +27,35 @@ export function cborDecode(data: Uint8Array): unknown;
|
|
|
27
27
|
*/
|
|
28
28
|
export function frameEncode(value: unknown): Uint8Array;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Default cap on a single frame's claimed payload length (16 MiB).
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_MAX_FRAME_SIZE: number;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Thrown by FrameDecoder#feed when a frame's claimed length exceeds the
|
|
37
|
+
* decoder's configured maximum.
|
|
38
|
+
*/
|
|
39
|
+
export class FrameSizeError extends Error {
|
|
40
|
+
constructor(len: number, maxFrameSize: number);
|
|
41
|
+
readonly len: number;
|
|
42
|
+
readonly maxFrameSize: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
/**
|
|
31
46
|
* Streaming frame decoder. Feed it chunks and it yields complete messages.
|
|
32
47
|
*/
|
|
33
48
|
export class FrameDecoder {
|
|
49
|
+
/**
|
|
50
|
+
* @param opts.maxFrameSize Maximum allowed claimed frame length in bytes.
|
|
51
|
+
* Defaults to DEFAULT_MAX_FRAME_SIZE.
|
|
52
|
+
*/
|
|
53
|
+
constructor(opts?: { maxFrameSize?: number });
|
|
54
|
+
|
|
34
55
|
/**
|
|
35
56
|
* Feed bytes and return decoded messages.
|
|
57
|
+
* @throws {FrameSizeError} if a frame's claimed length exceeds the
|
|
58
|
+
* configured maximum.
|
|
36
59
|
*/
|
|
37
60
|
feed(chunk: Uint8Array): unknown[];
|
|
38
61
|
|
package/src/index.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
// CBOR codec + framing
|
|
9
|
-
export { cborEncode, cborDecode, frameEncode, FrameDecoder } from './cbor.mjs';
|
|
9
|
+
export { cborEncode, cborDecode, frameEncode, FrameDecoder, FrameSizeError, DEFAULT_MAX_FRAME_SIZE } from './cbor.mjs';
|
|
10
10
|
|
|
11
11
|
// Protocol messages
|
|
12
12
|
export {
|
package/src/messages.gen.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* wsh protocol control message types and constructors.
|
|
3
3
|
* AUTO-GENERATED from wsh-v1.yaml — do not edit.
|
|
4
|
-
* Run: node
|
|
4
|
+
* Run: node spec/codegen.mjs (from the @johnhenry/wsh repo root)
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
// ── Message type constants ────────────────────────────────────────────
|
package/src/transport-ws.mjs
CHANGED
|
@@ -15,10 +15,18 @@
|
|
|
15
15
|
* Data streams carry raw bytes with no additional framing.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { frameEncode, FrameDecoder } from './cbor.mjs';
|
|
18
|
+
import { frameEncode, FrameDecoder, FrameSizeError } from './cbor.mjs';
|
|
19
19
|
import { WshTransport } from './transport.mjs';
|
|
20
20
|
|
|
21
21
|
// ── Frame type constants ─────────────────────────────────────────────
|
|
22
|
+
//
|
|
23
|
+
// NOTE: these are unrelated to MSG.WS_DATA (0x60) in messages.gen.mjs.
|
|
24
|
+
// WS_DATA is a JS-only, CBOR-layer bookkeeping marker (it shares its
|
|
25
|
+
// opcode with MSG.DETACH and is explicitly excluded from the Rust
|
|
26
|
+
// message enum by spec/codegen.mjs) — it is never sent as the outer
|
|
27
|
+
// envelope's frame-type byte below. See spec/wsh-v1.yaml's
|
|
28
|
+
// `transport.websocket` section for the authoritative outer-envelope
|
|
29
|
+
// framing docs.
|
|
22
30
|
|
|
23
31
|
const FRAME_CONTROL = 0x01;
|
|
24
32
|
const FRAME_DATA = 0x02;
|
|
@@ -198,6 +206,27 @@ export class WebSocketTransport extends WshTransport {
|
|
|
198
206
|
/** Tracks whether we initiated the close. */
|
|
199
207
|
#closedByUs = false;
|
|
200
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
|
+
|
|
201
230
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
202
231
|
|
|
203
232
|
/** @override */
|
|
@@ -207,6 +236,8 @@ export class WebSocketTransport extends WshTransport {
|
|
|
207
236
|
this.#nextLocalId = 1;
|
|
208
237
|
this.#openResolvers.clear();
|
|
209
238
|
this.#closedByUs = false;
|
|
239
|
+
this.#messageQueue.length = 0;
|
|
240
|
+
this.#draining = false;
|
|
210
241
|
|
|
211
242
|
return new Promise((resolve, reject) => {
|
|
212
243
|
// Normalize URL scheme: wsh:// → wss://, http:// → ws://.
|
|
@@ -234,7 +265,8 @@ export class WebSocketTransport extends WshTransport {
|
|
|
234
265
|
});
|
|
235
266
|
|
|
236
267
|
ws.addEventListener('message', (ev) => {
|
|
237
|
-
this.#
|
|
268
|
+
this.#messageQueue.push(ev.data);
|
|
269
|
+
this.#drainMessageQueue();
|
|
238
270
|
});
|
|
239
271
|
});
|
|
240
272
|
}
|
|
@@ -287,6 +319,29 @@ export class WebSocketTransport extends WshTransport {
|
|
|
287
319
|
|
|
288
320
|
// ── Inbound message dispatch ───────────────────────────────────────
|
|
289
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
|
+
|
|
290
345
|
/**
|
|
291
346
|
* Handle a raw WebSocket message (ArrayBuffer).
|
|
292
347
|
* @param {ArrayBuffer} raw
|
|
@@ -329,7 +384,18 @@ export class WebSocketTransport extends WshTransport {
|
|
|
329
384
|
* @param {Uint8Array} payload
|
|
330
385
|
*/
|
|
331
386
|
#handleControlFrame(payload) {
|
|
332
|
-
|
|
387
|
+
let messages;
|
|
388
|
+
try {
|
|
389
|
+
messages = this.#decoder.feed(payload);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
this._emitError(err);
|
|
392
|
+
if (err instanceof FrameSizeError) {
|
|
393
|
+
// Oversized claimed frame length — treat as a hostile or badly
|
|
394
|
+
// broken peer and tear down the connection rather than continue.
|
|
395
|
+
this.close().catch(() => {});
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
333
399
|
for (const msg of messages) {
|
|
334
400
|
this._emitControl(msg);
|
|
335
401
|
}
|
package/src/transport.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Data streams carry raw bytes with no framing overhead.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { frameEncode, FrameDecoder, cborEncode } from './cbor.mjs';
|
|
8
|
+
import { frameEncode, FrameDecoder, cborEncode, FrameSizeError } from './cbor.mjs';
|
|
9
9
|
|
|
10
10
|
// ── Transport states ─────────────────────────────────────────────────
|
|
11
11
|
|
|
@@ -281,11 +281,29 @@ 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) {
|
|
287
300
|
if (!this.#abort.signal.aborted) {
|
|
288
301
|
this._emitError(new Error(`Control stream read error: ${err.message}`));
|
|
302
|
+
if (err instanceof FrameSizeError) {
|
|
303
|
+
// Oversized claimed frame length — treat as a hostile or badly
|
|
304
|
+
// broken peer and tear down the connection rather than continue.
|
|
305
|
+
this.close().catch(() => {});
|
|
306
|
+
}
|
|
289
307
|
}
|
|
290
308
|
} finally {
|
|
291
309
|
reader.releaseLock();
|