@johnhenry/wsh 0.29.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.
@@ -15,7 +15,9 @@ import { SessionRegistry } from './sessions.mjs';
15
15
  import { RelayHub } from './relay.mjs';
16
16
  import { startWebTransport } from './webtransport.mjs';
17
17
  import { createConnectionFactory, STREAM_ANNOUNCE } from './connection.mjs';
18
+ import { normalizeGateway } from './gateway.mjs';
18
19
  import { createRpcHost, mcpServerAdapter } from './rpc.mjs';
20
+ import { bindSocket } from './socket.mjs';
19
21
 
20
22
  export { parseAuthorizedKeys, STREAM_ANNOUNCE, mcpServerAdapter };
21
23
  export { createReverseHost } from './reverse-host.mjs';
@@ -67,12 +69,16 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
67
69
  * Handlers for application-defined messages: a control message whose `type` is a *string* (every protocol message
68
70
  * has a numeric type, so these cannot collide) from an authenticated connection is passed to `extensions[type]`.
69
71
  * A handler that throws is logged and ignored. Off unless given.
72
+ * @param {{ allow: string[], maxConnections?: number, connectTimeoutMs?: number, socks?: string | { host: string, port: number } }} [options.gateway]
73
+ * Answer the gateway opcodes (OpenTcp, ResolveDns, GatewayData/Close): the client opens TCP connections and resolves
74
+ * names from this host. Default deny: a destination must match `allow` (`"*"`, `"host"`, `"host:port"`). `socks` dials
75
+ * through a SOCKS5 proxy (Tor) with the name unresolved. UDP and reverse tunnels are refused. Off unless given. See `gateway.mjs`.
70
76
  * @param {number} [options.bindTimeoutMs=3000]
71
77
  * @param {(line: string) => void} [options.onLog]
72
- * @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) }}
73
79
  */
74
80
  export function createWshServer({
75
- host = '127.0.0.1', port = 0, tls, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, extensions, webTransport, bindTimeoutMs = 3000, onLog = () => {},
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 = () => {},
76
82
  } = {}) {
77
83
  let wss = null;
78
84
  let httpsServer = null;
@@ -94,17 +100,16 @@ export function createWshServer({
94
100
  let registry = null;
95
101
  let hub = null;
96
102
  let wt = null;
103
+ const gatewayCfg = gateway === undefined ? null : normalizeGateway(gateway);
97
104
  const sessionOptions = sessions ? (sessions === true ? {} : sessions) : null;
98
105
 
99
- return {
100
- async listen() {
101
- if (wss) throw new Error('createWshServer: already listening');
102
- let WebSocketServer;
103
- try {
104
- ({ WebSocketServer } = await import('ws'));
105
- } catch (err) {
106
- throw new Error('@johnhenry/wsh/server needs the "ws" package: npm install ws', { cause: err });
107
- }
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 () => {
108
113
  const authorize = await buildAuthorizer(auth);
109
114
  host_ = hostKey ? await loadHostKey(hostKey) : null;
110
115
  if (sessionOptions) {
@@ -119,10 +124,25 @@ export function createWshServer({
119
124
  }
120
125
  if (relay) hub = new RelayHub({ ...relay, log: onLog });
121
126
  if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createWshServer: extensions must be an object of handlers');
122
- const attach = createConnectionFactory({
127
+ return createConnectionFactory({
123
128
  authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
124
- methods, hostKey: host_, rateLimit, extensions, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
129
+ methods, hostKey: host_, rateLimit, extensions, mcp: mcpHost, rpc: rpcHost, gateway: gatewayCfg, sessions: registry, relay: hub,
125
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();
126
146
 
127
147
  if (tls) {
128
148
  const { createServer } = await import('node:https');
@@ -166,20 +186,45 @@ export function createWshServer({
166
186
  return bound;
167
187
  },
168
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
+
169
210
  async close() {
170
- if (!wss) return;
211
+ for (const entry of [...accepted]) entry.handle.close();
212
+ accepted.clear();
171
213
  const server = wss;
172
214
  wss = null;
173
215
  bound = null;
174
- for (const client of server.clients) client.terminate();
175
- await wt?.close();
176
- wt = null;
177
- await new Promise((resolve) => server.close(resolve));
178
- 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
+ }
179
223
  registry?.closeAll();
180
224
  registry = null;
181
225
  hub?.closeAll();
182
226
  hub = null;
227
+ ready = null;
183
228
  },
184
229
 
185
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);