@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.
- package/README.md +83 -15
- package/package.json +5 -1
- package/src/auth.mjs +59 -0
- package/src/client.mjs +33 -29
- package/src/index.d.ts +44 -3
- package/src/index.mjs +1 -0
- package/src/memory-host.d.ts +48 -0
- package/src/memory-host.mjs +175 -0
- package/src/recording.mjs +18 -10
- package/src/server/connection.mjs +10 -4
- package/src/server/constants.mjs +10 -0
- package/src/server/fs.mjs +2 -1
- package/src/server/gateway.mjs +248 -0
- package/src/server/index.d.ts +30 -0
- package/src/server/index.mjs +64 -19
- package/src/server/pty.mjs +1 -1
- package/src/server/sessions.mjs +2 -1
- package/src/server/socket.mjs +73 -0
- package/src/transport-ws.mjs +18 -2
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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() {
|
package/src/server/pty.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export function normalizePty(pty) {
|
|
|
17
17
|
}
|
|
18
18
|
return {
|
|
19
19
|
spawn: pty.spawn,
|
|
20
|
-
shell: pty.shell ?? process
|
|
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',
|
package/src/server/sessions.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/transport-ws.mjs
CHANGED
|
@@ -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 !==
|
|
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);
|