@herenfor/dsh-wsl 0.0.0-stage → 0.1.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/LICENSE +51 -0
- package/README.md +68 -2
- package/README.zh.md +69 -0
- package/agent/broker.mjs +107 -0
- package/agent/cordis.patch.yml +3 -0
- package/agent/index.mjs +130 -0
- package/agent/package.json +9 -0
- package/client.js +817 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +1 -0
- package/ids.mjs +89 -0
- package/index.mjs +586 -0
- package/language.mjs +27 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/merge.mjs +65 -0
- package/package.json +59 -4
- package/paths.mjs +24 -0
- package/plugins.mjs +77 -0
- package/remote-mux.mjs +154 -0
- package/routes.mjs +47 -0
- package/routing.mjs +502 -0
- package/selfupdate.mjs +79 -0
- package/sessions.mjs +101 -0
- package/settings.mjs +53 -0
- package/setup.mjs +286 -0
- package/transport/client.mjs +102 -0
- package/transport/daemon.mjs +268 -0
- package/transport/mux.mjs +118 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// WSL side of the bridge.
|
|
2
|
+
// node daemon.mjs --serve [options] long-running: runs the DSH Host, accepts bridges on a Unix socket
|
|
3
|
+
// node daemon.mjs --bridge [options] started by wsl.exe: joins its stdio to the daemon (starting it if needed)
|
|
4
|
+
// Options: --profile <name> (desktop-wsl); --dsh <DSH's entry script> (else searched); --stamp <bridge files stamp>;
|
|
5
|
+
// --dsh-home <dir> (else DSH's default ~/.dsh, shared with the person's own WSL DSH; the seam for moving sessions).
|
|
6
|
+
// A bridge whose stamp, DSH or DSH home differs from the running daemon's replaces that daemon (after a plugin or DSH
|
|
7
|
+
// update). Before starting DSH the daemon creates the profile from the web template and adds this plugin's WSL half
|
|
8
|
+
// (../agent) when they are missing; both are local operations.
|
|
9
|
+
// Run directory ~/.dsh-wsl/run/<profile>/ (mode 700): rpc.sock, daemon.token (600), daemon.log, daemon.json.
|
|
10
|
+
// The DSH Host listens on 127.0.0.2, which WSL's localhost forwarding does not expose to Windows; the only way in from
|
|
11
|
+
// Windows is a bridge, and a bridge must present the daemon token the run directory holds.
|
|
12
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
13
|
+
import { randomBytes } from 'node:crypto'
|
|
14
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { connect, createServer } from 'node:net'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { delimiter, dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { Mux } from './mux.mjs'
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2)
|
|
22
|
+
const option = (name, fallback) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : fallback }
|
|
23
|
+
const PROFILE = option('--profile', 'desktop-wsl')
|
|
24
|
+
const RUN = join(homedir(), '.dsh-wsl', 'run', PROFILE)
|
|
25
|
+
const SOCKET = join(RUN, 'rpc.sock')
|
|
26
|
+
const TOKEN_FILE = join(RUN, 'daemon.token')
|
|
27
|
+
const LOG = join(RUN, 'daemon.log')
|
|
28
|
+
const IDENTITY_FILE = join(RUN, 'daemon.json')
|
|
29
|
+
const DSH_OPTION = option('--dsh')
|
|
30
|
+
const STAMP = option('--stamp', '')
|
|
31
|
+
const DSH_HOME_OPTION = option('--dsh-home')
|
|
32
|
+
const AGENT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'agent')
|
|
33
|
+
const DSH_HOST = '127.0.0.2'
|
|
34
|
+
// With no Desktop connected for this long the daemon stops its DSH Host and exits: quitting Desktop stops the WSL side
|
|
35
|
+
// too. The wait lets a bridge that is only being replaced (plugin reload, update) come back without a restart.
|
|
36
|
+
const IDLE_MS = Number(process.env.DSH_WSL_IDLE_MS ?? 5000)
|
|
37
|
+
// wsl.exe --exec starts us without a login shell, so PATH may lack ~/.local/bin: find DSH's entry script ourselves and
|
|
38
|
+
// run it with this same node; its own child processes get node's directory and ~/.local/bin on PATH.
|
|
39
|
+
function findDsh() {
|
|
40
|
+
const candidates = [DSH_OPTION, process.env.DSH_BIN, join(homedir(), '.dsh-wsl', 'dsh', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'), join(dirname(process.execPath), 'dsh'), join(homedir(), '.local', 'bin', 'dsh'), '/usr/local/bin/dsh', '/usr/bin/dsh']
|
|
41
|
+
for (const c of candidates) if (c && existsSync(c)) return realpathSync(c)
|
|
42
|
+
throw new Error('dsh is not installed in WSL (set DSH_BIN)')
|
|
43
|
+
}
|
|
44
|
+
// The WSL DSH Host always uses the default DSH home (~/.dsh): a DSH_HOME inherited from whoever started the daemon
|
|
45
|
+
// (a test Host, a shell) must not move it elsewhere.
|
|
46
|
+
const childEnv = () => ({ ...withoutDshHome(process.env), ...(DSH_HOME_OPTION ? { DSH_HOME: DSH_HOME_OPTION } : {}), DSH_WSL_RUN: RUN, PATH: [dirname(process.execPath), join(homedir(), '.local', 'bin'), process.env.PATH ?? '/usr/bin:/bin'].join(delimiter) })
|
|
47
|
+
export const PROTOCOL = 1
|
|
48
|
+
|
|
49
|
+
function withoutDshHome(env) { const { DSH_HOME, ...rest } = env; return rest }
|
|
50
|
+
/** DSH's version, read from the package that holds its entry script. */
|
|
51
|
+
function dshVersion(entry) {
|
|
52
|
+
for (let dir = dirname(entry); dir !== dirname(dir); dir = dirname(dir)) {
|
|
53
|
+
try { const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); if (pkg.name === '@deepseek-ai/dsh') return pkg.version } catch {}
|
|
54
|
+
}
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
/** What a daemon runs: a bridge started with a different identity replaces it. */
|
|
58
|
+
function identity() {
|
|
59
|
+
let dsh = null
|
|
60
|
+
try { dsh = findDsh() } catch {}
|
|
61
|
+
return { stamp: STAMP, dsh, version: dsh ? dshVersion(dsh) ?? null : null, dshHome: DSH_HOME_OPTION ?? null }
|
|
62
|
+
}
|
|
63
|
+
const sameIdentity = (a, b) => a && b && a.stamp === b.stamp && a.dsh === b.dsh && a.version === b.version && a.dshHome === b.dshHome
|
|
64
|
+
/** Create the profile and add the WSL half of the plugin when missing. Local operations only (a link: dependency). */
|
|
65
|
+
function ensureProfile(dsh) {
|
|
66
|
+
const home = DSH_HOME_OPTION ?? join(homedir(), '.dsh')
|
|
67
|
+
const dir = join(home, 'profiles', PROFILE)
|
|
68
|
+
const step = (what, argv) => {
|
|
69
|
+
const r = spawnSync(process.execPath, [dsh, ...argv], { env: childEnv(), encoding: 'utf8', timeout: 180_000 })
|
|
70
|
+
log(`profile: ${what}: exit ${r.status}${r.status === 0 ? '' : ` ${(r.stderr || r.stdout || r.error?.message || '').trim().slice(-400)}`}`)
|
|
71
|
+
}
|
|
72
|
+
if (!existsSync(join(dir, 'package.json'))) step(`create ${PROFILE} from the web template`, [PROFILE, '--from-default-profile', 'web', '--dump-config'])
|
|
73
|
+
let pkg = {}
|
|
74
|
+
try { pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) } catch {}
|
|
75
|
+
// The WSL half travels with the Desktop plugin (~/.dsh-wsl/bridge/agent): point the profile at that copy, so it is
|
|
76
|
+
// updated together with the plugin. A daemon run from elsewhere (a development checkout) has no ../agent and leaves
|
|
77
|
+
// the profile as it is.
|
|
78
|
+
if (existsSync(join(AGENT_DIR, 'package.json'))) {
|
|
79
|
+
const spec = `link:${realpathSync(AGENT_DIR)}`
|
|
80
|
+
if (pkg.dependencies?.['dsh-wsl-agent'] !== spec) step(`point dsh-wsl-agent at ${AGENT_DIR}`, ['plugin', '--profile', PROFILE, 'add', spec])
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const log = (message) => { try { appendFileSync(LOG, `${new Date().toISOString()} ${message}\n`, { mode: 0o600 }) } catch {} }
|
|
84
|
+
const masked = (text) => text.replace(/token=[A-Za-z0-9_-]+/g, 'token=***')
|
|
85
|
+
|
|
86
|
+
// ---- serve ----------------------------------------------------------------------------------------------------------
|
|
87
|
+
async function serve() {
|
|
88
|
+
mkdirSync(RUN, { recursive: true, mode: 0o700 }); chmodSync(RUN, 0o700)
|
|
89
|
+
if (await reachable()) { log('serve: another daemon already answers; exiting'); return }
|
|
90
|
+
rmSync(SOCKET, { force: true })
|
|
91
|
+
const token = randomBytes(24).toString('base64url')
|
|
92
|
+
writeFileSync(TOKEN_FILE, token, { mode: 0o600 })
|
|
93
|
+
writeFileSync(IDENTITY_FILE, JSON.stringify({ pid: process.pid, ...identity() }), { mode: 0o600 })
|
|
94
|
+
|
|
95
|
+
let dsh = { state: 'starting' }
|
|
96
|
+
const bridges = new Set()
|
|
97
|
+
const caps = new WeakMap() // bridge mux → capabilities it announced ({type:'client', caps:[...]})
|
|
98
|
+
const announce = () => { for (const mux of bridges) mux.control({ type: 'dsh', ...publicState() }) }
|
|
99
|
+
const publicState = () => ({ state: dsh.state, origin: dsh.origin, token: dsh.token, version: dsh.version, exit: dsh.exit })
|
|
100
|
+
let profileChecked = false
|
|
101
|
+
let current // the running DSH Host process
|
|
102
|
+
let stopping = false
|
|
103
|
+
let idle
|
|
104
|
+
const idleSoon = () => {
|
|
105
|
+
clearTimeout(idle)
|
|
106
|
+
idle = setTimeout(() => { if (bridges.size === 0) { log(`serve: no Desktop for ${IDLE_MS} ms`); stop() } }, IDLE_MS)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let restarts = 0
|
|
110
|
+
const startDsh = () => {
|
|
111
|
+
dsh = { state: 'starting' }
|
|
112
|
+
announce()
|
|
113
|
+
let child
|
|
114
|
+
try {
|
|
115
|
+
const entry = findDsh()
|
|
116
|
+
if (!profileChecked) { ensureProfile(entry); profileChecked = true }
|
|
117
|
+
dsh.version = dshVersion(entry)
|
|
118
|
+
child = current = spawn(process.execPath, [entry, '--profile', PROFILE, '--no-open', '--host', DSH_HOST, '--port', '0'], { stdio: ['ignore', 'pipe', 'pipe'], env: childEnv() })
|
|
119
|
+
} catch (error) {
|
|
120
|
+
dsh = { state: 'stopped', exit: { error: error.message } }; log(`dsh: ${error.message}`); announce()
|
|
121
|
+
setTimeout(startDsh, 30_000).unref?.(); return
|
|
122
|
+
}
|
|
123
|
+
let out = ''
|
|
124
|
+
const onLine = (chunk) => {
|
|
125
|
+
out += chunk.toString('utf8')
|
|
126
|
+
const m = /dsh web: (http:\/\/[^/\s]+)\/\?token=([A-Za-z0-9_-]+)/.exec(out)
|
|
127
|
+
if (m && dsh.state === 'starting') { dsh = { state: 'running', origin: m[1], token: m[2], pid: child.pid, version: dsh.version }; restarts = 0; log(`dsh running at ${m[1]} pid ${child.pid}`); announce() }
|
|
128
|
+
if (out.length > 65536) out = out.slice(-8192)
|
|
129
|
+
}
|
|
130
|
+
child.stdout.on('data', onLine)
|
|
131
|
+
child.stderr.on('data', (c) => { onLine(c); log(`dsh stderr: ${masked(c.toString('utf8')).trimEnd().slice(0, 500)}`) })
|
|
132
|
+
child.on('exit', (code, signal) => {
|
|
133
|
+
dsh = { state: 'stopped', exit: { code, signal } }
|
|
134
|
+
log(`dsh exited code ${code} signal ${signal}`)
|
|
135
|
+
announce()
|
|
136
|
+
if (stopping) return
|
|
137
|
+
const delay = Math.min(30_000, 1000 * 2 ** restarts++)
|
|
138
|
+
setTimeout(startDsh, delay).unref?.()
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
const server = createServer((socket) => {
|
|
142
|
+
// First line: the daemon token (a Desktop bridge), or the token and " broker" (the WSL DSH Host asking Desktop for
|
|
143
|
+
// credentials; see brokerConnection). Anything else closes the connection.
|
|
144
|
+
let head = Buffer.alloc(0)
|
|
145
|
+
const onData = (chunk) => {
|
|
146
|
+
head = Buffer.concat([head, chunk])
|
|
147
|
+
const nl = head.indexOf(10)
|
|
148
|
+
if (nl < 0) { if (head.length > 256) socket.destroy(); return }
|
|
149
|
+
socket.off('data', onData)
|
|
150
|
+
const first = head.subarray(0, nl).toString('utf8')
|
|
151
|
+
const rest = head.subarray(nl + 1)
|
|
152
|
+
if (first === `${token} broker`) { brokerConnection(socket, rest); return }
|
|
153
|
+
if (first !== token) { log('bridge rejected: bad token'); socket.destroy(); return }
|
|
154
|
+
const mux = new Mux(socket, socket, { side: 'acceptor' })
|
|
155
|
+
bridges.add(mux)
|
|
156
|
+
clearTimeout(idle)
|
|
157
|
+
log(`bridge joined (${bridges.size} open)`)
|
|
158
|
+
mux.on('close', () => { bridges.delete(mux); log(`bridge left (${bridges.size} open)`); if (bridges.size === 0) idleSoon() })
|
|
159
|
+
mux.on('control', (m) => { if (m?.type === 'client' && Array.isArray(m.caps)) caps.set(mux, m.caps) })
|
|
160
|
+
mux.on('stream', (stream, target) => {
|
|
161
|
+
if (target?.kind !== 'dsh' || dsh.state !== 'running') { stream.destroy(new Error(`no target ${JSON.stringify(target)} (dsh ${dsh.state})`)); return }
|
|
162
|
+
const port = Number(new URL(dsh.origin).port)
|
|
163
|
+
const tcp = connect(port, DSH_HOST)
|
|
164
|
+
stream.pipe(tcp).pipe(stream)
|
|
165
|
+
const close = (e) => { tcp.destroy(); stream.destroy(e) }
|
|
166
|
+
tcp.once('error', close); stream.once('error', () => tcp.destroy())
|
|
167
|
+
})
|
|
168
|
+
mux.control({ type: 'hello', protocol: PROTOCOL, profile: PROFILE, distro: process.env.WSL_DISTRO_NAME ?? null, node: process.version })
|
|
169
|
+
mux.control({ type: 'dsh', ...publicState() })
|
|
170
|
+
if (rest.length) socket.emit('data', rest)
|
|
171
|
+
}
|
|
172
|
+
socket.on('data', onData)
|
|
173
|
+
socket.on('error', () => {})
|
|
174
|
+
})
|
|
175
|
+
// Credential requests from the WSL DSH Host: one JSON object per line, {id, op, ...}; each is passed to the newest
|
|
176
|
+
// bridge that announced the 'credentials' capability and answered on the same line protocol, {id, ...answer}.
|
|
177
|
+
function brokerConnection(socket, rest) {
|
|
178
|
+
let buffer = rest.toString('utf8')
|
|
179
|
+
const answer = (value) => { if (!socket.destroyed) socket.write(`${JSON.stringify(value)}\n`) }
|
|
180
|
+
const handle = async (line) => {
|
|
181
|
+
let request
|
|
182
|
+
try { request = JSON.parse(line) } catch { return }
|
|
183
|
+
const desktop = [...bridges].reverse().find((mux) => caps.get(mux)?.includes('credentials'))
|
|
184
|
+
if (!desktop) { answer({ id: request.id, error: 'Desktop is not connected' }); return }
|
|
185
|
+
const stream = desktop.open({ kind: 'credentials' })
|
|
186
|
+
const parts = []
|
|
187
|
+
const timer = setTimeout(() => stream.destroy(new Error('timeout')), 15_000)
|
|
188
|
+
stream.on('data', (c) => parts.push(c))
|
|
189
|
+
stream.once('end', () => {
|
|
190
|
+
clearTimeout(timer)
|
|
191
|
+
try { answer({ ...JSON.parse(Buffer.concat(parts).toString('utf8')), id: request.id }) } catch { answer({ id: request.id, error: 'bad answer from Desktop' }) }
|
|
192
|
+
})
|
|
193
|
+
stream.once('error', (e) => { clearTimeout(timer); answer({ id: request.id, error: e.message }) })
|
|
194
|
+
const { id, ...body } = request
|
|
195
|
+
stream.end(JSON.stringify(body))
|
|
196
|
+
}
|
|
197
|
+
const onData = (chunk) => {
|
|
198
|
+
buffer += chunk.toString('utf8')
|
|
199
|
+
let nl
|
|
200
|
+
while ((nl = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1); void handle(line) }
|
|
201
|
+
}
|
|
202
|
+
socket.on('data', onData)
|
|
203
|
+
if (buffer) onData(Buffer.alloc(0))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
server.once('error', (error) => { log(`serve: cannot listen on ${SOCKET}: ${error.message}`); current?.kill('SIGTERM'); process.exit(1) })
|
|
207
|
+
// DSH starts only once the socket is ours: a daemon that cannot listen leaves nothing running behind it.
|
|
208
|
+
server.listen(SOCKET, () => { chmodSync(SOCKET, 0o600); log(`serve: listening on ${SOCKET}`); startDsh(); idleSoon() })
|
|
209
|
+
const stop = () => {
|
|
210
|
+
log('serve: stopping'); stopping = true; rmSync(SOCKET, { force: true })
|
|
211
|
+
if (!current || current.exitCode !== null) process.exit(0)
|
|
212
|
+
current.once('exit', () => process.exit(0)); current.kill('SIGTERM')
|
|
213
|
+
setTimeout(() => { current.kill('SIGKILL'); process.exit(0) }, 5000).unref()
|
|
214
|
+
}
|
|
215
|
+
process.on('SIGTERM', stop); process.on('SIGINT', stop)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function reachable() {
|
|
219
|
+
return new Promise((resolve) => {
|
|
220
|
+
if (!existsSync(SOCKET)) return resolve(false)
|
|
221
|
+
const s = connect(SOCKET)
|
|
222
|
+
s.once('connect', () => { s.destroy(); resolve(true) })
|
|
223
|
+
s.once('error', () => resolve(false))
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---- bridge ---------------------------------------------------------------------------------------------------------
|
|
228
|
+
async function bridge() {
|
|
229
|
+
if (await reachable()) {
|
|
230
|
+
// A daemon from older bridge files or another DSH: replace it (its DSH Host stops with it).
|
|
231
|
+
let running
|
|
232
|
+
try { running = JSON.parse(readFileSync(IDENTITY_FILE, 'utf8')) } catch {}
|
|
233
|
+
const wanted = identity()
|
|
234
|
+
if (STAMP && !sameIdentity(running, wanted)) {
|
|
235
|
+
log(`bridge: replacing daemon ${JSON.stringify(running)} with ${JSON.stringify(wanted)}`)
|
|
236
|
+
try { process.kill(running.pid, 'SIGTERM') } catch {}
|
|
237
|
+
const alive = () => { try { process.kill(running.pid, 0); return true } catch { return false } }
|
|
238
|
+
for (let i = 0; i < 100 && (alive() || (await reachable())); i++) await new Promise((r) => setTimeout(r, 100))
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!(await reachable())) {
|
|
242
|
+
mkdirSync(RUN, { recursive: true, mode: 0o700 })
|
|
243
|
+
const out = openSync(LOG, 'a', 0o600)
|
|
244
|
+
const pass = [['--profile', PROFILE], ['--dsh', DSH_OPTION], ['--stamp', STAMP || undefined], ['--dsh-home', DSH_HOME_OPTION]].flatMap(([k, v]) => (v ? [k, v] : []))
|
|
245
|
+
spawn(process.execPath, [fileURLToPath(import.meta.url), '--serve', ...pass], { detached: true, stdio: ['ignore', out, out], env: withoutDshHome(process.env) }).unref()
|
|
246
|
+
for (let i = 0; i < 100 && !(await reachable()); i++) await new Promise((r) => setTimeout(r, 100))
|
|
247
|
+
if (!(await reachable())) {
|
|
248
|
+
// Say why on stderr: the Desktop side shows it in the setup dialog.
|
|
249
|
+
let last = ''
|
|
250
|
+
try { last = readFileSync(LOG, 'utf8').trim().split('\n').filter((l) => /^\d{4}-/.test(l)).slice(-1)[0] ?? '' } catch {}
|
|
251
|
+
process.stderr.write(`the WSL daemon did not start${last ? `: ${last.replace(/^\S+ /, '')}` : ''} (log: ${LOG})\n`)
|
|
252
|
+
process.exit(1)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const socket = connect(SOCKET)
|
|
256
|
+
socket.once('connect', () => {
|
|
257
|
+
socket.write(`${readFileSync(TOKEN_FILE, 'utf8')}\n`)
|
|
258
|
+
process.stdin.pipe(socket)
|
|
259
|
+
socket.pipe(process.stdout)
|
|
260
|
+
})
|
|
261
|
+
const quit = () => process.exit(0)
|
|
262
|
+
socket.once('close', quit); socket.once('error', (e) => { process.stderr.write(`dsh-wsl bridge: ${e.message}\n`); process.exit(1) })
|
|
263
|
+
process.stdin.once('end', () => socket.end())
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (args.includes('--serve')) serve()
|
|
267
|
+
else if (args.includes('--bridge')) bridge()
|
|
268
|
+
else { process.stderr.write('usage: daemon.mjs --serve|--bridge [--profile name]\n'); process.exit(2) }
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// A stream multiplexer over one byte stream (a wsl.exe stdio pipe, or a Unix socket behind it).
|
|
2
|
+
// Frame: u32 length of the rest | u8 type | u32 stream id | payload.
|
|
3
|
+
// Types: OPEN (payload: JSON target), DATA, END (the sender will write no more), RESET (payload: reason), CONTROL
|
|
4
|
+
// (stream 0, payload: JSON). The opening side uses odd ids, the accepting side even ids, so ids never collide.
|
|
5
|
+
// Backpressure is the underlying pipe's; there are no per-stream windows yet.
|
|
6
|
+
import { Duplex } from 'node:stream'
|
|
7
|
+
import { EventEmitter } from 'node:events'
|
|
8
|
+
|
|
9
|
+
export const OPEN = 1, DATA = 2, END = 3, RESET = 4, CONTROL = 5
|
|
10
|
+
const HEADER = 9
|
|
11
|
+
|
|
12
|
+
export function encode(type, id, payload = Buffer.alloc(0)) {
|
|
13
|
+
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
|
|
14
|
+
const frame = Buffer.allocUnsafe(4 + 5 + body.length)
|
|
15
|
+
frame.writeUInt32BE(5 + body.length, 0)
|
|
16
|
+
frame.writeUInt8(type, 4)
|
|
17
|
+
frame.writeUInt32BE(id, 5)
|
|
18
|
+
body.copy(frame, HEADER)
|
|
19
|
+
return frame
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Incremental frame decoder: feed chunks, get { type, id, payload } frames. */
|
|
23
|
+
export class Decoder {
|
|
24
|
+
buffer = Buffer.alloc(0);
|
|
25
|
+
*push(chunk) {
|
|
26
|
+
this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk
|
|
27
|
+
while (this.buffer.length >= 4) {
|
|
28
|
+
const length = this.buffer.readUInt32BE(0)
|
|
29
|
+
if (this.buffer.length < 4 + length) return
|
|
30
|
+
const type = this.buffer.readUInt8(4)
|
|
31
|
+
const id = this.buffer.readUInt32BE(5)
|
|
32
|
+
const payload = this.buffer.subarray(HEADER, 4 + length)
|
|
33
|
+
this.buffer = this.buffer.subarray(4 + length)
|
|
34
|
+
yield { type, id, payload: Buffer.from(payload) }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One end of the multiplexer.
|
|
41
|
+
* Events: 'stream' (duplex, target) for streams the other end opened; 'control' (message); 'close' (error?).
|
|
42
|
+
*/
|
|
43
|
+
export class Mux extends EventEmitter {
|
|
44
|
+
streams = new Map()
|
|
45
|
+
closed = false
|
|
46
|
+
constructor(readable, writable, { side }) {
|
|
47
|
+
super()
|
|
48
|
+
this.writable = writable
|
|
49
|
+
this.nextId = side === 'opener' ? 1 : 2
|
|
50
|
+
const decoder = new Decoder()
|
|
51
|
+
readable.on('data', (chunk) => { for (const frame of decoder.push(chunk)) this.receive(frame) })
|
|
52
|
+
const close = (error) => this.close(error)
|
|
53
|
+
readable.once('end', () => close())
|
|
54
|
+
readable.once('error', close)
|
|
55
|
+
writable.once('error', close)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
send(type, id, payload) {
|
|
59
|
+
if (this.closed) return false
|
|
60
|
+
return this.writable.write(encode(type, id, payload))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
control(message) { this.send(CONTROL, 0, JSON.stringify(message)) }
|
|
64
|
+
|
|
65
|
+
/** Open a stream to a named target on the other end. */
|
|
66
|
+
open(target) {
|
|
67
|
+
const id = this.nextId
|
|
68
|
+
this.nextId += 2
|
|
69
|
+
const stream = this.attach(id)
|
|
70
|
+
this.send(OPEN, id, JSON.stringify(target))
|
|
71
|
+
return stream
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
attach(id) {
|
|
75
|
+
const mux = this
|
|
76
|
+
let ended = false
|
|
77
|
+
const stream = new Duplex({
|
|
78
|
+
read() {},
|
|
79
|
+
write(chunk, _encoding, callback) {
|
|
80
|
+
if (mux.send(DATA, id, chunk)) callback()
|
|
81
|
+
else mux.writable.once('drain', () => callback())
|
|
82
|
+
},
|
|
83
|
+
final(callback) { ended = true; mux.send(END, id); callback() },
|
|
84
|
+
destroy(error, callback) {
|
|
85
|
+
if (mux.streams.get(id) === stream) {
|
|
86
|
+
mux.streams.delete(id)
|
|
87
|
+
if (!ended || error) mux.send(RESET, id, error ? String(error.message ?? error) : '')
|
|
88
|
+
}
|
|
89
|
+
callback(error)
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
this.streams.set(id, stream)
|
|
93
|
+
return stream
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
receive({ type, id, payload }) {
|
|
97
|
+
if (type === CONTROL) { try { this.emit('control', JSON.parse(payload.toString('utf8'))) } catch {} return }
|
|
98
|
+
if (type === OPEN) {
|
|
99
|
+
let target
|
|
100
|
+
try { target = JSON.parse(payload.toString('utf8')) } catch { target = null }
|
|
101
|
+
this.emit('stream', this.attach(id), target)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const stream = this.streams.get(id)
|
|
105
|
+
if (!stream) return
|
|
106
|
+
if (type === DATA) stream.push(payload)
|
|
107
|
+
else if (type === END) stream.push(null)
|
|
108
|
+
else if (type === RESET) { this.streams.delete(id); stream.destroy(payload.length ? new Error(payload.toString('utf8')) : undefined) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
close(error) {
|
|
112
|
+
if (this.closed) return
|
|
113
|
+
this.closed = true
|
|
114
|
+
for (const stream of this.streams.values()) stream.destroy(new Error('bridge closed'))
|
|
115
|
+
this.streams.clear()
|
|
116
|
+
this.emit('close', error)
|
|
117
|
+
}
|
|
118
|
+
}
|