@linxin666/dsh-ssh 0.1.1

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.
Files changed (52) hide show
  1. package/README.md +75 -0
  2. package/cordis.patch.yml +12 -0
  3. package/lib/client.js +15638 -0
  4. package/lib/index.js +3297 -0
  5. package/lib/invariant.js +6 -0
  6. package/lib/types/client/api.d.ts +72 -0
  7. package/lib/types/client/index.d.ts +37 -0
  8. package/lib/types/client/locales.d.ts +137 -0
  9. package/lib/types/client/mount.d.ts +12 -0
  10. package/lib/types/client/panel/ClusterTab.d.ts +7 -0
  11. package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
  12. package/lib/types/client/panel/HostsTab.d.ts +9 -0
  13. package/lib/types/client/panel/SshPanel.d.ts +13 -0
  14. package/lib/types/client/panel/TerminalTab.d.ts +11 -0
  15. package/lib/types/client/panel/TransferTab.d.ts +7 -0
  16. package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
  17. package/lib/types/client/panel/controller.d.ts +22 -0
  18. package/lib/types/client/panel/helpers.d.ts +14 -0
  19. package/lib/types/client/panel/xterm.css.d.ts +2 -0
  20. package/lib/types/client/sidebar-entry.d.ts +24 -0
  21. package/lib/types/engine.d.ts +135 -0
  22. package/lib/types/index.d.ts +40 -0
  23. package/lib/types/invariant.d.ts +3 -0
  24. package/lib/types/protocol.d.ts +189 -0
  25. package/lib/types/routes.d.ts +29 -0
  26. package/lib/types/store.d.ts +54 -0
  27. package/lib/types/tools.d.ts +18 -0
  28. package/package.json +106 -0
  29. package/src/client/api.ts +363 -0
  30. package/src/client/css-modules.d.ts +5 -0
  31. package/src/client/index.ts +68 -0
  32. package/src/client/locales.ts +284 -0
  33. package/src/client/mount.tsx +83 -0
  34. package/src/client/panel/ClusterTab.tsx +123 -0
  35. package/src/client/panel/HostFormDialog.tsx +221 -0
  36. package/src/client/panel/HostsTab.tsx +166 -0
  37. package/src/client/panel/SshPanel.tsx +77 -0
  38. package/src/client/panel/TerminalTab.tsx +176 -0
  39. package/src/client/panel/TransferTab.tsx +232 -0
  40. package/src/client/panel/TunnelsTab.tsx +177 -0
  41. package/src/client/panel/controller.ts +48 -0
  42. package/src/client/panel/helpers.ts +26 -0
  43. package/src/client/panel/panel.module.css +833 -0
  44. package/src/client/panel/xterm.css.ts +2 -0
  45. package/src/client/sidebar-entry.ts +123 -0
  46. package/src/engine.ts +780 -0
  47. package/src/index.ts +155 -0
  48. package/src/invariant.ts +4 -0
  49. package/src/protocol.ts +188 -0
  50. package/src/routes.ts +540 -0
  51. package/src/store.ts +344 -0
  52. package/src/tools.ts +371 -0
