@johnhenry/wsh 0.30.0 → 0.31.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 CHANGED
@@ -61,22 +61,39 @@ Or via CDN:
61
61
  - **Reverse mode** -- register as a peer (via a signed peer record) and accept incoming connections through a relay
62
62
  - **File transfer** -- scp-like upload/download as `FileChunk` control messages in 64KB chunks
63
63
  - **MCP bridge** -- discover and invoke remote MCP tools through the control channel
64
- - **Session recording** -- own JSON schema (not asciicast v2 -- see note below) recording and playback with seek/pause/resume
64
+ - **Session recording** -- own JSON schema (asciicast-v2-shaped, not a playable asciicast file -- see note below) recording and playback with seek/pause/resume
65
65
  - **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
66
66
  - **98 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, authorized-key management, and more
67
67
 
68
- > **Session recording is not asciicast v2.** Both `SessionRecorder`
69
- > implementations capture more than asciicast v2's three event codes
70
- > (`o`/`i`/`r`) can represent -- session lifecycle events (`open`/`exit` in
71
- > JS; `Start`/`Exit`/`Snapshot` in Rust) have no asciicast v2 equivalent,
72
- > and dropping them wasn't worth it just to match the format. The JS and
73
- > Rust recorders also use two different, mutually incompatible schemas from
74
- > each other (neither is asciicast v2's newline-delimited-JSON shape).
75
- > `RecordingExport`'s `format: "asciicast"` option is accepted on the wire
76
- > but not yet implemented server-side -- requesting it currently returns
77
- > the same custom JSONL as `format: "jsonl"`. If interop with standard
78
- > asciicast players (`asciinema play`, etc.) is wanted, that needs a real
79
- > (lossy -- lifecycle events would be dropped) export path, not a rename.
68
+ > **Session recording is asciicast-v2-shaped, but not an asciicast file.**
69
+ > The JS `SessionRecorder.toJSON()` export borrows asciicast v2's vocabulary --
70
+ > `version: 2`, `width`, `height`, `timestamp`, `env`, and events as
71
+ > `[seconds, "o" | "i" | "r", data]` -- so it is easy to convert, but it is not
72
+ > something `asciinema play` can read as-is:
73
+ >
74
+ > - it is **one JSON document** with an `events` array; asciicast v2 is
75
+ > newline-delimited JSON (a header line, then one event per line);
76
+ > - the header carries wsh's own `sessionId`, and `width`/`height` follow the
77
+ > latest resize rather than the size at the start;
78
+ > - session lifecycle events (`open`, `exit`) use codes asciicast v2 does not
79
+ > define (the Rust recorder also has `Start`/`Exit`/`Snapshot`, and uses a
80
+ > different, incompatible schema from the JS one);
81
+ > - `RecordingExport`'s `format: "asciicast"` option is accepted on the wire but
82
+ > not implemented server-side -- it returns the same custom JSONL as
83
+ > `format: "jsonl"`.
84
+ >
85
+ > To play a JS recording in asciinema, write the header and the `o`/`i`/`r`
86
+ > events out as lines (lossy: lifecycle events are dropped):
87
+ >
88
+ > ```js
89
+ > const { version, width, height, timestamp, env, events } = recorder.toJSON();
90
+ > const cast = [JSON.stringify({ version, width, height, timestamp, env }),
91
+ > ...events.filter((e) => 'oir'.includes(e[1])).map((e) => JSON.stringify(e))].join('\n') + '\n';
92
+ > // write `cast` to session.cast, then: asciinema play session.cast
93
+ > ```
94
+ >
95
+ > A first-class `.cast` exporter is not provided; the conversion above is the
96
+ > supported path (it is covered by `test/recording-format.test.mjs`).
80
97
 
81
98
  ## Wire Protocol: QMux
82
99
 
@@ -201,6 +218,50 @@ messages whose `type` is a string, from authenticated connections only. On a rel
201
218
  `relay: { onUnreachable(from, targetFingerprint, request) }` is told when an operator asked for a peer that is not
202
219
  registered (the operator still gets `no such peer`), so the host can reach that peer another way, such as a Web Push.
203
220
 
221
+ ### Pre-upgraded transports: `accept()`
222
+
223
+ `createWshServer()` normally opens its own listener. If your application already owns the HTTP server (or Worker, or
224
+ test harness) and has upgraded the connection itself, hand the socket over instead of bridging it to a per-session
225
+ loopback listener (which any local process could also connect to):
226
+
227
+ ```js
228
+ const wsh = createWshServer({ auth: { authorizedKeys }, exec: true }); // no listen()
229
+ wss.on('connection', (ws, req) => wsh.accept(ws, { remote: { address: req.socket.remoteAddress } }));
230
+ ```
231
+
232
+ `accept(socket, { remote })` takes any WebSocket-like duplex (`send`, `close`, `readyState`, and `on` or
233
+ `addEventListener`): a `ws` socket, a browser/Worker `WebSocket`, a test double. It applies the same auth, features,
234
+ rate limit and session registry as `listen()`'s connections, may be mixed with `listen()`, and resolves to
235
+ `{ close() }`. `remote.address` keys the password-failure throttle (sockets without one share a key). `server.close()`
236
+ drops every accepted socket.
237
+
238
+ ### In-memory host (`@johnhenry/wsh/memory`)
239
+
240
+ A browser-safe host for demos and tests: no sockets, no ports, no Node (nothing in its import graph is `node:*` or `ws`).
241
+
242
+ ```js
243
+ import { createMemoryHost } from '@johnhenry/wsh/memory';
244
+
245
+ const host = createMemoryHost({
246
+ auth: { authorizedKeys: [rawPublicKey] }, // same shapes as createWshServer
247
+ exec: async (command, io) => { await io.write(`ran ${command}\n`); return 0; },
248
+ });
249
+ const client = await host.connect({ username: 'demo', keyPair }); // or { signer } / { password }
250
+ const session = await client.openSession({ type: 'exec', command: 'hi' });
251
+ session.onData = (d) => console.log(new TextDecoder().decode(d)); // "ran hi"
252
+ ```
253
+
254
+ It is the real connection handler speaking QMux over an in-memory socket pair, so the client code you test is the code
255
+ you ship. `host.createClient()` / `host.WebSocket` / `host.accept(socket)` / `createSocketPair()` are the lower-level pieces.
256
+ Supported: `auth`, `exec` (a function; there is no shell), `mcp`, `extensions`. Not available in memory: `pty`, `fs`,
257
+ `gateway`, `relay`, `sessions`, `rpc`, `hostKey` and WebTransport (use `@johnhenry/wsh/server`).
258
+
259
+ ### Signing with a page-held signer
260
+
261
+ `connect()`, `WshClient.exec()` and `connectReverse()` take `signer: { publicKey, sign(data) }` instead of `keyPair`
262
+ (not both): `publicKey` is the raw 32-byte Ed25519 key or a public `CryptoKey`, `sign` returns the 64-byte signature
263
+ (sync or async). For keys that live in a wallet, a hardware token or another realm and are not a `CryptoKeyPair`.
264
+
204
265
  ### WebTransport listener
205
266
 
206
267
  The client's ladder prefers WebTransport (`https://`) and falls back to `wss://`;
@@ -936,7 +997,7 @@ so options the platform gains later need no change here.
936
997
  | `WshMcpBridge` | Remote MCP tool discovery and invocation |
937
998
  | `RpcChannel` / `RpcError` | Typed RPC channel (JSON-RPC 2.0 over a CBOR sequence): `client.openRpc(protocol)`; `RPC_FEATURE`, `rpcProtocolFeature(name)`, `RPC_ERROR`, `CborSequenceDecoder` |
938
999
  | `mcpClientTransport()` / `mcpServerAdapter()` (`/server`) | MCP SDK `Transport` over an `rpc` channel |
939
- | `SessionRecorder` | Record PTY I/O with timestamps (own schema, not asciicast v2) |
1000
+ | `SessionRecorder` | Record PTY I/O with timestamps (asciicast-v2-shaped JSON; convert to play in asciinema, see the note under Session recording) |
940
1001
  | `SessionPlayer` | Replay recordings with original timing |
941
1002
  | `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
942
1003
  | `sign(privateKey, message)` / `verify(publicKey, signature, message)` | Raw Ed25519 sign / verify. This positional order (WebCrypto's) is the one canonical form across wsh, raijin and browsermesh |
@@ -1003,7 +1064,7 @@ than left to be found.
1003
1064
  | Attach / resume | `attachSession()`, `resumeSession()`, `WshSession.seq` | `attach_session()`, `resume_session()` | **Wire-unified** (`Attach`/`Resume`/`Presence`); `seq` = cumulative output bytes. `@johnhenry/wsh/server` honours `last_seq` (bounded ring, gap errors) and answers Presence with `channel_id`/`seq`; the Rust `wsh-server` does the same (`rust-v0.2.0`+; fixed 256 KiB ring) |
1004
1065
  | Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()`; `@johnhenry/wsh/server`: `relay` option and `createReverseHost()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service. The Node relay is default-deny and bridges one operator per peer unless `maxOperatorsPerPeer` is raised (`busy:` reasons say so); E2E and bridged feature gates need `rust-v0.3.0`+ on the Rust side; several operators per peer and the signed `KeyExchange` are JS-relay only for now |
1005
1066
  | Post-quantum E2E (experimental) | `initiateE2E()` (WebCrypto ML-KEM-768 or `@noble/post-quantum` fallback) | `E2eKeyExchange` (`ml-kem` crate) | **Wire-unified** algorithm and transcript; key material backends differ by platform necessity |
1006
- | Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast v2) | none | **JS-only** -- no current Rust consumer needs playback; the format itself (asciicast v2) is not proprietary if one is added later |
1067
+ | Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast-v2-shaped JSON) | none | **JS-only** -- no current Rust consumer needs playback; the vocabulary is asciicast v2's, so a Rust port could share it |
1007
1068
  | Self-signed cert pinning | `serverCertificateHashes` (WebTransport option) | N/A -- Rust dials a cert its own TLS stack already trusts, or `--generate-cert`'s self-signed cert out of band | **Deliberately not unified** -- this is a browser-specific WebTransport API shape, not a wire-protocol concept |
1008
1069
 
1009
1070
  ### Build and test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.30.0",
3
+ "version": "0.31.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",
@@ -12,6 +12,10 @@
12
12
  "./server": {
13
13
  "types": "./src/server/index.d.ts",
14
14
  "import": "./src/server/index.mjs"
15
+ },
16
+ "./memory": {
17
+ "types": "./src/memory-host.d.ts",
18
+ "import": "./src/memory-host.mjs"
15
19
  }
16
20
  },
17
21
  "types": "./src/index.d.ts",
package/src/auth.mjs CHANGED
@@ -159,6 +159,65 @@ export async function sign(privateKey, data) {
159
159
  return new Uint8Array(sig);
160
160
  }
161
161
 
162
+ /**
163
+ * A page-held signer: an object that can sign but need not expose a private key.
164
+ * `publicKey` is the raw 32-byte Ed25519 key (or a `CryptoKey`); `sign(data)` returns the
165
+ * 64-byte signature, synchronously or not. For keys held by a wallet, a hardware token, a
166
+ * Worker, or anything else that is not a `CryptoKeyPair`.
167
+ *
168
+ * @typedef {{ publicKey: Uint8Array | CryptoKey, sign: (data: Uint8Array) => Uint8Array | Promise<Uint8Array> }} WshSigner
169
+ */
170
+
171
+ /**
172
+ * Turn `{ keyPair }` or `{ signer }` into one `{ publicKeyRaw, sign }` pair, or `null` when neither is given.
173
+ * Throws a TypeError for both together or for a malformed signer.
174
+ *
175
+ * @param {{ keyPair?: CryptoKeyPair, signer?: WshSigner }} credentials
176
+ * @returns {Promise<{ publicKeyRaw: Uint8Array, sign: (data: Uint8Array) => Promise<Uint8Array> } | null>}
177
+ */
178
+ export async function resolveSigner({ keyPair, signer } = {}) {
179
+ if (keyPair && signer) throw new TypeError('pass either keyPair or signer, not both');
180
+ if (keyPair) {
181
+ return { publicKeyRaw: await exportPublicKeyRaw(keyPair.publicKey), sign: (data) => sign(keyPair.privateKey, data) };
182
+ }
183
+ if (!signer) return null;
184
+ if (typeof signer !== 'object' || typeof signer.sign !== 'function') {
185
+ throw new TypeError('signer must be { publicKey, sign(data) }');
186
+ }
187
+ let publicKeyRaw;
188
+ if (signer.publicKey instanceof Uint8Array) publicKeyRaw = signer.publicKey;
189
+ else if (signer.publicKey && typeof signer.publicKey === 'object' && signer.publicKey.type === 'public') publicKeyRaw = await exportPublicKeyRaw(signer.publicKey);
190
+ if (!publicKeyRaw || publicKeyRaw.byteLength !== 32) {
191
+ throw new TypeError('signer.publicKey must be a raw 32-byte Ed25519 key or a public CryptoKey');
192
+ }
193
+ return {
194
+ publicKeyRaw,
195
+ sign: async (data) => {
196
+ const sig = await signer.sign(data);
197
+ if (!(sig instanceof Uint8Array) || sig.byteLength !== 64) throw new TypeError('signer.sign() must return a 64-byte Ed25519 signature');
198
+ return sig;
199
+ },
200
+ };
201
+ }
202
+
203
+ /**
204
+ * `signChallenge` for a resolved signer (see `resolveSigner`).
205
+ * @returns {Promise<{ signature: Uint8Array, publicKeyRaw: Uint8Array }>}
206
+ */
207
+ export async function signChallengeWith(signer, sessionId, nonce, opts) {
208
+ const transcript = await buildTranscript(sessionId, nonce, opts);
209
+ return { signature: await signer.sign(transcript), publicKeyRaw: signer.publicKeyRaw };
210
+ }
211
+
212
+ /**
213
+ * `signPeerRecord` for a resolved signer (see `resolveSigner`).
214
+ * @returns {Promise<{ signature: Uint8Array, publicKeyRaw: Uint8Array }>}
215
+ */
216
+ export async function signPeerRecordWith(signer, record) {
217
+ const transcript = await buildPeerRecordTranscript(record);
218
+ return { signature: await signer.sign(transcript), publicKeyRaw: signer.publicKeyRaw };
219
+ }
220
+
162
221
  /**
163
222
  * Verify an Ed25519 signature.
164
223
  * @param {CryptoKey} publicKey
package/src/client.mjs CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  sessionGrant as sessionGrantMsg, sessionRevoke as sessionRevokeMsg,
38
38
  isRelayForwardable,
39
39
  } from './messages.mjs';
40
- import { signChallenge, exportPublicKeyRaw, signPeerRecord, verifyPeerRecord, importPublicKeyRaw, fingerprint as computeFingerprint } from './auth.mjs';
40
+ import { resolveSigner, signChallengeWith, signPeerRecordWith, verifyPeerRecord, importPublicKeyRaw, fingerprint as computeFingerprint } from './auth.mjs';
41
41
  import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate } from './mlkem.mjs';
42
42
  import { compareBytes, combineHybridSecret, verifyKeyExchangeSignature } from './e2e-exchange.mjs';
43
43
  import { WshSession } from './session.mjs';
@@ -467,7 +467,8 @@ export class WshClient {
467
467
  * @param {string} url - Server URL (https:// for WebTransport, wss:// or ws:// for WebSocket)
468
468
  * @param {object} opts
469
469
  * @param {string} opts.username - Username for authentication
470
- * @param {CryptoKeyPair} [opts.keyPair] - Ed25519 key pair for pubkey auth
470
+ * @param {CryptoKeyPair} [opts.keyPair]
471
+ * @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
471
472
  * @param {string} [opts.password] - Password for password auth
472
473
  * @param {'wt'|'ws'|'auto'} [opts.transport] - Force a specific transport
473
474
  * @param {object} [opts.webTransport] - Options forwarded to the
@@ -493,15 +494,16 @@ export class WshClient {
493
494
  * @param {Function} [opts.onHostKey] - Per-connect `onHostKey`.
494
495
  * @returns {Promise<string>} The server-assigned session ID
495
496
  */
496
- async connect(url, { username, keyPair, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT, ...hostKeyOpts } = {}) {
497
+ async connect(url, { username, keyPair, signer, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT, ...hostKeyOpts } = {}) {
497
498
  if (this.#state !== STATE_DISCONNECTED && this.#state !== STATE_CLOSED) {
498
499
  throw new Error(`Client already ${this.#state}`);
499
500
  }
500
501
  if (!username) {
501
502
  throw new Error('username is required');
502
503
  }
503
- if (!keyPair && !password) {
504
- throw new Error('Either keyPair or password is required for authentication');
504
+ const cred = await resolveSigner({ keyPair, signer });
505
+ if (!cred && !password) {
506
+ throw new Error('Either keyPair, signer or password is required for authentication');
505
507
  }
506
508
 
507
509
  this.#state = STATE_CONNECTING;
@@ -519,7 +521,7 @@ export class WshClient {
519
521
  this.#state = STATE_CONNECTED;
520
522
 
521
523
  // ── Auth handshake ────────────────────────────────────────────
522
- const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
524
+ const authMethod = cred ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
523
525
  const hostKeyNonce = newHostKeyNonce();
524
526
  await transport.sendControl(
525
527
  hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] })
@@ -568,9 +570,8 @@ export class WshClient {
568
570
  // Sign the challenge. Challenge.session_id (not the one from
569
571
  // ServerHello) is authoritative for the transcript — see the
570
572
  // comment in the CHALLENGE-first branch below for why.
571
- const { signature, publicKeyRaw } = await signChallenge(
572
- keyPair.privateKey,
573
- keyPair.publicKey,
573
+ const { signature, publicKeyRaw } = await signChallengeWith(
574
+ cred,
574
575
  challengeMsg.session_id,
575
576
  challengeMsg.nonce,
576
577
  { username }
@@ -595,7 +596,7 @@ export class WshClient {
595
596
  } else if (firstResponse.type === MSG.CHALLENGE) {
596
597
  // Some servers skip SERVER_HELLO and go straight to CHALLENGE.
597
598
  await this.#verifyHostKey(null, hostCtx);
598
- if (authMethod !== AUTH_METHOD.PUBKEY || !keyPair) {
599
+ if (authMethod !== AUTH_METHOD.PUBKEY || !cred) {
599
600
  throw new Error('Server sent CHALLENGE but no key pair was provided');
600
601
  }
601
602
 
@@ -607,9 +608,8 @@ export class WshClient {
607
608
  // tempSessionId as a fallback for this.#sessionId below in case
608
609
  // AUTH_OK's own session_id is ever absent.
609
610
  tempSessionId = firstResponse.session_id;
610
- const { signature, publicKeyRaw } = await signChallenge(
611
- keyPair.privateKey,
612
- keyPair.publicKey,
611
+ const { signature, publicKeyRaw } = await signChallengeWith(
612
+ cred,
613
613
  firstResponse.session_id,
614
614
  firstResponse.nonce,
615
615
  { username }
@@ -996,18 +996,19 @@ export class WshClient {
996
996
  * @param {object} opts
997
997
  * @param {string} opts.username
998
998
  * @param {CryptoKeyPair} [opts.keyPair]
999
+ * @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
999
1000
  * @param {string} [opts.password]
1000
1001
  * @param {number} [opts.timeout=60000] - Overall timeout in ms
1001
1002
  * @param {boolean} [opts.primer=true] - See `openSession({ primer })`
1002
1003
  * @returns {Promise<{stdout: Uint8Array, exitCode: number}>}
1003
1004
  */
1004
- static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true, ...hostKeyOpts } = {}) {
1005
+ static async exec(url, command, { username, keyPair, signer, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true, ...hostKeyOpts } = {}) {
1005
1006
  const client = new WshClient();
1006
1007
  const chunks = [];
1007
1008
  let exitCode = -1;
1008
1009
 
1009
1010
  try {
1010
- await client.connect(url, { username, keyPair, password, ...hostKeyOpts });
1011
+ await client.connect(url, { username, keyPair, signer, password, ...hostKeyOpts });
1011
1012
 
1012
1013
  const session = await client.openSession({ type: 'exec', command, primer });
1013
1014
 
@@ -1056,6 +1057,7 @@ export class WshClient {
1056
1057
  * @param {object} opts
1057
1058
  * @param {string} opts.username
1058
1059
  * @param {CryptoKeyPair} [opts.keyPair]
1060
+ * @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
1059
1061
  * @param {string} [opts.password]
1060
1062
  * @param {object} [opts.expose] - Capabilities to expose { shell, exec, fs, tools }
1061
1063
  * @param {string} [opts.peerType]
@@ -1069,6 +1071,7 @@ export class WshClient {
1069
1071
  async connectReverse(url, {
1070
1072
  username,
1071
1073
  keyPair,
1074
+ signer,
1072
1075
  password,
1073
1076
  webTransport,
1074
1077
  expose = {},
@@ -1081,7 +1084,7 @@ export class WshClient {
1081
1084
  ...hostKeyOpts
1082
1085
  } = {}) {
1083
1086
  // Authenticate normally first.
1084
- const sessionId = await this.connect(url, { username, keyPair, password, webTransport, ...hostKeyOpts });
1087
+ const sessionId = await this.connect(url, { username, keyPair, signer, password, webTransport, ...hostKeyOpts });
1085
1088
 
1086
1089
  // Build capabilities list from expose options.
1087
1090
  const capabilities = [];
@@ -1091,10 +1094,9 @@ export class WshClient {
1091
1094
  if (expose.tools) capabilities.push('tools');
1092
1095
 
1093
1096
  // Export public key for peer identification.
1097
+ const cred = await resolveSigner({ keyPair, signer });
1094
1098
  let publicKey = null;
1095
- if (keyPair) {
1096
- publicKey = await exportPublicKeyRaw(keyPair.publicKey);
1097
- }
1099
+ if (cred) publicKey = cred.publicKeyRaw;
1098
1100
 
1099
1101
  const effectiveShellBackend = shellBackend || (expose.shell ? 'virtual-shell' : 'exec-only');
1100
1102
  const record = {
@@ -1118,8 +1120,8 @@ export class WshClient {
1118
1120
  // (see auth.mjs's "Signed peer records" section). Only possible with
1119
1121
  // a real identity key, same precondition `publicKey` already had.
1120
1122
  let recordSignature;
1121
- if (keyPair) {
1122
- ({ signature: recordSignature } = await signPeerRecord(keyPair.privateKey, keyPair.publicKey, record));
1123
+ if (cred) {
1124
+ ({ signature: recordSignature } = await signPeerRecordWith(cred, record));
1123
1125
  }
1124
1126
 
1125
1127
  // Register as a reverse peer.
@@ -2360,18 +2362,20 @@ export class WshClient {
2360
2362
  * @param {object} opts
2361
2363
  * @param {string} opts.username
2362
2364
  * @param {CryptoKeyPair} [opts.keyPair]
2365
+ * @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
2363
2366
  * @param {string} [opts.password]
2364
2367
  * @param {number} [opts.timeout]
2365
2368
  * @returns {Promise<string>} Session ID
2366
2369
  * @private
2367
2370
  */
2368
- async #performAuth({ username, keyPair, password, timeout = DEFAULT_AUTH_TIMEOUT, url, ...hostKeyOpts } = {}) {
2371
+ async #performAuth({ username, keyPair, signer, password, timeout = DEFAULT_AUTH_TIMEOUT, url, ...hostKeyOpts } = {}) {
2369
2372
  if (!username) throw new Error('username is required');
2370
- if (!keyPair && !password) throw new Error('Either keyPair or password is required');
2373
+ const cred = await resolveSigner({ keyPair, signer });
2374
+ if (!cred && !password) throw new Error('Either keyPair, signer or password is required');
2371
2375
 
2372
2376
  this.#hostKey = null;
2373
2377
  try {
2374
- const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
2378
+ const authMethod = cred ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
2375
2379
  const hostKeyNonce = newHostKeyNonce();
2376
2380
  await this.#transport.sendControl(hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] }));
2377
2381
  const hostCtx = { ...hostKeyOpts, url, username, nonce: hostKeyNonce };
@@ -2409,8 +2413,8 @@ export class WshClient {
2409
2413
  return this.#sessionId;
2410
2414
  }
2411
2415
 
2412
- const { signature, publicKeyRaw } = await signChallenge(
2413
- keyPair.privateKey, keyPair.publicKey, challengeMsg.session_id, challengeMsg.nonce, { username }
2416
+ const { signature, publicKeyRaw } = await signChallengeWith(
2417
+ cred, challengeMsg.session_id, challengeMsg.nonce, { username }
2414
2418
  );
2415
2419
 
2416
2420
  await this.#transport.sendControl(authMsg({
@@ -2422,7 +2426,7 @@ export class WshClient {
2422
2426
  }));
2423
2427
  }
2424
2428
  } else if (firstResponse.type === MSG.CHALLENGE) {
2425
- if (!keyPair) throw new Error('Server sent CHALLENGE but no key pair provided');
2429
+ if (!cred) throw new Error('Server sent CHALLENGE but no key pair provided');
2426
2430
  await this.#verifyHostKey(null, hostCtx);
2427
2431
 
2428
2432
  // Challenge carries session_id directly — see connect()'s
@@ -2430,8 +2434,8 @@ export class WshClient {
2430
2434
  // this.#sessionId below in case AUTH_OK's own session_id is ever
2431
2435
  // absent.
2432
2436
  tempSessionId = firstResponse.session_id;
2433
- const { signature, publicKeyRaw } = await signChallenge(
2434
- keyPair.privateKey, keyPair.publicKey, firstResponse.session_id, firstResponse.nonce, { username }
2437
+ const { signature, publicKeyRaw } = await signChallengeWith(
2438
+ cred, firstResponse.session_id, firstResponse.nonce, { username }
2435
2439
  );
2436
2440
 
2437
2441
  await this.#transport.sendControl(authMsg({
package/src/index.d.ts CHANGED
@@ -1108,6 +1108,38 @@ export function signChallenge(
1108
1108
  opts?: WshTranscriptOptions,
1109
1109
  ): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
1110
1110
 
1111
+ /**
1112
+ * A signer that need not expose a private key: a wallet, a hardware token, a Worker.
1113
+ * `publicKey` is the raw 32-byte Ed25519 key or a public `CryptoKey`; `sign` returns the 64-byte signature.
1114
+ */
1115
+ export interface WshSigner {
1116
+ publicKey: Uint8Array | CryptoKey;
1117
+ sign(data: Uint8Array): Uint8Array | Promise<Uint8Array>;
1118
+ }
1119
+
1120
+ /** A `keyPair` or `signer` normalized to a raw public key and an async `sign`; `null` when neither is given. */
1121
+ export interface WshResolvedSigner {
1122
+ publicKeyRaw: Uint8Array;
1123
+ sign(data: Uint8Array): Promise<Uint8Array>;
1124
+ }
1125
+
1126
+ /** Normalize `{ keyPair }` or `{ signer }`. Throws a TypeError for both together or a malformed signer. */
1127
+ export function resolveSigner(credentials?: { keyPair?: CryptoKeyPair; signer?: WshSigner }): Promise<WshResolvedSigner | null>;
1128
+
1129
+ /** `signChallenge` for a signer resolved by `resolveSigner`. */
1130
+ export function signChallengeWith(
1131
+ signer: WshResolvedSigner,
1132
+ sessionId: string,
1133
+ nonce: Uint8Array,
1134
+ opts?: WshTranscriptOptions,
1135
+ ): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
1136
+
1137
+ /** `signPeerRecord` for a signer resolved by `resolveSigner`. */
1138
+ export function signPeerRecordWith(
1139
+ signer: WshResolvedSigner,
1140
+ record: WshPeerRecord,
1141
+ ): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
1142
+
1111
1143
  /**
1112
1144
  * Server-side: verify a client's challenge response.
1113
1145
  */
@@ -1385,7 +1417,10 @@ export class SerialQueue<T = unknown> {
1385
1417
  * Provides the same interface as WebTransportTransport so that upper
1386
1418
  * layers (session, client) work identically over either transport.
1387
1419
  */
1388
- export class WebSocketTransport extends WshTransport {}
1420
+ export class WebSocketTransport extends WshTransport {
1421
+ /** `WebSocket`: a constructor to dial with instead of the global one (a test double, `createMemoryHost().WebSocket`). */
1422
+ constructor(options?: { WebSocket?: new (url: string) => any });
1423
+ }
1389
1424
 
1390
1425
  /**
1391
1426
  * @deprecated The old hand-rolled 5-byte mux's frame-type byte values.
@@ -1772,6 +1807,8 @@ export interface WshHostKeyOptions {
1772
1807
  export interface WshConnectOptions extends WshHostKeyOptions {
1773
1808
  username: string;
1774
1809
  keyPair?: CryptoKeyPair;
1810
+ /** A page-held signer instead of `keyPair` (not both). */
1811
+ signer?: WshSigner;
1775
1812
  password?: string;
1776
1813
  transport?: 'wt' | 'ws';
1777
1814
  /**
@@ -1845,6 +1882,8 @@ export interface WshResumeSessionOptions {
1845
1882
  export interface WshConnectReverseOptions {
1846
1883
  username: string;
1847
1884
  keyPair?: CryptoKeyPair;
1885
+ /** A page-held signer instead of `keyPair` (not both). */
1886
+ signer?: WshSigner;
1848
1887
  password?: string;
1849
1888
  /** Forwarded to the underlying `connect()` -- see `WshConnectOptions`. */
1850
1889
  webTransport?: WshWebTransportOptions;
@@ -1871,6 +1910,8 @@ export interface WshConnectReverseOptions {
1871
1910
  export interface WshExecOptions extends WshHostKeyOptions {
1872
1911
  username: string;
1873
1912
  keyPair?: CryptoKeyPair;
1913
+ /** A page-held signer instead of `keyPair` (not both). */
1914
+ signer?: WshSigner;
1874
1915
  password?: string;
1875
1916
  timeout?: number;
1876
1917
  /** See {@link WshOpenSessionOptions.primer}. */
@@ -2542,7 +2583,7 @@ export interface SessionRecordingEntry {
2542
2583
  data: unknown;
2543
2584
  }
2544
2585
 
2545
- /** Asciicast v2 export format. */
2586
+ /** The `toJSON()` export: asciicast-v2-shaped (not a playable asciicast file -- one JSON document, plus `sessionId` and `open`/`exit` events). */
2546
2587
  export interface SessionRecordingJSON {
2547
2588
  version: 2;
2548
2589
  width: number;
@@ -2580,7 +2621,7 @@ export class SessionRecorder {
2580
2621
  record(type: SessionRecordingEventType | string, data: unknown): void;
2581
2622
 
2582
2623
  /**
2583
- * Export the recording as a JSON-serializable object (asciicast v2 format).
2624
+ * Export the recording as a JSON-serializable object (asciicast-v2-shaped; see the README for converting it to a `.cast` file).
2584
2625
  */
2585
2626
  toJSON(): SessionRecordingJSON;
2586
2627
 
package/src/index.mjs CHANGED
@@ -47,6 +47,7 @@ export {
47
47
  generateKeyPair, exportPublicKeyRaw, exportPublicKeySSH,
48
48
  importPublicKeyRaw, exportPrivateKeyPKCS8, importPrivateKeyPKCS8,
49
49
  sign, verify, buildTranscript, signChallenge, verifyChallenge,
50
+ resolveSigner, signChallengeWith, signPeerRecordWith,
50
51
  buildPeerRecordTranscript, signPeerRecord, verifyPeerRecord,
51
52
  fingerprint, shortFingerprint, generateNonce,
52
53
  podId, fingerprintToPodId, podIdToFingerprint,
@@ -0,0 +1,48 @@
1
+ import type { WshClient } from './index.js';
2
+ import type { WshSigner } from './index.js';
3
+ import type {
4
+ WshServerAuthOptions, WshServerPrincipal, WshServerMcpOptions, WshExecRunner, WshServerOptions,
5
+ WshAcceptableSocket, WshAcceptOptions,
6
+ } from './server/index.js';
7
+
8
+ export interface WshMemoryHostOptions {
9
+ /** As for `createWshServer`. Omitted = refuse everyone. */
10
+ auth?: WshServerAuthOptions | ((who: WshServerPrincipal) => boolean | Promise<boolean>);
11
+ /** `run(command, io)`; there is no shell in memory, so there is no `true`. */
12
+ exec?: WshExecRunner | { run: WshExecRunner; timeoutMs?: number; clientEnv?: boolean };
13
+ mcp?: WshServerMcpOptions;
14
+ extensions?: WshServerOptions['extensions'];
15
+ bindTimeoutMs?: number;
16
+ onLog?: (line: string) => void;
17
+ }
18
+
19
+ /** Credentials for `host.connect()`: the options of `WshClient.connect()` minus the URL. */
20
+ export interface WshMemoryConnectOptions {
21
+ username: string;
22
+ keyPair?: CryptoKeyPair;
23
+ signer?: WshSigner;
24
+ password?: string;
25
+ timeout?: number;
26
+ }
27
+
28
+ export interface WshMemoryHost {
29
+ /** The URL to give `client.connect()`; the host ignores it. */
30
+ readonly url: string;
31
+ /** A `WebSocket` constructor that dials this host. */
32
+ readonly WebSocket: new (url?: string) => any;
33
+ /** Serve any pre-upgraded socket (see `createSocketPair()`). */
34
+ accept(socket: WshAcceptableSocket, options?: WshAcceptOptions): Promise<{ close(): void }>;
35
+ socketPair: typeof createSocketPair;
36
+ /** A `WshClient` whose transport is this host: `await client.connect(host.url, { username, keyPair, transport: 'ws' })`. */
37
+ createClient(options?: ConstructorParameters<typeof WshClient>[0]): WshClient;
38
+ /** `createClient()` and `connect()` in one. */
39
+ connect(credentials: WshMemoryConnectOptions): Promise<WshClient>;
40
+ /** Drop every connection. */
41
+ close(): Promise<void>;
42
+ }
43
+
44
+ /** Two connected in-memory WebSocket-like sockets. */
45
+ export function createSocketPair(): [WshAcceptableSocket & EventTarget, WshAcceptableSocket & EventTarget];
46
+
47
+ /** A browser-safe wsh host in this JavaScript realm: no sockets, no Node. */
48
+ export function createMemoryHost(options?: WshMemoryHostOptions): WshMemoryHost;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `@johnhenry/wsh/memory` -- a wsh host that lives in the same JavaScript realm as its clients, with no sockets, no
3
+ * ports and no Node. Browser-safe (a page, a Worker, Deno, Bun, Node): it imports nothing from `node:*`, and a test
4
+ * (`test/memory-host.test.mjs`) keeps it that way. For demos, docs and tests of an application that talks wsh.
5
+ *
6
+ * It is the real protocol end to end -- the same connection handler `createWshServer` runs (handshake, Ed25519 or
7
+ * password auth, exec channels, MCP tools, extensions), speaking QMux over a pair of in-memory WebSocket-like
8
+ * sockets -- so what a client does here is what it does against a server. It serves what a browser can: `exec` as a
9
+ * function you provide (there is no shell), `mcp` tools, `extensions`. Not available: `pty`, `fs`, `gateway`, `relay`,
10
+ * `sessions`, `rpc`, `hostKey`, WebTransport (they need Node; use `@johnhenry/wsh/server`).
11
+ *
12
+ * ```js
13
+ * import { createMemoryHost } from '@johnhenry/wsh/memory';
14
+ * const host = createMemoryHost({
15
+ * auth: { authorizedKeys: [rawPublicKey] },
16
+ * exec: async (command, io) => { await io.write(`you ran: ${command}\n`); return 0; },
17
+ * });
18
+ * const client = await host.connect({ username: 'demo', keyPair });
19
+ * ```
20
+ */
21
+
22
+ import { WshClient } from './client.mjs';
23
+ import { WebSocketTransport } from './transport-ws.mjs';
24
+ import { createConnectionFactory } from './server/connection.mjs';
25
+ import { buildAuthorizer, authMethods, FailureLimiter } from './server/auth.mjs';
26
+ import { createMcpHost } from './server/mcp.mjs';
27
+ import { bindSocket } from './server/socket.mjs';
28
+
29
+ const CONNECTING = 0;
30
+ const OPEN = 1;
31
+ const CLOSING = 2;
32
+ const CLOSED = 3;
33
+
34
+ /** The URL to hand `client.connect()` (the host ignores it; it only has to look like a WebSocket URL). */
35
+ const MEMORY_URL = 'ws://memory.invalid/';
36
+
37
+ function event(type, props) {
38
+ const ev = new Event(type);
39
+ Object.assign(ev, props);
40
+ return ev;
41
+ }
42
+
43
+ /** One end of an in-memory socket pair: just enough WebSocket for `WebSocketTransport` and `bindSocket`. */
44
+ class MemorySocket extends EventTarget {
45
+ static CONNECTING = CONNECTING;
46
+ static OPEN = OPEN;
47
+ static CLOSING = CLOSING;
48
+ static CLOSED = CLOSED;
49
+
50
+ readyState = CONNECTING;
51
+ binaryType = 'arraybuffer';
52
+ /** @type {MemorySocket | null} */
53
+ peer = null;
54
+
55
+ send(data) {
56
+ if (this.readyState !== OPEN) throw new Error('WebSocket is not open');
57
+ if (typeof data === 'string') return; // wsh frames are binary
58
+ const view = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
59
+ const copy = view.slice().buffer;
60
+ const peer = this.peer;
61
+ queueMicrotask(() => { if (peer.readyState === OPEN) peer.dispatchEvent(event('message', { data: copy })); });
62
+ }
63
+
64
+ close(code = 1000, reason = '') {
65
+ if (this.readyState >= CLOSING) return;
66
+ this.readyState = CLOSING;
67
+ const peer = this.peer;
68
+ queueMicrotask(() => {
69
+ for (const s of [this, peer]) {
70
+ if (s.readyState === CLOSED) continue;
71
+ s.readyState = CLOSED;
72
+ s.dispatchEvent(event('close', { code, reason, wasClean: true }));
73
+ }
74
+ });
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Two connected sockets: what one sends, the other receives (asynchronously, in order). Both are open after a tick.
80
+ * @returns {[MemorySocket, MemorySocket]}
81
+ */
82
+ export function createSocketPair() {
83
+ const a = new MemorySocket();
84
+ const b = new MemorySocket();
85
+ a.peer = b;
86
+ b.peer = a;
87
+ queueMicrotask(() => {
88
+ for (const s of [a, b]) { s.readyState = OPEN; s.dispatchEvent(new Event('open')); }
89
+ });
90
+ return [a, b];
91
+ }
92
+
93
+ /**
94
+ * @param {object} [options]
95
+ * @param {object | Function} [options.auth] - As for `createWshServer`. Omitted = refuse everyone.
96
+ * @param {Function | { run: Function, timeoutMs?: number, clientEnv?: boolean }} [options.exec]
97
+ * `run(command, io) => Promise<exitCode>` (see `ExecIo` in `server/exec.mjs`): the host has no shell, so there is no `true`.
98
+ * @param {{ tools?: object[] | object, client?: object, authorize?: Function, maxConcurrent?: number, timeoutMs?: number }} [options.mcp]
99
+ * @param {Record<string, Function>} [options.extensions] - Handlers for application-defined messages, as for `createWshServer`.
100
+ * @param {number} [options.bindTimeoutMs=3000]
101
+ * @param {(line: string) => void} [options.onLog]
102
+ */
103
+ export function createMemoryHost({ auth, exec, mcp, extensions, bindTimeoutMs = 3000, onLog = () => {} } = {}) {
104
+ if (exec === true) throw new TypeError('createMemoryHost: exec must be a function (run(command, io)); there is no shell to run `true` with');
105
+ if (exec !== undefined && typeof exec !== 'function' && typeof exec?.run !== 'function') {
106
+ throw new TypeError('createMemoryHost: exec must be a function or { run }');
107
+ }
108
+ if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createMemoryHost: extensions must be an object of handlers');
109
+ const execOptions = typeof exec === 'function' || !exec ? {} : exec;
110
+ const execRunner = typeof exec === 'function' ? exec : exec?.run ?? null;
111
+ const rl = auth?.rateLimit ?? {};
112
+ const rateLimit = {
113
+ limiter: new FailureLimiter(rl),
114
+ key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
115
+ failureDelayMs: rl.failureDelayMs ?? 250,
116
+ };
117
+ const methods = authMethods(auth);
118
+ const mcpHost = mcp ? createMcpHost(mcp) : null;
119
+
120
+ const ready = buildAuthorizer(auth).then((authorize) => createConnectionFactory({
121
+ authorize, execRunner, execOptions, pty: null, files: null, bindTimeoutMs, log: onLog,
122
+ methods, hostKey: null, rateLimit, extensions, mcp: mcpHost, rpc: null, gateway: null, sessions: null, relay: null,
123
+ }));
124
+ ready.catch(() => {});
125
+
126
+ const accepted = new Set();
127
+ let closed = false;
128
+
129
+ async function accept(socket, options) {
130
+ if (closed) throw new Error('createMemoryHost: host is closed');
131
+ const handle = await bindSocket(socket, ready, options);
132
+ accepted.add(handle);
133
+ return handle;
134
+ }
135
+
136
+ /** A `WebSocket` constructor that dials this host: `new host.WebSocket(url)` ignores the URL. */
137
+ class HostWebSocket extends MemorySocket {
138
+ constructor() {
139
+ super();
140
+ const [client, server] = createSocketPair();
141
+ void accept(server, { remote: { address: 'memory' } }).catch(onLog);
142
+ return client;
143
+ }
144
+ }
145
+
146
+ const host = {
147
+ /** Pass to `client.connect()`. */
148
+ url: MEMORY_URL,
149
+ WebSocket: HostWebSocket,
150
+ accept,
151
+ socketPair: createSocketPair,
152
+ /** A `WshClient` whose transport is this host (`await client.connect(host.url, ...)`). */
153
+ createClient(clientOptions = {}) {
154
+ return new WshClient({
155
+ ...clientOptions,
156
+ transportFactories: {
157
+ ws: () => new WebSocketTransport({ WebSocket: HostWebSocket }),
158
+ wt: () => { throw new Error('createMemoryHost: WebTransport is not available in memory'); },
159
+ },
160
+ });
161
+ },
162
+ /** `createClient()` and `connect()` in one: resolves to a connected client. */
163
+ async connect(credentials) {
164
+ const client = host.createClient();
165
+ await client.connect(MEMORY_URL, { transport: 'ws', ...credentials });
166
+ return client;
167
+ },
168
+ async close() {
169
+ closed = true;
170
+ for (const handle of accepted) handle.close();
171
+ accepted.clear();
172
+ },
173
+ };
174
+ return host;
175
+ }
package/src/recording.mjs CHANGED
@@ -2,8 +2,14 @@
2
2
  * Session recording and playback for wsh PTY sessions.
3
3
  *
4
4
  * Records all terminal I/O with timestamps for later replay.
5
- * The export format is compatible with the asciicast v2 structure
6
- * used by asciinema, enabling interop with standard terminal players.
5
+ * The export (`toJSON()`) is asciicast-v2-*shaped*, not an asciicast v2 file: the header
6
+ * fields (`version: 2`, `width`, `height`, `timestamp`, `env`) and the `[seconds, code, data]`
7
+ * events with the `o` / `i` / `r` codes are asciicast's, but it is one JSON document (asciicast
8
+ * v2 is newline-delimited JSON: a header line, then one event per line), the header carries
9
+ * wsh's own `sessionId`, `width`/`height` follow the latest resize rather than the start size,
10
+ * and the lifecycle events `open` / `exit` have no asciicast code. `asciinema play` therefore
11
+ * cannot read it as-is. The README ("Session recording") shows the short conversion to a real
12
+ * `.cast` file (header line + the `o`/`i`/`r` events); `fromJSON()` reads this document shape.
7
13
  */
8
14
 
9
15
  // ── SessionRecorder ──────────────────────────────────────────────────
@@ -66,16 +72,17 @@ export class SessionRecorder {
66
72
  /**
67
73
  * Export the recording as a JSON-serializable object.
68
74
  *
69
- * The format is compatible with asciicast v2:
75
+ * The shape follows asciicast v2 but this is NOT a playable asciicast file (see the file header):
70
76
  * - version: 2
71
- * - width, height: terminal dimensions at start
77
+ * - width, height: terminal dimensions as of the latest resize (asciicast: at start)
72
78
  * - timestamp: Unix epoch seconds when recording started
73
- * - env: optional environment metadata
74
- * - events: array of [time_seconds, event_type, data]
79
+ * - env: environment metadata
80
+ * - sessionId: wsh's own field
81
+ * - events: array of [time_seconds, event_type, data] -- one JSON document, not one line per event
75
82
  *
76
- * Event types in asciicast:
77
- * "o" = output, "i" = input, "r" = resize
78
- * We extend with: "open", "exit" for session lifecycle.
83
+ * Event types from asciicast:
84
+ * "o" = output, "i" = input, "r" = resize ("COLSxROWS")
85
+ * Not in asciicast, so dropped by the conversion in the README: "open", "exit" (session lifecycle).
79
86
  *
80
87
  * @returns {object}
81
88
  */
@@ -111,7 +118,8 @@ export class SessionRecorder {
111
118
  }
112
119
 
113
120
  /**
114
- * Import a recording from JSON (as produced by toJSON or parsed from asciicast).
121
+ * Import a recording from the JSON document `toJSON()` produces. An asciicast v2 `.cast` file is not read directly:
122
+ * parse its lines and gather them into `{ ...header, events }` first.
115
123
  *
116
124
  * @param {object | string} json - Recording object or JSON string
117
125
  * @returns {SessionRecorder}
@@ -13,7 +13,6 @@
13
13
  * dropped rather than forwarded to the process's stdin.
14
14
  */
15
15
 
16
- import { randomUUID } from 'node:crypto';
17
16
  import { QMuxConnection } from '../qmux-connection.mjs';
18
17
  import { FrameDecoder, frameEncode } from '../cbor.mjs';
19
18
  import {
@@ -24,8 +23,7 @@ import {
24
23
  import {
25
24
  generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
26
25
  } from '../auth.mjs';
27
- import { FILE_CHUNK_BYTES } from './fs.mjs';
28
- import { MAX_ATTACHMENTS } from './sessions.mjs';
26
+ import { FILE_CHUNK_BYTES, MAX_ATTACHMENTS } from './constants.mjs';
29
27
  import { MCP_CALL_ID_FEATURE } from '../client.mjs';
30
28
  import { RpcChannel, RPC_FEATURE, RPC_MAX_MESSAGE_PREFIX, rpcProtocolFeature } from '../rpc.mjs';
31
29
  import { sealFrame, openFrame, ROLE_TAGS } from '../e2e-frame.mjs';
@@ -70,6 +68,7 @@ const REPLAY_CHUNK_BYTES = 32 * 1024;
70
68
  const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
71
69
  const enc = new TextEncoder();
72
70
 
71
+ const randomUUID = () => crypto.randomUUID();
73
72
  const randomBytes = (n) => crypto.getRandomValues(new Uint8Array(n));
74
73
  const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
75
74
 
@@ -831,7 +830,7 @@ export function createConnectionFactory(cfg) {
831
830
  const args = m.command ? ['-c', String(m.command)] : [];
832
831
  proc = p.spawn(p.shell, args, {
833
832
  name: p.term, cols, rows, cwd: p.cwd,
834
- env: { ...(p.env ?? process.env), TERM: p.term, ...(m.env && typeof m.env === 'object' ? m.env : {}) },
833
+ env: { ...(p.env ?? globalThis.process?.env), TERM: p.term, ...(m.env && typeof m.env === 'object' ? m.env : {}) },
835
834
  });
836
835
  } catch (e) {
837
836
  return send(openFail({ reason: `pty spawn failed: ${e.message}` }));
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Limits shared by the connection handler and the Node-only modules that also export them
3
+ * (kept here so the connection handler imports nothing from `node:*` -- see `src/memory-host.mjs`).
4
+ */
5
+
6
+ /** Size of one FileChunk when a file is streamed to the client. */
7
+ export const FILE_CHUNK_BYTES = 64 * 1024;
8
+
9
+ /** Most clients attached to one detached-session at once. */
10
+ export const MAX_ATTACHMENTS = 16;
package/src/server/fs.mjs CHANGED
@@ -14,7 +14,8 @@ import { constants as fsc } from 'node:fs';
14
14
  import path from 'node:path';
15
15
 
16
16
  export const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
17
- export const FILE_CHUNK_BYTES = 64 * 1024;
17
+ import { FILE_CHUNK_BYTES } from './constants.mjs';
18
+ export { FILE_CHUNK_BYTES };
18
19
 
19
20
  /**
20
21
  * @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} opts
@@ -22,10 +22,15 @@
22
22
  * the host would have to resolve the name itself.
23
23
  */
24
24
 
25
- import net from 'node:net';
26
- import dns from 'node:dns/promises';
27
25
  import { MSG, gatewayOk, gatewayFail, gatewayClose, gatewayData, dnsResult, listenFail } from '../messages.gen.mjs';
28
26
 
27
+ // Loaded on first use so that importing the connection handler (which serves the gateway opcodes)
28
+ // does not pull `node:net` into a browser bundle (see `src/memory-host.mjs`).
29
+ let netModule = null;
30
+ let dnsModule = null;
31
+ const loadNet = async () => (netModule ??= (await import('node:net')).default);
32
+ const loadDns = async () => (dnsModule ??= (await import('node:dns/promises')).default);
33
+
29
34
  /** Numeric codes of GatewayFail (the same table `@johnhenry/browsermesh-netway` decodes). */
30
35
  export const GATEWAY_FAIL = Object.freeze({
31
36
  CONNECTION_REFUSED: 1, HOST_UNREACHABLE: 2, DNS_FAILED: 3, POLICY_DENIED: 4, TIMEOUT: 5, CLOSED: 6, QUEUE_FULL: 7,
@@ -62,7 +67,8 @@ export function destinationAllowed(allow, host, port) {
62
67
  * Resolves with the connected socket, or rejects with an Error carrying `.code`
63
68
  * (a GATEWAY_FAIL value).
64
69
  */
65
- export function socksConnect(proxy, host, port, { timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS } = {}) {
70
+ export async function socksConnect(proxy, host, port, { timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS } = {}) {
71
+ const net = await loadNet();
66
72
  return new Promise((resolve, reject) => {
67
73
  const name = Buffer.from(host, 'utf8');
68
74
  const fail = (code, message, sock) => { sock?.destroy(); const e = new Error(message); e.code = code; reject(e); };
@@ -161,6 +167,7 @@ export function createGatewaySession(cfg, send, log = () => {}) {
161
167
  pending.add(id);
162
168
  let sock;
163
169
  try {
170
+ const net = await loadNet();
164
171
  if (cfg.socks) sock = await socksConnect(cfg.socks, m.host, m.port, { timeoutMs: cfg.connectTimeoutMs });
165
172
  else {
166
173
  sock = await new Promise((resolve, reject) => {
@@ -202,7 +209,7 @@ export function createGatewaySession(cfg, send, log = () => {}) {
202
209
  try {
203
210
  const family = m.record_type === 'AAAA' ? 6 : m.record_type === 'A' || m.record_type === undefined ? 4 : 0;
204
211
  if (!family) return fail(id, GATEWAY_FAIL.DNS_FAILED, `unsupported record type ${String(m.record_type).slice(0, 8)}`);
205
- const addresses = (await dns.lookup(m.name, { family, all: true })).map((a) => a.address);
212
+ const addresses = (await (await loadDns()).lookup(m.name, { family, all: true })).map((a) => a.address);
206
213
  return reply(dnsResult({ gatewayId: id, addresses }));
207
214
  } catch (e) {
208
215
  return fail(id, GATEWAY_FAIL.DNS_FAILED, String(e.code === 'ENOTFOUND' ? 'name not found' : e.message).slice(0, 200));
@@ -402,9 +402,32 @@ export interface WshServerAddress {
402
402
  port: number;
403
403
  }
404
404
 
405
+ /**
406
+ * An already-accepted WebSocket-like duplex: `send(bytes)`, `close(code?)`, `readyState` (1 = open), and either Node-style
407
+ * `on(event, fn)` or `addEventListener(event, fn)`. A `ws` socket, a browser/Worker `WebSocket`, or a test double.
408
+ */
409
+ export interface WshAcceptableSocket {
410
+ send(data: Uint8Array): void;
411
+ close(code?: number): void;
412
+ readonly readyState: number;
413
+ on?(event: string, listener: (...args: any[]) => void): unknown;
414
+ addEventListener?(event: string, listener: (ev: any) => void): unknown;
415
+ }
416
+
417
+ export interface WshAcceptOptions {
418
+ /** What the embedder knows of the peer; `address` keys the password-failure throttle. */
419
+ remote?: { address?: string; headers?: Record<string, unknown> };
420
+ }
421
+
405
422
  export interface WshServer {
406
423
  /** Start listening. Requires the `ws` package. */
407
424
  listen(): Promise<WshServerAddress>;
425
+ /**
426
+ * Serve a pre-upgraded transport -- a socket your own HTTP server, Worker or test already accepted -- with this
427
+ * server's auth, features and sessions, so no listener of wsh's own is needed (`listen()` is optional).
428
+ * Resolves once the connection is attached; the handle's `close()` drops it. `close()` of the server drops all of them.
429
+ */
430
+ accept(socket: WshAcceptableSocket, options?: WshAcceptOptions): Promise<{ close(): void }>;
408
431
  /** Drop every connection and stop listening. */
409
432
  close(): Promise<void>;
410
433
  /** The bound address, or `null` when not listening. */
@@ -17,6 +17,7 @@ import { startWebTransport } from './webtransport.mjs';
17
17
  import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
18
18
  import { normalizeGateway } from './gateway.mjs';
19
19
  import { createRpcHost, mcpServerAdapter } from './rpc.mjs';
20
+ import { bindSocket } from './socket.mjs';
20
21
 
21
22
  export { parseAuthorizedKeys, STREAM_ANNOUNCE, mcpServerAdapter };
22
23
  export { createReverseHost } from './reverse-host.mjs';
@@ -74,7 +75,7 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
74
75
  * through a SOCKS5 proxy (Tor) with the name unresolved. UDP and reverse tunnels are refused. Off unless given. See `gateway.mjs`.
75
76
  * @param {number} [options.bindTimeoutMs=3000]
76
77
  * @param {(line: string) => void} [options.onLog]
77
- * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
78
+ * @returns {{ listen(): Promise<{address: string, port: number}>, accept(socket: object, options?: object): Promise<{ close(): void }>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
78
79
  */
79
80
  export function createWshServer({
80
81
  host = '127.0.0.1', port = 0, tls, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, extensions, gateway, webTransport, bindTimeoutMs = 3000, onLog = () => {},
@@ -102,15 +103,13 @@ export function createWshServer({
102
103
  const gatewayCfg = gateway === undefined ? null : normalizeGateway(gateway);
103
104
  const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
104
105
 
105
- return {
106
- async listen() {
107
- if (wss) throw new Error('createWshServer: already listening');
108
- let WebSocketServer;
109
- try {
110
- ({ WebSocketServer } = await import('ws'));
111
- } catch (err) {
112
- throw new Error('@johnhenry/wsh/server needs the "ws" package: npm install ws', { cause: err });
113
- }
106
+ /** Sockets handed to `accept()` that are still open. */
107
+ const accepted = new Set();
108
+ /** The connection factory, built once on first use (`listen()` or `accept()`) and torn down by `close()`. */
109
+ let ready = null;
110
+
111
+ function start() {
112
+ ready ??= (async () => {
114
113
  const authorize = await buildAuthorizer(auth);
115
114
  host_ = hostKey ? await loadHostKey(hostKey) : null;
116
115
  if (sessionOptions) {
@@ -125,10 +124,25 @@ export function createWshServer({
125
124
  }
126
125
  if (relay) hub = new RelayHub({ ...relay, log: onLog });
127
126
  if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createWshServer: extensions must be an object of handlers');
128
- const attach = createConnectionFactory({
127
+ return createConnectionFactory({
129
128
  authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
130
129
  methods, hostKey: host_, rateLimit, extensions, mcp: mcpHost, rpc: rpcHost, gateway: gatewayCfg, sessions: registry, relay: hub,
131
130
  });
131
+ })();
132
+ ready.catch(() => { ready = null; });
133
+ return ready;
134
+ }
135
+
136
+ return {
137
+ async listen() {
138
+ if (wss) throw new Error('createWshServer: already listening');
139
+ let WebSocketServer;
140
+ try {
141
+ ({ WebSocketServer } = await import('ws'));
142
+ } catch (err) {
143
+ throw new Error('@johnhenry/wsh/server needs the "ws" package: npm install ws', { cause: err });
144
+ }
145
+ const attach = await start();
132
146
 
133
147
  if (tls) {
134
148
  const { createServer } = await import('node:https');
@@ -172,20 +186,45 @@ export function createWshServer({
172
186
  return bound;
173
187
  },
174
188
 
189
+ /**
190
+ * Serve a pre-upgraded transport: an already-accepted WebSocket-like duplex (see `socket.mjs`) that the embedder's
191
+ * own HTTP server, Worker or test upgraded. Same auth, features and session registry as `listen()`'s connections, and
192
+ * no listener of this server's own is needed -- `accept()` works without `listen()`, and with both.
193
+ * `remote: { address, headers }` is what the embedder knows of the peer (`address` keys the password-failure throttle).
194
+ * `close()` drops every accepted socket. Resolves once the connection is attached.
195
+ *
196
+ * @param {object} socket - `send`, `close`, `readyState`, and `on` or `addEventListener`
197
+ * @param {{ remote?: { address?: string, headers?: object } }} [options]
198
+ * @returns {Promise<{ close(): void }>}
199
+ */
200
+ async accept(socket, options = {}) {
201
+ const handle = await bindSocket(socket, start(), options);
202
+ const entry = { socket, handle };
203
+ accepted.add(entry);
204
+ const forget = () => accepted.delete(entry);
205
+ if (typeof socket.addEventListener === 'function') socket.addEventListener('close', forget);
206
+ else socket.on('close', forget);
207
+ return handle;
208
+ },
209
+
175
210
  async close() {
176
- if (!wss) return;
211
+ for (const entry of [...accepted]) entry.handle.close();
212
+ accepted.clear();
177
213
  const server = wss;
178
214
  wss = null;
179
215
  bound = null;
180
- for (const client of server.clients) client.terminate();
181
- await wt?.close();
182
- wt = null;
183
- await new Promise((resolve) => server.close(resolve));
184
- if (httpsServer) { const h = httpsServer; httpsServer = null; await new Promise((resolve) => h.close(resolve)); }
216
+ if (server) {
217
+ for (const client of server.clients) client.terminate();
218
+ await wt?.close();
219
+ wt = null;
220
+ await new Promise((resolve) => server.close(resolve));
221
+ if (httpsServer) { const h = httpsServer; httpsServer = null; await new Promise((resolve) => h.close(resolve)); }
222
+ }
185
223
  registry?.closeAll();
186
224
  registry = null;
187
225
  hub?.closeAll();
188
226
  hub = null;
227
+ ready = null;
189
228
  },
190
229
 
191
230
  address() {
@@ -17,7 +17,7 @@ export function normalizePty(pty) {
17
17
  }
18
18
  return {
19
19
  spawn: pty.spawn,
20
- shell: pty.shell ?? process.env.SHELL ?? '/bin/sh',
20
+ shell: pty.shell ?? globalThis.process?.env?.SHELL ?? '/bin/sh',
21
21
  cwd: pty.cwd,
22
22
  env: pty.env,
23
23
  term: pty.term ?? 'xterm-256color',
@@ -20,7 +20,8 @@ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypt
20
20
  export const DEFAULT_RING_BYTES = 1024 * 1024;
21
21
  export const DEFAULT_DETACH_TTL_MS = 300_000;
22
22
  export const DEFAULT_MAX_DETACHED = 16;
23
- export const MAX_ATTACHMENTS = 16;
23
+ import { MAX_ATTACHMENTS } from './constants.mjs';
24
+ export { MAX_ATTACHMENTS };
24
25
 
25
26
  const NO_EXPIRY = Buffer.alloc(8, 0xff);
26
27
 
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Serve a pre-upgraded transport: an already-accepted WebSocket-like duplex handed to a host by whoever did the
3
+ * upgrade (an HTTP server of the embedder's, a Worker, a message port wrapper, a test). Browser-safe -- it touches
4
+ * nothing but the object it is given -- and shared by `createWshServer().accept()` and `createMemoryHost()`.
5
+ *
6
+ * The duplex needs `send(bytes)`, `close(code?)`, `readyState` (1 = open) and either Node-style `on(event, fn)`
7
+ * (`ws`) or `addEventListener(event, fn)` (browsers, Workers, undici). Incoming frames must be binary; a text frame
8
+ * is ignored, as the QMux framing never carries one.
9
+ */
10
+
11
+ const OPEN = 1;
12
+
13
+ /** @param {unknown} data - a message payload as `ws` (Buffer, ArrayBuffer, Buffer[]) or a browser (ArrayBuffer, view, Blob) delivers it */
14
+ async function toBytes(data) {
15
+ if (data instanceof Uint8Array) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
16
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
17
+ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
18
+ if (Array.isArray(data)) {
19
+ const parts = await Promise.all(data.map(toBytes));
20
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0));
21
+ let off = 0;
22
+ for (const p of parts) { out.set(p, off); off += p.byteLength; }
23
+ return out;
24
+ }
25
+ if (data && typeof data.arrayBuffer === 'function') return new Uint8Array(await data.arrayBuffer());
26
+ return null;
27
+ }
28
+
29
+ /**
30
+ * @param {object} socket - the WebSocket-like duplex
31
+ * @param {Promise<(opts: object) => { receive: Function, close: Function }>} ready - resolves to the connection factory's `attach`
32
+ * @param {{ remote?: { address?: string, headers?: object } }} [options] - what the host knows about the peer
33
+ * (`address` keys the password-failure throttle; the default key is shared by every socket without one)
34
+ * @returns {Promise<{ close(): void }>} resolves once the connection is attached; `close()` drops it
35
+ */
36
+ export function bindSocket(socket, ready, { remote = {} } = {}) {
37
+ if (!socket || typeof socket.send !== 'function' || typeof socket.close !== 'function') {
38
+ throw new TypeError('accept: socket must be a WebSocket-like duplex with send() and close()');
39
+ }
40
+ const listen = typeof socket.addEventListener === 'function'
41
+ ? (event, fn) => socket.addEventListener(event, (ev) => fn(event === 'message' ? ev?.data : ev))
42
+ : typeof socket.on === 'function'
43
+ ? (event, fn) => socket.on(event, fn)
44
+ : null;
45
+ if (!listen) throw new TypeError('accept: socket must support addEventListener() or on()');
46
+ try { socket.binaryType = 'arraybuffer'; } catch { /* read-only on some implementations; toBytes copes with any */ }
47
+
48
+ // Listeners are attached now, before the host has finished starting, and the frames they see are
49
+ // chained behind `ready`: a peer that speaks first (a client's QMux handshake) is not dropped.
50
+ let conn = null;
51
+ let closed = false;
52
+ let chain = ready.then((attach) => {
53
+ if (closed) return;
54
+ conn = attach({
55
+ send: (bytes) => { if (socket.readyState === OPEN) socket.send(bytes); },
56
+ remote: { address: remote.address, headers: remote.headers ?? {} },
57
+ closeTransport: () => { try { socket.close(1000); } catch { /* already closed */ } },
58
+ });
59
+ });
60
+ const drop = () => { if (closed) return; closed = true; chain = chain.then(() => conn?.close()).catch(() => {}); };
61
+ listen('message', (data) => {
62
+ chain = chain.then(async () => {
63
+ if (closed || !conn) return;
64
+ const bytes = await toBytes(data);
65
+ if (bytes && !closed) conn.receive(bytes);
66
+ }).catch(() => {});
67
+ });
68
+ listen('close', drop);
69
+ listen('error', () => {});
70
+ return chain.then(() => ({
71
+ close() { drop(); try { socket.close(1000); } catch { /* already closed */ } },
72
+ }));
73
+ }
@@ -97,6 +97,9 @@ class QMuxStreamAdapter {
97
97
  }
98
98
  }
99
99
 
100
+ /** `WebSocket.OPEN` (spelled out so a substitute constructor need not define it). */
101
+ const WS_OPEN = 1;
102
+
100
103
  // ── WebSocket transport ──────────────────────────────────────────────
101
104
 
102
105
  /**
@@ -133,6 +136,19 @@ export class WebSocketTransport extends WshTransport {
133
136
  */
134
137
  #inbox = new SerialQueue((raw) => this.#handleControlBytes(raw));
135
138
 
139
+ /** @type {typeof WebSocket} */
140
+ #WebSocket;
141
+
142
+ /**
143
+ * @param {object} [options]
144
+ * @param {typeof WebSocket} [options.WebSocket] - A WebSocket constructor to dial with instead of the global one: a
145
+ * test double, or the in-memory host's (`createMemoryHost().WebSocket`, see `memory-host.mjs`).
146
+ */
147
+ constructor({ WebSocket: Impl } = {}) {
148
+ super();
149
+ this.#WebSocket = Impl;
150
+ }
151
+
136
152
  // ── Lifecycle ──────────────────────────────────────────────────────
137
153
 
138
154
  /** @override */
@@ -149,7 +165,7 @@ export class WebSocketTransport extends WshTransport {
149
165
  .replace(/^https:\/\//, 'wss://')
150
166
  .replace(/^http:\/\//, 'ws://');
151
167
 
152
- const ws = new WebSocket(wsUrl);
168
+ const ws = new (this.#WebSocket ?? WebSocket)(wsUrl);
153
169
  ws.binaryType = 'arraybuffer';
154
170
  this.#ws = ws;
155
171
 
@@ -316,7 +332,7 @@ export class WebSocketTransport extends WshTransport {
316
332
  * @param {Uint8Array} data
317
333
  */
318
334
  #send(data) {
319
- if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
335
+ if (!this.#ws || this.#ws.readyState !== WS_OPEN) {
320
336
  throw new Error('WebSocket is not open');
321
337
  }
322
338
  this.#ws.send(data);