dsh-remote 0.8.14 → 0.8.16

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/lib/forwards.js CHANGED
@@ -1,219 +1,219 @@
1
- // dsh-remote — SSH port forwarding manager (local & reverse tunnels).
2
- //
3
- // Local forward: listen on 127.0.0.1:<listenPort> → every connection is
4
- // piped through the SSH channel to <targetHost>:<targetPort> on the remote.
5
- // Reverse forward: ask the remote to listen on 127.0.0.1:<listenPort> and pipe
6
- // incoming connections back to <targetHost>:<targetPort> on the LOCAL side
7
- // (ssh2 `forwardIn` + the client `tcpip` event; requires AllowTcpForwarding on
8
- // the remote sshd).
9
- //
10
- // Definitions persist in forwards.json; forwards become active only while the
11
- // SSH connection is up (auto-restart on connect when `autoStart: true`), and
12
- // every tunnel is torn down when the pool disconnects — reverse tunnels are
13
- // never auto-restarted (they would re-expose a local port without asking).
14
- import net from 'node:net'
15
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
16
- import path from 'node:path'
17
-
18
- export class ForwardManager {
19
- /** @param {object} pool SshPool (needs .connect() returning the ssh2 client). */
20
- constructor(pool, { file } = {}) {
21
- this.pool = pool
22
- this.file = file || null
23
- this.defs = this._load()
24
- this.servers = new Map() // id → { server | remoteListen, sockets:Set, client }
25
- this.client = null
26
- this.onTcpip = null
27
- this.onClose = null
28
- this.attach = this.attach.bind(this)
29
- }
30
-
31
- _load() {
32
- if (!this.file) return []
33
- try {
34
- const j = JSON.parse(readFileSync(this.file, 'utf8'))
35
- if (Array.isArray(j.defs)) return j.defs
36
- } catch {}
37
- return []
38
- }
39
-
40
- _save() {
41
- if (!this.file) return
42
- try {
43
- mkdirSync(path.dirname(this.file), { recursive: true })
44
- writeFileSync(this.file, JSON.stringify({ defs: this.defs }, null, 2))
45
- } catch {}
46
- }
47
-
48
- /** Called by the pool after a successful connect: restore autoStart tunnels. */
49
- attach(client) {
50
- if (this.client === client) return
51
- this.client = client
52
- this.onTcpip = (info, accept, reject) => this._handleTcpip(info, accept, reject)
53
- client.on('tcpip', this.onTcpip)
54
- this.onClose = () => this.stopAll()
55
- client.on('close', this.onClose)
56
- for (const d of this.defs) {
57
- if (d.autoStart && d.direction === 'local' && !this.servers.has(d.id)) {
58
- this.start(d).catch(() => {})
59
- }
60
- }
61
- }
62
-
63
- detach() {
64
- this.stopAll()
65
- if (this.client) {
66
- if (this.onTcpip) this.client.removeListener('tcpip', this.onTcpip)
67
- if (this.onClose) this.client.removeListener('close', this.onClose)
68
- }
69
- this.client = null
70
- this.onTcpip = null
71
- this.onClose = null
72
- }
73
-
74
- list() {
75
- return this.defs.map((d) => ({
76
- id: d.id,
77
- direction: d.direction,
78
- listenPort: d.listenPort,
79
- targetHost: d.targetHost,
80
- targetPort: d.targetPort,
81
- machineId: d.machineId || null,
82
- autoStart: !!d.autoStart,
83
- active: this.servers.has(d.id),
84
- }))
85
- }
86
-
87
- _defId() {
88
- return 'fwd-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6)
89
- }
90
-
91
- /** Create a forward definition (persisted). Does NOT start it. */
92
- define({ direction = 'local', listenPort, targetHost, targetPort, autoStart = false, machineId } = {}) {
93
- const d = {
94
- id: this._defId(),
95
- direction: direction === 'reverse' ? 'reverse' : 'local',
96
- listenPort: Number(listenPort),
97
- targetHost: String(targetHost || '127.0.0.1'),
98
- targetPort: Number(targetPort),
99
- autoStart: !!autoStart,
100
- machineId: machineId || null,
101
- }
102
- this.defs.push(d)
103
- this._save()
104
- return d
105
- }
106
-
107
- remove(id) {
108
- this.stop(id)
109
- const i = this.defs.findIndex((d) => d.id === id)
110
- if (i >= 0) { this.defs.splice(i, 1); this._save(); return true }
111
- return false
112
- }
113
-
114
- async start(def) {
115
- const d = this.defs.find((x) => x.id === def.id) || def
116
- if (this.servers.has(d.id)) return { ok: true, active: true }
117
- let client = await this.pool.connect()
118
- if (!this.client) this.attach(client)
119
- if (this.client !== client) {
120
- // The pool reconnected while we were connecting — retry on the live client.
121
- client = this.client
122
- }
123
- try {
124
- if (d.direction === 'reverse') {
125
- await this._startReverse(d, client)
126
- } else {
127
- await this._startLocal(d, client)
128
- }
129
- return { ok: true, active: true }
130
- } catch (err) {
131
- return { ok: false, error: String((err && err.message) || err) }
132
- }
133
- }
134
-
135
- _startLocal(d, client) {
136
- return new Promise((resolve, reject) => {
137
- const server = net.createServer((socket) => {
138
- client.forwardOut('127.0.0.1', 0, d.targetHost, d.targetPort, (err, channel) => {
139
- if (err) { socket.destroy(); return }
140
- socket.pipe(channel)
141
- channel.pipe(socket)
142
- const kill = () => { try { socket.destroy() } catch {}; try { channel.close() } catch {} }
143
- socket.on('error', kill)
144
- channel.on('error', kill)
145
- socket.on('close', kill)
146
- channel.on('close', kill)
147
- })
148
- })
149
- server.on('error', (e) => reject(new Error(`本地转发 ${d.listenPort} 启动失败: ${e.message}`)))
150
- server.listen(d.listenPort, '127.0.0.1', () => {
151
- this.servers.set(d.id, { kind: 'local', server, sockets: new Set(), client })
152
- resolve()
153
- })
154
- })
155
- }
156
-
157
- _startReverse(d, client) {
158
- return new Promise((resolve, reject) => {
159
- // ssh2 calls the remote TCP listen API `forwardIn` (NOT openssh_forwardIn,
160
- // which in ssh2 >= 1.16 is stream-local only).
161
- if (typeof client.forwardIn !== 'function') {
162
- return reject(new Error('此 ssh2 版本不支持反向转发 (forwardIn)'))
163
- }
164
- client.forwardIn('127.0.0.1', d.listenPort, (err) => {
165
- if (err) return reject(new Error(`远端监听 ${d.listenPort} 失败: ${(err && err.message) || err}`))
166
- this.servers.set(d.id, { kind: 'reverse', sockets: new Set(), client })
167
- resolve()
168
- })
169
- })
170
- }
171
-
172
- _handleTcpip(info, accept, reject) {
173
- for (const entry of this.servers.values()) {
174
- if (entry.kind === 'reverse' && Number(info.destPort) === Number(this._defFor(entry).listenPort)) {
175
- const channel = accept()
176
- const socket = net.connect({ host: this._defFor(entry).targetHost, port: this._defFor(entry).targetPort })
177
- socket.pipe(channel)
178
- channel.pipe(socket)
179
- entry.sockets.add(socket)
180
- const kill = () => {
181
- try { socket.destroy() } catch {}
182
- try { channel.close() } catch {}
183
- entry.sockets.delete(socket)
184
- }
185
- socket.on('error', kill)
186
- channel.on('error', kill)
187
- socket.on('close', kill)
188
- return
189
- }
190
- }
191
- reject()
192
- }
193
-
194
- _defFor(entry) {
195
- for (const d of this.defs) {
196
- if (this.servers.has(d.id) && this.servers.get(d.id) === entry) return d
197
- }
198
- return { listenPort: 0, targetHost: '127.0.0.1', targetPort: 0 }
199
- }
200
-
201
- stop(id) {
202
- const entry = this.servers.get(id)
203
- if (!entry) return
204
- try {
205
- if (entry.kind === 'local') {
206
- entry.server.close()
207
- for (const s of entry.sockets) { try { s.destroy() } catch {} }
208
- } else if (entry.client && typeof entry.client.unforwardIn === 'function') {
209
- const d = this._defFor(entry)
210
- entry.client.unforwardIn('127.0.0.1', d.listenPort, () => {})
211
- }
212
- } catch {}
213
- this.servers.delete(id)
214
- }
215
-
216
- stopAll() {
217
- for (const id of [...this.servers.keys()]) this.stop(id)
218
- }
219
- }
1
+ // dsh-remote — SSH port forwarding manager (local & reverse tunnels).
2
+ //
3
+ // Local forward: listen on 127.0.0.1:<listenPort> → every connection is
4
+ // piped through the SSH channel to <targetHost>:<targetPort> on the remote.
5
+ // Reverse forward: ask the remote to listen on 127.0.0.1:<listenPort> and pipe
6
+ // incoming connections back to <targetHost>:<targetPort> on the LOCAL side
7
+ // (ssh2 `forwardIn` + the client `tcpip` event; requires AllowTcpForwarding on
8
+ // the remote sshd).
9
+ //
10
+ // Definitions persist in forwards.json; forwards become active only while the
11
+ // SSH connection is up (auto-restart on connect when `autoStart: true`), and
12
+ // every tunnel is torn down when the pool disconnects — reverse tunnels are
13
+ // never auto-restarted (they would re-expose a local port without asking).
14
+ import net from 'node:net'
15
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
16
+ import path from 'node:path'
17
+
18
+ export class ForwardManager {
19
+ /** @param {object} pool SshPool (needs .connect() returning the ssh2 client). */
20
+ constructor(pool, { file } = {}) {
21
+ this.pool = pool
22
+ this.file = file || null
23
+ this.defs = this._load()
24
+ this.servers = new Map() // id → { server | remoteListen, sockets:Set, client }
25
+ this.client = null
26
+ this.onTcpip = null
27
+ this.onClose = null
28
+ this.attach = this.attach.bind(this)
29
+ }
30
+
31
+ _load() {
32
+ if (!this.file) return []
33
+ try {
34
+ const j = JSON.parse(readFileSync(this.file, 'utf8'))
35
+ if (Array.isArray(j.defs)) return j.defs
36
+ } catch {}
37
+ return []
38
+ }
39
+
40
+ _save() {
41
+ if (!this.file) return
42
+ try {
43
+ mkdirSync(path.dirname(this.file), { recursive: true })
44
+ writeFileSync(this.file, JSON.stringify({ defs: this.defs }, null, 2))
45
+ } catch {}
46
+ }
47
+
48
+ /** Called by the pool after a successful connect: restore autoStart tunnels. */
49
+ attach(client) {
50
+ if (this.client === client) return
51
+ this.client = client
52
+ this.onTcpip = (info, accept, reject) => this._handleTcpip(info, accept, reject)
53
+ client.on('tcpip', this.onTcpip)
54
+ this.onClose = () => this.stopAll()
55
+ client.on('close', this.onClose)
56
+ for (const d of this.defs) {
57
+ if (d.autoStart && d.direction === 'local' && !this.servers.has(d.id)) {
58
+ this.start(d).catch(() => {})
59
+ }
60
+ }
61
+ }
62
+
63
+ detach() {
64
+ this.stopAll()
65
+ if (this.client) {
66
+ if (this.onTcpip) this.client.removeListener('tcpip', this.onTcpip)
67
+ if (this.onClose) this.client.removeListener('close', this.onClose)
68
+ }
69
+ this.client = null
70
+ this.onTcpip = null
71
+ this.onClose = null
72
+ }
73
+
74
+ list() {
75
+ return this.defs.map((d) => ({
76
+ id: d.id,
77
+ direction: d.direction,
78
+ listenPort: d.listenPort,
79
+ targetHost: d.targetHost,
80
+ targetPort: d.targetPort,
81
+ machineId: d.machineId || null,
82
+ autoStart: !!d.autoStart,
83
+ active: this.servers.has(d.id),
84
+ }))
85
+ }
86
+
87
+ _defId() {
88
+ return 'fwd-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6)
89
+ }
90
+
91
+ /** Create a forward definition (persisted). Does NOT start it. */
92
+ define({ direction = 'local', listenPort, targetHost, targetPort, autoStart = false, machineId } = {}) {
93
+ const d = {
94
+ id: this._defId(),
95
+ direction: direction === 'reverse' ? 'reverse' : 'local',
96
+ listenPort: Number(listenPort),
97
+ targetHost: String(targetHost || '127.0.0.1'),
98
+ targetPort: Number(targetPort),
99
+ autoStart: !!autoStart,
100
+ machineId: machineId || null,
101
+ }
102
+ this.defs.push(d)
103
+ this._save()
104
+ return d
105
+ }
106
+
107
+ remove(id) {
108
+ this.stop(id)
109
+ const i = this.defs.findIndex((d) => d.id === id)
110
+ if (i >= 0) { this.defs.splice(i, 1); this._save(); return true }
111
+ return false
112
+ }
113
+
114
+ async start(def) {
115
+ const d = this.defs.find((x) => x.id === def.id) || def
116
+ if (this.servers.has(d.id)) return { ok: true, active: true }
117
+ let client = await this.pool.connect()
118
+ if (!this.client) this.attach(client)
119
+ if (this.client !== client) {
120
+ // The pool reconnected while we were connecting — retry on the live client.
121
+ client = this.client
122
+ }
123
+ try {
124
+ if (d.direction === 'reverse') {
125
+ await this._startReverse(d, client)
126
+ } else {
127
+ await this._startLocal(d, client)
128
+ }
129
+ return { ok: true, active: true }
130
+ } catch (err) {
131
+ return { ok: false, error: String((err && err.message) || err) }
132
+ }
133
+ }
134
+
135
+ _startLocal(d, client) {
136
+ return new Promise((resolve, reject) => {
137
+ const server = net.createServer((socket) => {
138
+ client.forwardOut('127.0.0.1', 0, d.targetHost, d.targetPort, (err, channel) => {
139
+ if (err) { socket.destroy(); return }
140
+ socket.pipe(channel)
141
+ channel.pipe(socket)
142
+ const kill = () => { try { socket.destroy() } catch {}; try { channel.close() } catch {} }
143
+ socket.on('error', kill)
144
+ channel.on('error', kill)
145
+ socket.on('close', kill)
146
+ channel.on('close', kill)
147
+ })
148
+ })
149
+ server.on('error', (e) => reject(new Error(`本地转发 ${d.listenPort} 启动失败: ${e.message}`)))
150
+ server.listen(d.listenPort, '127.0.0.1', () => {
151
+ this.servers.set(d.id, { kind: 'local', server, sockets: new Set(), client })
152
+ resolve()
153
+ })
154
+ })
155
+ }
156
+
157
+ _startReverse(d, client) {
158
+ return new Promise((resolve, reject) => {
159
+ // ssh2 calls the remote TCP listen API `forwardIn` (NOT openssh_forwardIn,
160
+ // which in ssh2 >= 1.16 is stream-local only).
161
+ if (typeof client.forwardIn !== 'function') {
162
+ return reject(new Error('此 ssh2 版本不支持反向转发 (forwardIn)'))
163
+ }
164
+ client.forwardIn('127.0.0.1', d.listenPort, (err) => {
165
+ if (err) return reject(new Error(`远端监听 ${d.listenPort} 失败: ${(err && err.message) || err}`))
166
+ this.servers.set(d.id, { kind: 'reverse', sockets: new Set(), client })
167
+ resolve()
168
+ })
169
+ })
170
+ }
171
+
172
+ _handleTcpip(info, accept, reject) {
173
+ for (const entry of this.servers.values()) {
174
+ if (entry.kind === 'reverse' && Number(info.destPort) === Number(this._defFor(entry).listenPort)) {
175
+ const channel = accept()
176
+ const socket = net.connect({ host: this._defFor(entry).targetHost, port: this._defFor(entry).targetPort })
177
+ socket.pipe(channel)
178
+ channel.pipe(socket)
179
+ entry.sockets.add(socket)
180
+ const kill = () => {
181
+ try { socket.destroy() } catch {}
182
+ try { channel.close() } catch {}
183
+ entry.sockets.delete(socket)
184
+ }
185
+ socket.on('error', kill)
186
+ channel.on('error', kill)
187
+ socket.on('close', kill)
188
+ return
189
+ }
190
+ }
191
+ reject()
192
+ }
193
+
194
+ _defFor(entry) {
195
+ for (const d of this.defs) {
196
+ if (this.servers.has(d.id) && this.servers.get(d.id) === entry) return d
197
+ }
198
+ return { listenPort: 0, targetHost: '127.0.0.1', targetPort: 0 }
199
+ }
200
+
201
+ stop(id) {
202
+ const entry = this.servers.get(id)
203
+ if (!entry) return
204
+ try {
205
+ if (entry.kind === 'local') {
206
+ entry.server.close()
207
+ for (const s of entry.sockets) { try { s.destroy() } catch {} }
208
+ } else if (entry.client && typeof entry.client.unforwardIn === 'function') {
209
+ const d = this._defFor(entry)
210
+ entry.client.unforwardIn('127.0.0.1', d.listenPort, () => {})
211
+ }
212
+ } catch {}
213
+ this.servers.delete(id)
214
+ }
215
+
216
+ stopAll() {
217
+ for (const id of [...this.servers.keys()]) this.stop(id)
218
+ }
219
+ }
package/lib/hostkey.js CHANGED
@@ -1,48 +1,48 @@
1
- // dsh-remote — host-key fingerprint helpers (TOFU). Pure functions split out
2
- // of lib/index.js so the ssh2 hostVerifier contract (raw Buffer blob since
3
- // ssh2 v1.17) is unit-tested against the REAL wire shape — the v0.6.7 bug
4
- // class ("mock object hid a contract drift") must not regress.
5
- import { createHash } from 'node:crypto'
6
-
7
- /**
8
- * Extract the SSH host-key algorithm name from a raw SSH host-key blob
9
- * (SSH wire format: `uint32 len` + algorithm string + key data).
10
- */
11
- export function blobAlgorithm(blob) {
12
- if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
13
- try {
14
- const len = blob.readUInt32BE(0)
15
- return blob.toString('utf8', 4, 4 + len)
16
- } catch {
17
- return ''
18
- }
19
- }
20
-
21
- /**
22
- * SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
23
- * in a known_hosts file (without the `SHA256:` prefix).
24
- *
25
- * ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
26
- * wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
27
- * object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
28
- * argument must be of type string or an instance of Buffer…" on EVERY connect
29
- * (v0.6.6 bug). Accept both shapes defensively.
30
- */
31
- export function keyFingerprint(key) {
32
- const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
33
- if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
34
- return createHash('sha256').update(blob).digest('base64')
35
- }
36
-
37
- /** Build a fake-but-wire-shaped host-key blob for tests:
38
- * `string(algo) string(32 bytes)`. */
39
- export function makeKeyBlob(algo, seed) {
40
- const algoBuf = Buffer.from(algo, 'utf8')
41
- const data = Buffer.alloc(32, seed || 1)
42
- const blob = Buffer.alloc(4 + algoBuf.length + 4 + data.length)
43
- blob.writeUInt32BE(algoBuf.length, 0)
44
- algoBuf.copy(blob, 4)
45
- blob.writeUInt32BE(data.length, 4 + algoBuf.length)
46
- data.copy(blob, 8 + algoBuf.length)
47
- return blob
48
- }
1
+ // dsh-remote — host-key fingerprint helpers (TOFU). Pure functions split out
2
+ // of lib/index.js so the ssh2 hostVerifier contract (raw Buffer blob since
3
+ // ssh2 v1.17) is unit-tested against the REAL wire shape — the v0.6.7 bug
4
+ // class ("mock object hid a contract drift") must not regress.
5
+ import { createHash } from 'node:crypto'
6
+
7
+ /**
8
+ * Extract the SSH host-key algorithm name from a raw SSH host-key blob
9
+ * (SSH wire format: `uint32 len` + algorithm string + key data).
10
+ */
11
+ export function blobAlgorithm(blob) {
12
+ if (!Buffer.isBuffer(blob) || blob.length < 4) return ''
13
+ try {
14
+ const len = blob.readUInt32BE(0)
15
+ return blob.toString('utf8', 4, 4 + len)
16
+ } catch {
17
+ return ''
18
+ }
19
+ }
20
+
21
+ /**
22
+ * SHA-256 fingerprint (base64) of an ssh2 host-key blob, like `SHA256:…`
23
+ * in a known_hosts file (without the `SHA256:` prefix).
24
+ *
25
+ * ssh2 v1.17 passes hostVerifier the RAW host-key blob Buffer (the SSH
26
+ * wire-format `string(algo) string(keydata)`), NOT the old `{ algo, hash }`
27
+ * object. Fingerprinting `key.hash` on a raw Buffer crashed with "The 'data'
28
+ * argument must be of type string or an instance of Buffer…" on EVERY connect
29
+ * (v0.6.6 bug). Accept both shapes defensively.
30
+ */
31
+ export function keyFingerprint(key) {
32
+ const blob = Buffer.isBuffer(key) ? key : (key && key.hash)
33
+ if (!blob) throw new Error('host key missing (hostVerifier received no key blob)')
34
+ return createHash('sha256').update(blob).digest('base64')
35
+ }
36
+
37
+ /** Build a fake-but-wire-shaped host-key blob for tests:
38
+ * `string(algo) string(32 bytes)`. */
39
+ export function makeKeyBlob(algo, seed) {
40
+ const algoBuf = Buffer.from(algo, 'utf8')
41
+ const data = Buffer.alloc(32, seed || 1)
42
+ const blob = Buffer.alloc(4 + algoBuf.length + 4 + data.length)
43
+ blob.writeUInt32BE(algoBuf.length, 0)
44
+ algoBuf.copy(blob, 4)
45
+ blob.writeUInt32BE(data.length, 4 + algoBuf.length)
46
+ data.copy(blob, 8 + algoBuf.length)
47
+ return blob
48
+ }
@@ -0,0 +1,104 @@
1
+ // The routes in index.js are bounded JSON handlers, not arbitrary HTTP proxy
2
+ // handlers. Keep their implementation shared with legacy Web hosts while using
3
+ // Connection's authenticated, carrier-neutral channel on portless Desktop.
4
+ import { Readable } from 'node:stream'
5
+
6
+ const MAX_BODY_BYTES = 1024 * 1024
7
+
8
+ export function connectionRoute(route) {
9
+ // Only the prefix is a real invariant here. `kind`/`exact` belongs to
10
+ // dsh-host-webserver's registry (WebRouteKind); Connection's
11
+ // ConnectionFetchRoute has no `kind` field and assertFetchRoute never reads
12
+ // one, so requiring it would let a future route that omits it silently lose
13
+ // itself and every route registered after it.
14
+ if (!route.path.startsWith('/dsh-remote/')) {
15
+ throw new Error('dsh-remote: expected a plugin route under /dsh-remote/')
16
+ }
17
+ return {
18
+ path: '/api' + route.path,
19
+ methods: ['GET', 'POST'],
20
+ requestBody: 'buffered',
21
+ async fetch(request) {
22
+ const chunks = []
23
+ let size = 0
24
+ // Desktop's IPC carrier need not impose the Web carrier's JSON cap.
25
+ // Enforce our own cap before dispatch: oversized input must never become
26
+ // an empty object that could accidentally trigger a default operation.
27
+ const reader = request.body?.getReader()
28
+ try {
29
+ if (reader) {
30
+ while (true) {
31
+ request.signal.throwIfAborted()
32
+ const { done, value } = await reader.read()
33
+ if (done) break
34
+ size += value.byteLength
35
+ if (size > MAX_BODY_BYTES) {
36
+ await reader.cancel()
37
+ return Response.json({ ok: false, error: 'request body too large' }, { status: 413 })
38
+ }
39
+ chunks.push(Buffer.from(value))
40
+ }
41
+ }
42
+ } finally {
43
+ reader?.releaseLock()
44
+ }
45
+ request.signal.throwIfAborted()
46
+ const url = new URL(request.url)
47
+ // The legacy JSON reader joins chunks as strings. Supply one bounded
48
+ // buffer so UTF-8 characters split by the IPC stream remain intact.
49
+ const req = Readable.from(size ? [Buffer.concat(chunks, size)] : [])
50
+ req.method = request.method
51
+ req.url = route.path + url.search
52
+ const headers = new Headers()
53
+ let response
54
+ const res = {
55
+ statusCode: 200,
56
+ setHeader(key, value) { headers.set(key, value) },
57
+ end(body) { response = new Response(body, { status: this.statusCode, headers }) },
58
+ }
59
+ try {
60
+ await route.handler(req, res)
61
+ if (!response) throw new Error('JSON handler did not finish its response')
62
+ return response
63
+ } finally {
64
+ req.destroy()
65
+ }
66
+ },
67
+ }
68
+ }
69
+
70
+ export function registerHttpTransports(ctx, routes) {
71
+ // Reactive injection matters: either service can arrive after the plugin.
72
+ // Neither optional transport is a hard prerequisite for the SSH tools.
73
+ ctx.inject(['webServer'], (inner) => {
74
+ const disposers = routes.map((route) => inner.get('webServer').register(route))
75
+ inner.effect(() => () => disposers.forEach((dispose) => dispose()), 'dsh-remote.web-routes')
76
+ })
77
+ ctx.inject(['connection'], (inner) => {
78
+ const connection = inner.get('connection')
79
+ // Older Web compositions may have Connection without exact Fetch routes.
80
+ // dsh < 0.1.2-rc.1 is such a composition: the Desktop transport is simply
81
+ // absent there, and the legacy /dsh-remote/* routes above still serve.
82
+ if (typeof connection.fetch?.register !== 'function') return
83
+ // Register one route at a time. A single failing route must not abort the
84
+ // rest of the list (which would silently drop every later route) nor leak
85
+ // the routes already registered, because this callback is a ctx.inject
86
+ // child fiber: its throw is caught and logged by the loader, so the parent
87
+ // plugin stays ACTIVE and the failure is easy to miss.
88
+ const disposers = []
89
+ const failed = []
90
+ for (const route of routes) {
91
+ try {
92
+ disposers.push(connection.fetch.register(connectionRoute(route)))
93
+ } catch (err) {
94
+ failed.push(route.path + ': ' + String((err && err.message) || err))
95
+ }
96
+ }
97
+ inner.effect(() => () => Promise.all(disposers.map((dispose) => dispose())), 'dsh-remote.fetch-routes')
98
+ if (failed.length) {
99
+ // Surface a partial registration instead of swallowing it; the Web
100
+ // transport is unaffected, so this is a warning rather than a failure.
101
+ console.warn('[dsh-remote] some Connection Fetch routes did not register:', failed.join('; '))
102
+ }
103
+ })
104
+ }