package/src/routes.ts ADDED
@@ -0,0 +1,540 @@
1
+ /**
2
+ * The /api/dsh-ssh route family: host CRUD, exec, cluster, SFTP transfer
3
+ * (NDJSON progress stream for uploads, binary stream for downloads), remote
4
+ * listing, tunnels, and the WebSocket PTY terminal upgrade. Every route
5
+ * carries a loopback-only trust fence (plus browser same-origin markers) —
6
+ * these endpoints execute commands on remote servers, so LAN-exposed dsh web
7
+ * deployments must not serve them.
8
+ */
9
+
10
+ import { createReadStream, createWriteStream, mkdirSync } from 'node:fs'
11
+ import { unlink } from 'node:fs/promises'
12
+ import type { IncomingMessage, ServerResponse } from 'node:http'
13
+ import { basename, join } from 'node:path'
14
+ import { tmpdir } from 'node:os'
15
+ import { randomBytes } from 'node:crypto'
16
+ import { WebSocket, WebSocketServer } from 'ws'
17
+ import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
18
+ import type { SshEngine, ShellSession } from './engine.ts'
19
+ import { SSH_API, type HostPayload, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
20
+ import type { HostStore } from './store.ts'
21
+
22
+ /** Cap on JSON request bodies (host entries and exec payloads are small). */
23
+ const MAX_JSON_BODY_BYTES = 64 * 1024
24
+
25
+ /** Cap on declared upload bodies (staged to disk before SFTP). */
26
+ const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
27
+
28
+ /**
29
+ * One noServer WebSocket server for terminal upgrades: the browser half uses
30
+ * a standards-compliant WebSocket, so the host must speak real RFC 6455
31
+ * frames (the webserver hands us the raw upgraded socket).
32
+ */
33
+ const terminalWss = new WebSocketServer({ noServer: true })
34
+
35
+ /** Pause the shell when the socket's send buffer exceeds this… */
36
+ const BACKPRESSURE_HIGH_WATER = 1024 * 1024
37
+
38
+ /** …and resume once it drains below this. */
39
+ const BACKPRESSURE_LOW_WATER = 512 * 1024
40
+
41
+ /** Loopback literal check plus browser same-origin markers (mirrors the pairing routes' fence). */
42
+ function isLoopbackRequest(request: IncomingMessage): boolean {
43
+ const address = request.socket.remoteAddress
44
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
45
+ const host = request.headers.host
46
+ if (typeof host !== 'string') return false
47
+ let hostUrl: URL
48
+ try {
49
+ hostUrl = new URL(`http://${host}`)
50
+ } catch {
51
+ return false
52
+ }
53
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
54
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
55
+ const origin = request.headers.origin
56
+ if (origin === undefined) return true
57
+ try {
58
+ return new URL(origin).host === hostUrl.host
59
+ } catch {
60
+ return false
61
+ }
62
+ }
63
+
64
+ /** One JSON response. */
65
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
66
+ const payload = JSON.stringify(body)
67
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
68
+ res.end(payload)
69
+ }
70
+
71
+ /** Read a JSON request body (undefined when too large or unparseable). */
72
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
73
+ const chunks: Buffer[] = []
74
+ let size = 0
75
+ for await (const chunk of req) {
76
+ const buffer = chunk as Buffer
77
+ size += buffer.length
78
+ if (size > MAX_JSON_BODY_BYTES) return undefined
79
+ chunks.push(buffer)
80
+ }
81
+ try {
82
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
83
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
84
+ } catch {
85
+ return undefined
86
+ }
87
+ }
88
+
89
+ /** URL query helper (first value, decoded). */
90
+ function queryParam(url: URL, name: string): string | undefined {
91
+ const value = url.searchParams.get(name)
92
+ return value === null ? undefined : value
93
+ }
94
+
95
+ /** Route family dependencies. */
96
+ export interface SshRoutesDeps {
97
+ /** The host store (CRUD). */
98
+ store: HostStore
99
+ /** The engine (ops). */
100
+ engine: SshEngine
101
+ /** Temp dir for upload/download staging (tests inject a sandbox). */
102
+ stagingDir?: string
103
+ }
104
+
105
+ /**
106
+ * Build every /api/dsh-ssh route (exact paths) plus the terminal upgrade.
107
+ * @param deps - store, engine, staging dir.
108
+ * @returns routes and the upgrade route.
109
+ */
110
+ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
111
+ const { store, engine } = deps
112
+ const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
113
+ // The upload route stages request bodies here; it must exist before the
114
+ // first request (a missing dir would hang the first upload forever).
115
+ mkdirSync(staging, { recursive: true })
116
+
117
+ /** Guard helper: fence + method check. */
118
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
119
+ if (!isLoopbackRequest(req)) {
120
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
121
+ return false
122
+ }
123
+ if (req.method !== method) {
124
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
125
+ return false
126
+ }
127
+ return true
128
+ }
129
+
130
+ const routes: WebRoute[] = [
131
+ // ------------------------------------------------------------ hosts
132
+ {
133
+ kind: 'exact',
134
+ path: SSH_API.hosts,
135
+ handler: async (req, res) => {
136
+ // One handler per path (the webserver keyed route registry rejects
137
+ // duplicate (kind, path)); dispatch by HTTP method here.
138
+ const method = req.method ?? 'GET'
139
+ if (!isLoopbackRequest(req)) {
140
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
141
+ return
142
+ }
143
+ const url = new URL(req.url ?? '/', 'http://localhost')
144
+ if (method === 'GET') {
145
+ writeJson(res, 200, { hosts: engine.list(queryParam(url, 'query')) })
146
+ return
147
+ }
148
+ if (method === 'POST') {
149
+ const body = await readJsonBody(req)
150
+ if (body === undefined) {
151
+ writeJson(res, 400, { error: 'invalid JSON body' })
152
+ return
153
+ }
154
+ try {
155
+ const entry = store.create(body as unknown as HostPayload)
156
+ writeJson(res, 201, { host: store.summarize(entry) })
157
+ } catch (error) {
158
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
159
+ }
160
+ return
161
+ }
162
+ if (method !== 'PATCH' && method !== 'DELETE') {
163
+ writeJson(res, 405, { error: `method not allowed: ${method}` })
164
+ return
165
+ }
166
+ const alias = queryParam(url, 'alias')
167
+ if (alias === undefined || alias === '') {
168
+ writeJson(res, 400, { error: 'alias query parameter is required' })
169
+ return
170
+ }
171
+ if (method === 'PATCH') {
172
+ const body = await readJsonBody(req)
173
+ if (body === undefined) {
174
+ writeJson(res, 400, { error: 'invalid JSON body' })
175
+ return
176
+ }
177
+ try {
178
+ const entry = store.update(alias, body as unknown as Partial<HostPayload>)
179
+ writeJson(res, 200, { host: store.summarize(entry) })
180
+ } catch (error) {
181
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
182
+ }
183
+ return
184
+ }
185
+ if (method === 'DELETE') {
186
+ try {
187
+ engine.stopAllTunnels(alias)
188
+ store.delete(alias)
189
+ writeJson(res, 200, { ok: true })
190
+ } catch (error) {
191
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
192
+ }
193
+ return
194
+ }
195
+ writeJson(res, 405, { error: `method not allowed: ${method}` })
196
+ },
197
+ },
198
+ {
199
+ kind: 'exact',
200
+ path: SSH_API.importSshConfig,
201
+ handler: async (req, res) => {
202
+ if (!guard(req, res, 'POST')) return
203
+ try {
204
+ writeJson(res, 200, { result: store.importFromSshConfig() })
205
+ } catch (error) {
206
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
207
+ }
208
+ },
209
+ },
210
+ // ------------------------------------------------------------ ops
211
+ {
212
+ kind: 'exact',
213
+ path: SSH_API.test,
214
+ handler: async (req, res) => {
215
+ if (!guard(req, res, 'POST')) return
216
+ const body = await readJsonBody(req)
217
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
218
+ if (alias === '') {
219
+ writeJson(res, 400, { error: 'alias is required' })
220
+ return
221
+ }
222
+ try {
223
+ writeJson(res, 200, { result: await engine.test(alias) })
224
+ } catch (error) {
225
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
226
+ }
227
+ },
228
+ },
229
+ {
230
+ kind: 'exact',
231
+ path: SSH_API.exec,
232
+ handler: async (req, res) => {
233
+ if (!guard(req, res, 'POST')) return
234
+ const body = await readJsonBody(req)
235
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
236
+ const command = typeof body?.command === 'string' ? body.command : ''
237
+ if (alias === '' || command === '') {
238
+ writeJson(res, 400, { error: 'alias and command are required' })
239
+ return
240
+ }
241
+ const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
242
+ try {
243
+ writeJson(res, 200, { result: await engine.exec(alias, command, timeoutMs) })
244
+ } catch (error) {
245
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
246
+ }
247
+ },
248
+ },
249
+ {
250
+ kind: 'exact',
251
+ path: SSH_API.cluster,
252
+ handler: async (req, res) => {
253
+ if (!guard(req, res, 'POST')) return
254
+ const body = await readJsonBody(req)
255
+ const command = typeof body?.command === 'string' ? body.command : ''
256
+ if (command === '') {
257
+ writeJson(res, 400, { error: 'command is required' })
258
+ return
259
+ }
260
+ const aliases = Array.isArray(body?.aliases) ? body.aliases.filter((x): x is string => typeof x === 'string') : undefined
261
+ const tags = Array.isArray(body?.tags) ? body.tags.filter((x): x is string => typeof x === 'string') : undefined
262
+ const environment = typeof body?.environment === 'string' ? body.environment : undefined
263
+ const timeoutMs = typeof body?.timeoutMs === 'number' ? body.timeoutMs : undefined
264
+ const maxWorkers = typeof body?.maxWorkers === 'number' ? body.maxWorkers : undefined
265
+ try {
266
+ writeJson(res, 200, { results: await engine.cluster({ command, aliases, environment, tags, timeoutMs, maxWorkers }) })
267
+ } catch (error) {
268
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
269
+ }
270
+ },
271
+ },
272
+ // ------------------------------------------------------------- ls
273
+ {
274
+ kind: 'exact',
275
+ path: SSH_API.ls,
276
+ handler: async (req, res) => {
277
+ if (!guard(req, res, 'GET')) return
278
+ const url = new URL(req.url ?? '/', 'http://localhost')
279
+ const alias = queryParam(url, 'alias')
280
+ const path = queryParam(url, 'path') ?? '/'
281
+ if (alias === undefined || alias === '') {
282
+ writeJson(res, 400, { error: 'alias query parameter is required' })
283
+ return
284
+ }
285
+ try {
286
+ writeJson(res, 200, { entries: await engine.ls(alias, path) })
287
+ } catch (error) {
288
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
289
+ }
290
+ },
291
+ },
292
+ // --------------------------------------------------------- tunnel
293
+ {
294
+ kind: 'exact',
295
+ path: SSH_API.tunnel,
296
+ handler: async (req, res) => {
297
+ if (!guard(req, res, 'POST')) return
298
+ const body = await readJsonBody(req)
299
+ const action = typeof body?.action === 'string' ? body.action : ''
300
+ if (action === 'list') {
301
+ writeJson(res, 200, { tunnels: engine.listTunnels() })
302
+ return
303
+ }
304
+ if (action === 'start') {
305
+ const alias = typeof body?.alias === 'string' ? body.alias : ''
306
+ const remotePort = typeof body?.remotePort === 'number' ? body.remotePort : undefined
307
+ if (alias === '' || remotePort === undefined) {
308
+ writeJson(res, 400, { error: 'alias and remotePort are required' })
309
+ return
310
+ }
311
+ try {
312
+ const tunnel = await engine.startTunnel(alias, {
313
+ remotePort,
314
+ remoteHost: typeof body?.remoteHost === 'string' && body.remoteHost !== '' ? body.remoteHost : undefined,
315
+ localPort: typeof body?.localPort === 'number' ? body.localPort : undefined,
316
+ })
317
+ writeJson(res, 200, { tunnel })
318
+ } catch (error) {
319
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
320
+ }
321
+ return
322
+ }
323
+ if (action === 'stop') {
324
+ const id = typeof body?.tunnelId === 'string' ? body.tunnelId : ''
325
+ if (id === '') {
326
+ writeJson(res, 400, { error: 'tunnelId is required' })
327
+ return
328
+ }
329
+ writeJson(res, 200, { ok: engine.stopTunnel(id) })
330
+ return
331
+ }
332
+ if (action === 'stop-all') {
333
+ const alias = typeof body?.alias === 'string' ? body.alias : undefined
334
+ writeJson(res, 200, { stopped: engine.stopAllTunnels(alias === '' ? undefined : alias) })
335
+ return
336
+ }
337
+ writeJson(res, 400, { error: `unknown action '${action}'` })
338
+ },
339
+ },
340
+ // --------------------------------------------------------- upload
341
+ {
342
+ kind: 'exact',
343
+ path: SSH_API.upload,
344
+ handler: async (req, res) => {
345
+ if (!guard(req, res, 'POST')) return
346
+ const url = new URL(req.url ?? '/', 'http://localhost')
347
+ const alias = queryParam(url, 'alias')
348
+ const remotePath = queryParam(url, 'remotePath')
349
+ if (alias === undefined || remotePath === undefined) {
350
+ writeJson(res, 400, { error: 'alias and remotePath query parameters are required' })
351
+ return
352
+ }
353
+ const declared = Number(req.headers['content-length'])
354
+ if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
355
+ writeJson(res, 413, { error: 'upload body too large' })
356
+ return
357
+ }
358
+ res.writeHead(200, {
359
+ 'content-type': 'application/x-ndjson; charset=utf-8',
360
+ 'cache-control': 'no-cache',
361
+ 'referrer-policy': 'no-referrer',
362
+ })
363
+ const emit = (line: unknown): void => {
364
+ try { res.write(JSON.stringify(line) + '\n') } catch { /* client gone */ }
365
+ }
366
+ // Stage the uploaded bytes, then SFTP them out with progress frames.
367
+ const tmp = join(staging, `upload-${randomBytes(6).toString('hex')}`)
368
+ const sink = createWriteStream(tmp)
369
+ let settled = false
370
+ // Every terminal path (sink error, client abort, response loss) must
371
+ // emit a result frame, end the response, and remove the tmp file.
372
+ const fail = (error: unknown): void => {
373
+ if (settled) return
374
+ settled = true
375
+ emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
376
+ try { sink.destroy() } catch { /* closed */ }
377
+ void unlink(tmp).catch(() => undefined)
378
+ try { res.end() } catch { /* closed */ }
379
+ }
380
+ const done = (): void => {
381
+ if (settled) return
382
+ settled = true
383
+ try { res.end() } catch { /* closed */ }
384
+ }
385
+ sink.on('error', (error) => fail(error))
386
+ req.on('error', (error) => fail(error))
387
+ req.on('aborted', () => fail('upload aborted by the client'))
388
+ res.on('error', () => fail('response stream closed'))
389
+ res.on('close', () => { if (!res.writableEnded) fail('connection closed') })
390
+ req.pipe(sink)
391
+ sink.on('finish', async () => {
392
+ if (settled) return
393
+ emit({ type: 'progress', progress: { phase: 'connecting', file: remotePath, transferred: 0, total: 0, percent: 0 } })
394
+ try {
395
+ const outcome = await engine.upload(alias, tmp, remotePath, false, progress => emit({ type: 'progress', progress }))
396
+ emit({ type: 'result', ok: true, transferredBytes: outcome.bytes })
397
+ } catch (error) {
398
+ emit({ type: 'result', ok: false, error: error instanceof Error ? error.message : String(error) })
399
+ } finally {
400
+ await unlink(tmp).catch(() => undefined)
401
+ done()
402
+ }
403
+ })
404
+ },
405
+ },
406
+ // ------------------------------------------------------- download
407
+ {
408
+ kind: 'exact',
409
+ path: SSH_API.download,
410
+ handler: async (req, res) => {
411
+ if (!guard(req, res, 'GET')) return
412
+ const url = new URL(req.url ?? '/', 'http://localhost')
413
+ const alias = queryParam(url, 'alias')
414
+ const remotePath = queryParam(url, 'remotePath')
415
+ if (alias === undefined || remotePath === undefined) {
416
+ writeJson(res, 400, { error: 'alias and remotePath query parameters are required' })
417
+ return
418
+ }
419
+ const tmp = join(staging, `download-${randomBytes(6).toString('hex')}`)
420
+ try {
421
+ const outcome = await engine.download(alias, remotePath, tmp)
422
+ res.writeHead(200, {
423
+ 'content-type': 'application/octet-stream',
424
+ 'content-length': String(outcome.bytes),
425
+ 'content-disposition': `attachment; filename="${basename(remotePath).replace(/"/g, '')}"`,
426
+ 'referrer-policy': 'no-referrer',
427
+ })
428
+ await new Promise<void>((resolve, reject) => {
429
+ const source = createReadStream(tmp)
430
+ source.on('error', reject)
431
+ res.on('error', reject)
432
+ source.pipe(res)
433
+ source.on('end', resolve)
434
+ })
435
+ } catch (error) {
436
+ if (!res.headersSent) {
437
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
438
+ } else {
439
+ // Mid-stream failure after headers: destroy so the browser does
440
+ // not hang waiting for the promised content-length bytes.
441
+ res.destroy()
442
+ }
443
+ } finally {
444
+ await unlink(tmp).catch(() => undefined)
445
+ }
446
+ },
447
+ },
448
+ ]
449
+
450
+ // ---------------------------------------------- terminal (upgrade)
451
+ const upgrade: WebUpgradeRoute = {
452
+ path: SSH_API.terminal,
453
+ handler: (req, socket, head) => {
454
+ if (!isLoopbackRequest(req)) {
455
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
456
+ socket.destroy()
457
+ return
458
+ }
459
+ const url = new URL(req.url ?? '/', 'http://localhost')
460
+ const alias = queryParam(url, 'alias')
461
+ if (alias === undefined) {
462
+ socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
463
+ socket.destroy()
464
+ return
465
+ }
466
+ const cols = Number.parseInt(queryParam(url, 'cols') ?? '80', 10)
467
+ const rows = Number.parseInt(queryParam(url, 'rows') ?? '24', 10)
468
+ terminalWss.handleUpgrade(req, socket, head, (ws) => {
469
+ let session: ShellSession | undefined
470
+ let closed = false
471
+ let paused = false
472
+ // Resume the shell once the socket's send buffer drains below the
473
+ // low-water mark (transport backpressure).
474
+ const resume = (): void => {
475
+ if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
476
+ paused = false
477
+ session?.resume()
478
+ }
479
+ }
480
+ const sendFrame = (frame: TerminalServerFrame): void => {
481
+ if (closed || ws.readyState !== WebSocket.OPEN) return
482
+ ws.send(JSON.stringify(frame), resume)
483
+ if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
484
+ paused = true
485
+ session?.pause()
486
+ }
487
+ }
488
+ const closeSession = (): void => {
489
+ const opened = session
490
+ session = undefined
491
+ if (opened !== undefined) opened.close()
492
+ }
493
+ engine.openShell(alias, {
494
+ cols: Number.isFinite(cols) ? cols : 80,
495
+ rows: Number.isFinite(rows) ? rows : 24,
496
+ }).then((opened) => {
497
+ if (ws.readyState !== WebSocket.OPEN) {
498
+ opened.close()
499
+ return
500
+ }
501
+ session = opened
502
+ sendFrame({ type: 'ready', alias })
503
+ opened.onData = (data) => sendFrame({ type: 'output', data: data.toString('utf8') })
504
+ opened.onExit = (code, error) => {
505
+ sendFrame({ type: 'exit', code, error })
506
+ closed = true
507
+ try { ws.close(1000) } catch { /* already closed */ }
508
+ }
509
+ }).catch((error) => {
510
+ sendFrame({ type: 'exit', code: null, error: error instanceof Error ? error.message : String(error) })
511
+ closed = true
512
+ try { ws.close(1000) } catch { /* already closed */ }
513
+ })
514
+ ws.on('message', (data) => {
515
+ let frame: TerminalClientFrame
516
+ try {
517
+ frame = JSON.parse(String(data)) as TerminalClientFrame
518
+ } catch {
519
+ return
520
+ }
521
+ if (frame.type === 'input') {
522
+ session?.send(frame.data)
523
+ } else if (frame.type === 'resize') {
524
+ session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows))
525
+ }
526
+ })
527
+ ws.on('close', () => {
528
+ closed = true
529
+ closeSession()
530
+ })
531
+ ws.on('error', () => {
532
+ closed = true
533
+ closeSession()
534
+ })
535
+ })
536
+ },
537
+ }
538
+
539
+ return { routes, upgrade }
540
+ }