dsh-multi-chat 1.0.2 → 1.0.4
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 +287 -268
- package/README.zh.md +269 -232
- package/bin/dsh-multi-chat.mjs +33 -0
- package/lib/client.js +66 -28
- package/lib/client.js.map +1 -1
- package/lib/index.js +211 -22
- package/lib/invariant.js +23 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/types/client/WallToggle.js +2 -2
- package/lib/types/client/WallToggle.js.map +1 -1
- package/lib/types/client/WallView.js +8 -8
- package/lib/types/client/WallView.js.map +1 -1
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/store.d.ts +1 -1
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/client/store.js +1 -1
- package/lib/types/client/store.js.map +1 -1
- package/lib/types/index.d.ts +80 -80
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +117 -12
- package/lib/types/index.js.map +1 -1
- package/package.json +139 -108
- package/scripts/check-platform-contract.mjs +171 -0
- package/src/client/WallToggle.tsx +52 -52
- package/src/client/WallView.tsx +406 -406
- package/src/client/index.ts +108 -103
- package/src/client/store.ts +40 -40
- package/src/index.ts +715 -618
- package/scripts/sync-vitest-paths.mjs +0 -63
package/src/index.ts
CHANGED
|
@@ -1,618 +1,715 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Multi-window wall plugin, node half: registers the `/multi/api/*` routes
|
|
3
|
-
* on the webserver. The browser half fetches these same-origin to discover
|
|
4
|
-
* which local ports are live DSH instances, to poll liveness, and to
|
|
5
|
-
* terminate a chosen instance (`/multi/api/stop`). The wall itself is pure
|
|
6
|
-
* UI — this half answers a few small JSON requests.
|
|
7
|
-
* @module dsh-multi-chat
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { execFile, spawn } from 'node:child_process'
|
|
11
|
-
import { randomBytes } from 'node:crypto'
|
|
12
|
-
import { existsSync } from 'node:fs'
|
|
13
|
-
import { networkInterfaces } from 'node:os'
|
|
14
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
-
import z from '@deepseek-ai/schemastery'
|
|
16
|
-
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
17
|
-
import { startGateway, type GatewayHandle } from './gateway'
|
|
18
|
-
|
|
19
|
-
/** Stable Cordis plugin name. */
|
|
20
|
-
export const name = 'dsh-multi-chat'
|
|
21
|
-
|
|
22
|
-
/** Services required before the probe routes can be registered. */
|
|
23
|
-
export const inject = ['webServer']
|
|
24
|
-
|
|
25
|
-
/** Plugin config: the wall's auto-discovery scan range. */
|
|
26
|
-
export interface MultiWallConfig {
|
|
27
|
-
/** First port of the auto-discovery range. */
|
|
28
|
-
scanFrom?: number
|
|
29
|
-
/** Last port of the auto-discovery range. */
|
|
30
|
-
scanTo?: number
|
|
31
|
-
/** Optional fixed port list; when set, discovery ignores the scan range. */
|
|
32
|
-
ports?: number[]
|
|
33
|
-
/**
|
|
34
|
-
* External base URL reported by `/multi/api/link` (e.g. the authenticated
|
|
35
|
-
* gateway in front of this loopback instance). When set, the link route
|
|
36
|
-
* answers `{ lan: [publicUrl + '/'], reachable: true }`.
|
|
37
|
-
*/
|
|
38
|
-
publicUrl?: string
|
|
39
|
-
/**
|
|
40
|
-
* Gateway listen port for the inline phone-access gateway. `0` (default)
|
|
41
|
-
* means `targetPort + 5000`.
|
|
42
|
-
*/
|
|
43
|
-
gatewayPort?: number
|
|
44
|
-
/**
|
|
45
|
-
* Optional fixed login token for the inline gateway. Empty (default) means
|
|
46
|
-
* a random token is generated per gateway start (returned by /multi/api/link).
|
|
47
|
-
*/
|
|
48
|
-
gatewayToken?: string
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Schema-validated config (the Loader resolves defaults for absent keys). */
|
|
52
|
-
export const Config = z.object({
|
|
53
|
-
scanFrom: z.natural().default(3070),
|
|
54
|
-
scanTo: z.natural().default(3110),
|
|
55
|
-
ports: z.array(z.natural()).default([]),
|
|
56
|
-
publicUrl: z.string().default(''),
|
|
57
|
-
gatewayPort: z.number().default(0),
|
|
58
|
-
gatewayToken: z.string().default(''),
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
/** MIME for JSON probe answers. */
|
|
62
|
-
const JSON_TYPE = 'application/json; charset=utf-8'
|
|
63
|
-
|
|
64
|
-
/** One probe result row. */
|
|
65
|
-
interface ProbeRow {
|
|
66
|
-
port: number
|
|
67
|
-
alive: boolean
|
|
68
|
-
status: number
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** GET one local URL with a short timeout; resolve {status, body} or reject. */
|
|
72
|
-
async function request(url: string, timeoutMs = 600): Promise<{ status: number; body: string }> {
|
|
73
|
-
const controller = new AbortController()
|
|
74
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
75
|
-
try {
|
|
76
|
-
const res = await fetch(url, { signal: controller.signal })
|
|
77
|
-
return { status: res.status, body: await res.text() }
|
|
78
|
-
} finally {
|
|
79
|
-
clearTimeout(timer)
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Is this local port a live DSH instance (index.html carries __DSH_BOOT__)? */
|
|
84
|
-
async function probePort(port: number): Promise<ProbeRow> {
|
|
85
|
-
try {
|
|
86
|
-
const { status, body } = await request(`http://127.0.0.1:${port}/`)
|
|
87
|
-
return { port, alive: status === 200 && body.includes('__DSH_BOOT__'), status }
|
|
88
|
-
} catch {
|
|
89
|
-
return { port, alive: false, status: 0 }
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Concurrent probe of many ports (bounded chunking). */
|
|
94
|
-
async function probePorts(ports: number[]): Promise<ProbeRow[]> {
|
|
95
|
-
const CHUNK = 16
|
|
96
|
-
const out: ProbeRow[] = []
|
|
97
|
-
for (let i = 0; i < ports.length; i += CHUNK) {
|
|
98
|
-
out.push(...(await Promise.all(ports.slice(i, i + CHUNK).map(port => probePort(port)))))
|
|
99
|
-
}
|
|
100
|
-
return out
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Send a small JSON response. */
|
|
104
|
-
function json(res: import('node:http').ServerResponse, value: unknown, status = 200): void {
|
|
105
|
-
res.writeHead(status, { 'content-type': JSON_TYPE, 'cache-control': 'no-store' })
|
|
106
|
-
res.end(JSON.stringify(value))
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** One stop result row from /multi/api/stop. */
|
|
110
|
-
export interface StopRow {
|
|
111
|
-
port: number
|
|
112
|
-
ok: boolean
|
|
113
|
-
/** Human-readable failure reason (absent on success). */
|
|
114
|
-
error?: string
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Run a command and resolve its stdout text. Rejects on non-zero exit.
|
|
119
|
-
* @param file - the executable path.
|
|
120
|
-
* @param args - CLI arguments.
|
|
121
|
-
* @returns the trimmed stdout.
|
|
122
|
-
*/
|
|
123
|
-
function execStdout(file: string, args: string[]): Promise<string> {
|
|
124
|
-
return new Promise((resolve, reject) => {
|
|
125
|
-
execFile(file, args, { timeout: 5000 }, (error, stdout) => {
|
|
126
|
-
if (error !== null) {
|
|
127
|
-
reject(error)
|
|
128
|
-
return
|
|
129
|
-
}
|
|
130
|
-
resolve(stdout)
|
|
131
|
-
})
|
|
132
|
-
})
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* @param
|
|
139
|
-
* @returns the
|
|
140
|
-
*/
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
*
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
*
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
*
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Multi-window wall plugin, node half: registers the `/multi/api/*` routes
|
|
3
|
+
* on the webserver. The browser half fetches these same-origin to discover
|
|
4
|
+
* which local ports are live DSH instances, to poll liveness, and to
|
|
5
|
+
* terminate a chosen instance (`/multi/api/stop`). The wall itself is pure
|
|
6
|
+
* UI — this half answers a few small JSON requests.
|
|
7
|
+
* @module dsh-multi-chat
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFile, spawn } from 'node:child_process'
|
|
11
|
+
import { randomBytes } from 'node:crypto'
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs'
|
|
13
|
+
import { networkInterfaces } from 'node:os'
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import z from '@deepseek-ai/schemastery'
|
|
16
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
17
|
+
import { startGateway, type GatewayHandle } from './gateway'
|
|
18
|
+
|
|
19
|
+
/** Stable Cordis plugin name. */
|
|
20
|
+
export const name = 'dsh-multi-chat'
|
|
21
|
+
|
|
22
|
+
/** Services required before the probe routes can be registered. */
|
|
23
|
+
export const inject = ['webServer']
|
|
24
|
+
|
|
25
|
+
/** Plugin config: the wall's auto-discovery scan range. */
|
|
26
|
+
export interface MultiWallConfig {
|
|
27
|
+
/** First port of the auto-discovery range. */
|
|
28
|
+
scanFrom?: number
|
|
29
|
+
/** Last port of the auto-discovery range. */
|
|
30
|
+
scanTo?: number
|
|
31
|
+
/** Optional fixed port list; when set, discovery ignores the scan range. */
|
|
32
|
+
ports?: number[]
|
|
33
|
+
/**
|
|
34
|
+
* External base URL reported by `/multi/api/link` (e.g. the authenticated
|
|
35
|
+
* gateway in front of this loopback instance). When set, the link route
|
|
36
|
+
* answers `{ lan: [publicUrl + '/'], reachable: true }`.
|
|
37
|
+
*/
|
|
38
|
+
publicUrl?: string
|
|
39
|
+
/**
|
|
40
|
+
* Gateway listen port for the inline phone-access gateway. `0` (default)
|
|
41
|
+
* means `targetPort + 5000`.
|
|
42
|
+
*/
|
|
43
|
+
gatewayPort?: number
|
|
44
|
+
/**
|
|
45
|
+
* Optional fixed login token for the inline gateway. Empty (default) means
|
|
46
|
+
* a random token is generated per gateway start (returned by /multi/api/link).
|
|
47
|
+
*/
|
|
48
|
+
gatewayToken?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Schema-validated config (the Loader resolves defaults for absent keys). */
|
|
52
|
+
export const Config = z.object({
|
|
53
|
+
scanFrom: z.natural().default(3070),
|
|
54
|
+
scanTo: z.natural().default(3110),
|
|
55
|
+
ports: z.array(z.natural()).default([]),
|
|
56
|
+
publicUrl: z.string().default(''),
|
|
57
|
+
gatewayPort: z.number().default(0),
|
|
58
|
+
gatewayToken: z.string().default(''),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/** MIME for JSON probe answers. */
|
|
62
|
+
const JSON_TYPE = 'application/json; charset=utf-8'
|
|
63
|
+
|
|
64
|
+
/** One probe result row. */
|
|
65
|
+
interface ProbeRow {
|
|
66
|
+
port: number
|
|
67
|
+
alive: boolean
|
|
68
|
+
status: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** GET one local URL with a short timeout; resolve {status, body} or reject. */
|
|
72
|
+
async function request(url: string, timeoutMs = 600): Promise<{ status: number; body: string }> {
|
|
73
|
+
const controller = new AbortController()
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(url, { signal: controller.signal })
|
|
77
|
+
return { status: res.status, body: await res.text() }
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timer)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Is this local port a live DSH instance (index.html carries __DSH_BOOT__)? */
|
|
84
|
+
async function probePort(port: number): Promise<ProbeRow> {
|
|
85
|
+
try {
|
|
86
|
+
const { status, body } = await request(`http://127.0.0.1:${port}/`)
|
|
87
|
+
return { port, alive: status === 200 && body.includes('__DSH_BOOT__'), status }
|
|
88
|
+
} catch {
|
|
89
|
+
return { port, alive: false, status: 0 }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Concurrent probe of many ports (bounded chunking). */
|
|
94
|
+
async function probePorts(ports: number[]): Promise<ProbeRow[]> {
|
|
95
|
+
const CHUNK = 16
|
|
96
|
+
const out: ProbeRow[] = []
|
|
97
|
+
for (let i = 0; i < ports.length; i += CHUNK) {
|
|
98
|
+
out.push(...(await Promise.all(ports.slice(i, i + CHUNK).map(port => probePort(port)))))
|
|
99
|
+
}
|
|
100
|
+
return out
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Send a small JSON response. */
|
|
104
|
+
function json(res: import('node:http').ServerResponse, value: unknown, status = 200): void {
|
|
105
|
+
res.writeHead(status, { 'content-type': JSON_TYPE, 'cache-control': 'no-store' })
|
|
106
|
+
res.end(JSON.stringify(value))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One stop result row from /multi/api/stop. */
|
|
110
|
+
export interface StopRow {
|
|
111
|
+
port: number
|
|
112
|
+
ok: boolean
|
|
113
|
+
/** Human-readable failure reason (absent on success). */
|
|
114
|
+
error?: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run a command and resolve its stdout text. Rejects on non-zero exit.
|
|
119
|
+
* @param file - the executable path.
|
|
120
|
+
* @param args - CLI arguments.
|
|
121
|
+
* @returns the trimmed stdout.
|
|
122
|
+
*/
|
|
123
|
+
function execStdout(file: string, args: string[]): Promise<string> {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
execFile(file, args, { timeout: 5000 }, (error, stdout) => {
|
|
126
|
+
if (error !== null) {
|
|
127
|
+
reject(error)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
resolve(stdout)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether an exec failure means the binary itself is absent (ENOENT) rather
|
|
137
|
+
* than the command running and reporting a non-zero exit.
|
|
138
|
+
* @param error - the rejection from {@link execStdout}.
|
|
139
|
+
* @returns true when the executable could not be spawned at all.
|
|
140
|
+
*/
|
|
141
|
+
function isMissingBinary(error: unknown): boolean {
|
|
142
|
+
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read the local TCP sockets in LISTEN state from the kernel's `/proc` tables.
|
|
147
|
+
* This is the Linux fallback for hosts without `lsof`, which is most
|
|
148
|
+
* container images — including the ones `dsh web` is commonly run in. Only
|
|
149
|
+
* this network namespace's sockets are visible, which is exactly the view the
|
|
150
|
+
* serving instance and the instances it spawns live in.
|
|
151
|
+
* @param port - restrict the scan to one port when given.
|
|
152
|
+
* @returns listening port -> owning socket inode ('' when the row has none).
|
|
153
|
+
*/
|
|
154
|
+
function procListenSockets(port?: number): Map<number, string> {
|
|
155
|
+
const found = new Map<number, string>()
|
|
156
|
+
for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
157
|
+
let text: string
|
|
158
|
+
try {
|
|
159
|
+
text = readFileSync(file, 'utf8')
|
|
160
|
+
} catch {
|
|
161
|
+
continue // An absent IPv6 table is normal; either table alone suffices.
|
|
162
|
+
}
|
|
163
|
+
for (const line of text.split('\n').slice(1)) {
|
|
164
|
+
// sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
|
165
|
+
// 0: 0100007F:0CEA 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1
|
|
166
|
+
const f = line.trim().split(/\s+/)
|
|
167
|
+
if (f.length < 10 || f[3] !== '0A') continue
|
|
168
|
+
const local = f[1] ?? ''
|
|
169
|
+
const listening = Number.parseInt(local.slice(local.indexOf(':') + 1), 16)
|
|
170
|
+
if (!Number.isInteger(listening) || listening <= 0) continue
|
|
171
|
+
if (port !== undefined && listening !== port) continue
|
|
172
|
+
found.set(listening, f[9] ?? '')
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return found
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve which PIDs own the given socket inodes by scanning `/proc/<pid>/fd`
|
|
180
|
+
* links. Only processes this user is allowed to inspect are visible — the
|
|
181
|
+
* same permission bound `lsof` has.
|
|
182
|
+
* @param inodes - the socket inodes to look for.
|
|
183
|
+
* @returns the owning PIDs (possibly empty).
|
|
184
|
+
*/
|
|
185
|
+
function pidsForInodes(inodes: Iterable<string>): number[] {
|
|
186
|
+
const wanted = new Set(
|
|
187
|
+
[...inodes].filter(inode => inode !== '').map(inode => `socket:[${inode}]`),
|
|
188
|
+
)
|
|
189
|
+
if (wanted.size === 0) return []
|
|
190
|
+
let entries: string[]
|
|
191
|
+
try {
|
|
192
|
+
entries = readdirSync('/proc')
|
|
193
|
+
} catch {
|
|
194
|
+
return []
|
|
195
|
+
}
|
|
196
|
+
const pids = new Set<number>()
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const pid = Number(entry)
|
|
199
|
+
if (!Number.isInteger(pid) || pid <= 0) continue
|
|
200
|
+
let fds: string[]
|
|
201
|
+
try {
|
|
202
|
+
fds = readdirSync(`/proc/${entry}/fd`)
|
|
203
|
+
} catch {
|
|
204
|
+
continue // Exited, or not ours to inspect.
|
|
205
|
+
}
|
|
206
|
+
for (const fd of fds) {
|
|
207
|
+
try {
|
|
208
|
+
if (wanted.has(readlinkSync(`/proc/${entry}/fd/${fd}`))) {
|
|
209
|
+
pids.add(pid)
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Descriptor closed between readdir and readlink.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return [...pids]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve the PIDs listening on a local TCP port. Windows uses `netstat`;
|
|
222
|
+
* POSIX prefers `lsof` and falls back to the kernel's `/proc` tables on Linux
|
|
223
|
+
* hosts that do not ship it.
|
|
224
|
+
* @param port - the listening port.
|
|
225
|
+
* @returns the listener PIDs (possibly empty).
|
|
226
|
+
*/
|
|
227
|
+
async function listeningPids(port: number): Promise<number[]> {
|
|
228
|
+
if (process.platform === 'win32') {
|
|
229
|
+
const stdout = await execStdout('netstat', ['-ano', '-p', 'tcp'])
|
|
230
|
+
const pids = new Set<number>()
|
|
231
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
232
|
+
// TCP 127.0.0.1:3080 0.0.0.0:0 LISTENING 12345
|
|
233
|
+
const m = /^\s*TCP\s+([0-9.]+|\*|\[::\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/.exec(line)
|
|
234
|
+
if (m !== null && Number(m[2]) === port) pids.add(Number(m[3]))
|
|
235
|
+
}
|
|
236
|
+
return [...pids]
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const stdout = await execStdout('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'])
|
|
240
|
+
return stdout.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (process.platform !== 'linux' || !isMissingBinary(error)) throw error
|
|
243
|
+
return pidsForInodes(procListenSockets(port).values())
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Terminate one PID. Windows uses `taskkill /F` (force); POSIX sends SIGTERM
|
|
249
|
+
* then SIGKILL after a grace period.
|
|
250
|
+
* @param pid - the process id to terminate.
|
|
251
|
+
*/
|
|
252
|
+
async function killPid(pid: number): Promise<void> {
|
|
253
|
+
if (process.platform === 'win32') {
|
|
254
|
+
await execStdout('taskkill', ['/PID', String(pid), '/F', '/T'])
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
process.kill(pid, 'SIGTERM')
|
|
259
|
+
} catch {
|
|
260
|
+
// Race: process already gone — treat as terminated.
|
|
261
|
+
}
|
|
262
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
263
|
+
try {
|
|
264
|
+
process.kill(pid, 'SIGKILL')
|
|
265
|
+
} catch {
|
|
266
|
+
// Already gone.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Terminate the DSH instance listening on one local port. The port serving
|
|
272
|
+
* this wall may also be terminated (the user may want to stop the instance
|
|
273
|
+
* they are viewing): the kill is deferred a beat so the HTTP response is
|
|
274
|
+
* written before the process dies, then the listener's PIDs are force-killed.
|
|
275
|
+
* @param port - the target port.
|
|
276
|
+
* @param selfPort - this instance's own listening port.
|
|
277
|
+
* @returns the stop result.
|
|
278
|
+
*/
|
|
279
|
+
export async function stopPort(port: number, selfPort: number): Promise<StopRow> {
|
|
280
|
+
try {
|
|
281
|
+
const pids = await listeningPids(port)
|
|
282
|
+
if (pids.length === 0) {
|
|
283
|
+
return { port, ok: false, error: 'no listener on this port' }
|
|
284
|
+
}
|
|
285
|
+
const kill = () => Promise.all(pids.map(pid => killPid(pid).catch(() => {})))
|
|
286
|
+
if (port === selfPort) {
|
|
287
|
+
// Let the response flush before taking ourselves down.
|
|
288
|
+
setTimeout(() => { void kill() }, 250)
|
|
289
|
+
return { port, ok: true }
|
|
290
|
+
}
|
|
291
|
+
await kill()
|
|
292
|
+
return { port, ok: true }
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return { port, ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** How a new `dsh web` process is spawned. */
|
|
299
|
+
interface Launcher {
|
|
300
|
+
file: string
|
|
301
|
+
args: string[]
|
|
302
|
+
/** Resolve `file` through the shell (needed for the Windows .cmd shim). */
|
|
303
|
+
shell: boolean
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve how to launch a new DSH instance. Primary path: the current
|
|
308
|
+
* process's own entry (`node <bin> web` under `process.argv[1]`), so the new
|
|
309
|
+
* instance inherits the exact CLI/profile already running. Fallback: the
|
|
310
|
+
* `dsh` command from PATH when the entry cannot be derived (unusual host
|
|
311
|
+
* launcher, missing file).
|
|
312
|
+
* @returns the launcher description.
|
|
313
|
+
*/
|
|
314
|
+
function resolveLauncher(): Launcher {
|
|
315
|
+
const first = process.argv[1]
|
|
316
|
+
if (first !== undefined && existsSync(first)) {
|
|
317
|
+
return { file: process.execPath, args: [first, 'web', '--port'], shell: false }
|
|
318
|
+
}
|
|
319
|
+
return { file: 'dsh', args: ['web', '--port'], shell: process.platform === 'win32' }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Collect every distinct local TCP port that is listening, in ONE command
|
|
324
|
+
* (not one `netstat`/`lsof` per candidate, which the old free-port scan ran
|
|
325
|
+
* sequentially and could take many seconds on slow Windows boxes). Windows
|
|
326
|
+
* parses `netstat`; POSIX parses `lsof` `(LISTEN)` lines, falling back to
|
|
327
|
+
* `/proc/net/tcp*` on Linux hosts without `lsof`.
|
|
328
|
+
* @returns the set of busy ports.
|
|
329
|
+
*/
|
|
330
|
+
async function listeningPorts(): Promise<Set<number>> {
|
|
331
|
+
const set = new Set<number>()
|
|
332
|
+
if (process.platform === 'win32') {
|
|
333
|
+
const stdout = await execStdout('netstat', ['-ano', '-p', 'tcp'])
|
|
334
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
335
|
+
// TCP 127.0.0.1:3080 0.0.0.0:0 LISTENING 12345
|
|
336
|
+
const m = /^\s*TCP\s+([0-9.]+|\*|\[::\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/.exec(line)
|
|
337
|
+
if (m !== null) set.add(Number(m[2]))
|
|
338
|
+
}
|
|
339
|
+
return set
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const stdout = await execStdout('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'])
|
|
343
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
344
|
+
// node 1234 user 13u IPv4 12345 0t0 TCP 127.0.0.1:3080 (LISTEN)
|
|
345
|
+
const m = /:(\d+)\s+\(LISTEN\)\s*$/.exec(line.trim())
|
|
346
|
+
if (m !== null) set.add(Number(m[1]))
|
|
347
|
+
}
|
|
348
|
+
return set
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (process.platform !== 'linux' || !isMissingBinary(error)) throw error
|
|
351
|
+
return new Set(procListenSockets().keys())
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Pick the first free port in [lo, hi] that is neither the serving port nor
|
|
357
|
+
* already listening. The busy set is resolved once (a single command), then
|
|
358
|
+
* scanned in memory.
|
|
359
|
+
* @param lo - first port of the range.
|
|
360
|
+
* @param hi - last port of the range.
|
|
361
|
+
* @param selfPort - the port serving this wall (never chosen).
|
|
362
|
+
* @returns a free port, or undefined when the range is exhausted.
|
|
363
|
+
*/
|
|
364
|
+
async function pickFreePort(lo: number, hi: number, selfPort: number): Promise<number | undefined> {
|
|
365
|
+
const busy = await listeningPorts()
|
|
366
|
+
for (let port = lo; port <= hi; port++) {
|
|
367
|
+
if (port === selfPort) continue
|
|
368
|
+
if (busy.has(port)) continue
|
|
369
|
+
return port
|
|
370
|
+
}
|
|
371
|
+
return undefined
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Spawn a new `dsh web` instance on a port and decide quickly whether it is
|
|
376
|
+
* viable. Detached so it outlives this process. This is intentionally NOT a
|
|
377
|
+
* full readiness gate: the wall polls liveness itself, so the create response
|
|
378
|
+
* returns fast and a still-booting instance simply shows a pane that lights up
|
|
379
|
+
* when the server finishes. A short bounded wait still catches immediate
|
|
380
|
+
* spawn failures (bad bin, ENOENT) and very fast boots.
|
|
381
|
+
*
|
|
382
|
+
* On a genuine launch failure the child is killed so no orphan lingers; on a
|
|
383
|
+
* slow-but-viable start the deadline returns ok:true and the child keeps
|
|
384
|
+
* booting in the background (the pane already mounted, liveness confirms when
|
|
385
|
+
* alive). The child's stderr is captured and quoted into every failure so a
|
|
386
|
+
* crash or a bad bin surfaces a concrete reason instead of a bare timeout.
|
|
387
|
+
* @param launcher - how to spawn the dsh CLI.
|
|
388
|
+
* @param port - the port for the new instance.
|
|
389
|
+
* @param timeoutMs - how long to wait before handing back ok:true.
|
|
390
|
+
* @param pollMs - readiness probe interval while waiting.
|
|
391
|
+
* @returns ok plus the port, or ok:false with a reason.
|
|
392
|
+
*/
|
|
393
|
+
async function startInstance(
|
|
394
|
+
launcher: Launcher,
|
|
395
|
+
port: number,
|
|
396
|
+
timeoutMs = 3000,
|
|
397
|
+
pollMs = 300,
|
|
398
|
+
): Promise<{ ok: boolean; port: number; error?: string }> {
|
|
399
|
+
const child = spawn(launcher.file, [...launcher.args, String(port)], {
|
|
400
|
+
detached: true,
|
|
401
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
402
|
+
windowsHide: true,
|
|
403
|
+
shell: launcher.shell,
|
|
404
|
+
})
|
|
405
|
+
child.unref()
|
|
406
|
+
let stderr = ''
|
|
407
|
+
// Held by property so flow analysis cannot conclude the closure assignment
|
|
408
|
+
// never runs (a local assigned only inside a callback narrows to never at
|
|
409
|
+
// the check).
|
|
410
|
+
const spawnFailure: { error: Error | null } = { error: null }
|
|
411
|
+
child.stderr?.on('data', chunk => {
|
|
412
|
+
stderr += String(chunk)
|
|
413
|
+
if (stderr.length > 2000) stderr = stderr.slice(-2000)
|
|
414
|
+
})
|
|
415
|
+
child.once('error', error => { spawnFailure.error = error })
|
|
416
|
+
const detail = (): string => {
|
|
417
|
+
const tail = stderr.trim().split(/\r?\n/).slice(-3).join(' | ')
|
|
418
|
+
return tail === '' ? '' : ` (${tail})`
|
|
419
|
+
}
|
|
420
|
+
const deadline = Date.now() + timeoutMs
|
|
421
|
+
for (;;) {
|
|
422
|
+
if (spawnFailure.error !== null) {
|
|
423
|
+
// Can't launch at all: reap the child so no orphan lingers.
|
|
424
|
+
try { child.kill() } catch { /* already gone */ }
|
|
425
|
+
return { ok: false, port, error: `new instance failed to start: ${spawnFailure.error.message}` }
|
|
426
|
+
}
|
|
427
|
+
if (child.exitCode !== null) {
|
|
428
|
+
return { ok: false, port, error: `new instance exited early (code ${child.exitCode})${detail()}` }
|
|
429
|
+
}
|
|
430
|
+
const row = await probePort(port)
|
|
431
|
+
if (row.alive) return { ok: true, port }
|
|
432
|
+
if (Date.now() > deadline) {
|
|
433
|
+
// Not ready yet but viable: hand back ok so the create response isn't
|
|
434
|
+
// blocked on a slow boot — the pane mounts and the wall's own liveness
|
|
435
|
+
// poll lights it up when the server finishes starting.
|
|
436
|
+
return { ok: true, port }
|
|
437
|
+
}
|
|
438
|
+
await new Promise(resolve => setTimeout(resolve, pollMs))
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Interface-name patterns that mark a *virtual* NIC (VM bridge, WSL,
|
|
444
|
+
* Docker, Hyper-V, VPN adapters, Loopback Pseudo-Instance, etc.). These
|
|
445
|
+
* interfaces are never reachable from a phone on the same LAN, so they are
|
|
446
|
+
* dropped from the link list entirely.
|
|
447
|
+
*/
|
|
448
|
+
const VIRTUAL_IFACE_PATTERNS: RegExp[] = [
|
|
449
|
+
/vEthernet/i, // Hyper-V virtual switch
|
|
450
|
+
/vmware/i, // VMware VMnet adapters
|
|
451
|
+
/virtualbox/i, // VirtualBox host-only
|
|
452
|
+
/^vbox/i,
|
|
453
|
+
/wsl/i, // Windows Subsystem for Linux
|
|
454
|
+
/docker/i, // Docker / DockerNAT
|
|
455
|
+
/hyper-v/i,
|
|
456
|
+
/tap-windows/i, // OpenVPN TAP
|
|
457
|
+
/^tap/i,
|
|
458
|
+
/^tun/i, // TUN VPN tunnels
|
|
459
|
+
/ppp/i,
|
|
460
|
+
/loopback/i, // Loopback Pseudo-Interface 1
|
|
461
|
+
/apipa/i, // automatic private IP (169.254.*.*)
|
|
462
|
+
/^utun/i, // macOS TUN
|
|
463
|
+
/^awdl/i, // macOS Apple Wireless Direct Link
|
|
464
|
+
/^llw/i, // macOS low-latency WLAN
|
|
465
|
+
/^bridge/i,
|
|
466
|
+
/bluetooth/i,
|
|
467
|
+
]
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Interface-name patterns that mark a *physical* NIC (Wi-Fi / Ethernet).
|
|
471
|
+
* Matches kept addresses are ordered before any unknown-but-surviving
|
|
472
|
+
* address so the phone-first address is the machine's real NIC.
|
|
473
|
+
*/
|
|
474
|
+
const PHYSICAL_IFACE_PATTERNS: RegExp[] = [
|
|
475
|
+
/^(wi-?fi|wlan|wireless)/i,
|
|
476
|
+
/^(eth(ernet)?|以太网|以太)/i,
|
|
477
|
+
/^(en|wan)[0-9]/i, // macOS en0 / en1
|
|
478
|
+
/^本地连接/i,
|
|
479
|
+
/^e[0-9]+$/i, // bare ethernet (linux)
|
|
480
|
+
/^w[0-9]+$/i, // bare wlan (linux)
|
|
481
|
+
]
|
|
482
|
+
|
|
483
|
+
interface LanCandidate {
|
|
484
|
+
address: string
|
|
485
|
+
physical: boolean
|
|
486
|
+
virtual: boolean
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The non-loopback IPv4 addresses of this machine (the LAN reachable URLs).
|
|
491
|
+
* Virtual NICs (VM/WSL/Docker/VPN/loopback pseudo) are filtered out; the
|
|
492
|
+
* remaining addresses are ordered with physical NICs (Wi-Fi/Ethernet) first
|
|
493
|
+
* so the phone shows the actually-reachable LAN address at the top.
|
|
494
|
+
* @returns the address list (possibly empty).
|
|
495
|
+
*/
|
|
496
|
+
function lanAddresses(): string[] {
|
|
497
|
+
const candidates: LanCandidate[] = []
|
|
498
|
+
for (const [name, ifaces] of Object.entries(networkInterfaces())) {
|
|
499
|
+
const virtual = VIRTUAL_IFACE_PATTERNS.some(re => re.test(name))
|
|
500
|
+
const physical = !virtual && PHYSICAL_IFACE_PATTERNS.some(re => re.test(name))
|
|
501
|
+
for (const iface of ifaces ?? []) {
|
|
502
|
+
if (iface.family !== 'IPv4' || iface.internal) continue
|
|
503
|
+
if (virtual) continue // drop virtual NICs entirely
|
|
504
|
+
candidates.push({ address: iface.address, physical, virtual: false })
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
candidates.sort((a, b) => Number(b.physical) - Number(a.physical))
|
|
508
|
+
return candidates.map(c => c.address)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Register the probe routes. Everything lives under `/multi/api` so the
|
|
513
|
+
* plugin is purely additive: exact `ports` (auto-discovery) and `status`
|
|
514
|
+
* (liveness of a specific port list).
|
|
515
|
+
* @param ctx - plugin context carrying the webServer service.
|
|
516
|
+
* @param config - validated {@link MultiWallConfig}.
|
|
517
|
+
*/
|
|
518
|
+
export function apply(ctx: Context, config: MultiWallConfig = {}): void {
|
|
519
|
+
const scanFrom = config.scanFrom ?? 3070
|
|
520
|
+
const scanTo = config.scanTo ?? 3110
|
|
521
|
+
const fixedPorts = config.ports ?? []
|
|
522
|
+
|
|
523
|
+
// Inline gateway state: lazily started on first `/multi/api/link` call and
|
|
524
|
+
// reused until the target port changes (or the instance restarts).
|
|
525
|
+
let gateway: GatewayHandle | null = null
|
|
526
|
+
let gatewayTargetPort = -1
|
|
527
|
+
|
|
528
|
+
ctx.effect(() => ctx.webServer.register({
|
|
529
|
+
kind: 'exact',
|
|
530
|
+
path: '/multi/api/ports',
|
|
531
|
+
handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
|
|
532
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
533
|
+
res.writeHead(405)
|
|
534
|
+
res.end()
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
538
|
+
const qFromRaw = url.searchParams.get('from')
|
|
539
|
+
const qToRaw = url.searchParams.get('to')
|
|
540
|
+
const qFrom = qFromRaw !== null ? Number(qFromRaw) : NaN
|
|
541
|
+
const qTo = qToRaw !== null ? Number(qToRaw) : NaN
|
|
542
|
+
const lo = Number.isInteger(qFrom) ? qFrom : scanFrom
|
|
543
|
+
const hi = Number.isInteger(qTo) ? qTo : scanTo
|
|
544
|
+
const ports = fixedPorts.length > 0 ? [...fixedPorts] : []
|
|
545
|
+
if (fixedPorts.length === 0) {
|
|
546
|
+
for (let p = lo; p <= hi; p++) ports.push(p)
|
|
547
|
+
}
|
|
548
|
+
// The serving instance is a discoverable target too: the user may want
|
|
549
|
+
// to watch (or stop) the very instance hosting the wall. Recursion is
|
|
550
|
+
// prevented client-side by the ?multi-wall=embed pane flag, not by
|
|
551
|
+
// hiding the self port.
|
|
552
|
+
probePorts(ports).then(results => {
|
|
553
|
+
json(res, { ports: results.filter(row => row.alive) })
|
|
554
|
+
}).catch(() => json(res, { ports: [] }, 500))
|
|
555
|
+
},
|
|
556
|
+
}), 'multi-wall: /multi/api/ports')
|
|
557
|
+
|
|
558
|
+
ctx.effect(() => ctx.webServer.register({
|
|
559
|
+
kind: 'exact',
|
|
560
|
+
path: '/multi/api/status',
|
|
561
|
+
handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
|
|
562
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
563
|
+
res.writeHead(405)
|
|
564
|
+
res.end()
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
568
|
+
const ports = (url.searchParams.get('ports') ?? '')
|
|
569
|
+
.split(',')
|
|
570
|
+
.map(Number)
|
|
571
|
+
.filter(p => Number.isInteger(p) && p > 0)
|
|
572
|
+
probePorts(ports).then(results => {
|
|
573
|
+
json(res, { ports: results })
|
|
574
|
+
}).catch(() => json(res, { ports: [] }, 500))
|
|
575
|
+
},
|
|
576
|
+
}), 'multi-wall: /multi/api/status')
|
|
577
|
+
|
|
578
|
+
// Terminate the DSH instance on a specific port (closes that session).
|
|
579
|
+
// GET /multi/api/stop?port=3080 or ?ports=3080,3081
|
|
580
|
+
ctx.effect(() => ctx.webServer.register({
|
|
581
|
+
kind: 'exact',
|
|
582
|
+
path: '/multi/api/stop',
|
|
583
|
+
handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
|
|
584
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
585
|
+
res.writeHead(405)
|
|
586
|
+
res.end()
|
|
587
|
+
return
|
|
588
|
+
}
|
|
589
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
590
|
+
const raw = url.searchParams.get('ports') ?? url.searchParams.get('port') ?? ''
|
|
591
|
+
const ports = raw.split(',').map(Number).filter(p => Number.isInteger(p) && p > 0)
|
|
592
|
+
const selfPort = ctx.webServer.port
|
|
593
|
+
Promise.all(ports.map(port => stopPort(port, selfPort))).then(results => {
|
|
594
|
+
json(res, { ports: results })
|
|
595
|
+
}).catch(() => json(res, { ports: [] }, 500))
|
|
596
|
+
},
|
|
597
|
+
}), 'multi-wall: /multi/api/stop')
|
|
598
|
+
|
|
599
|
+
// Start a NEW DSH instance and return its port, so the wall can grow a
|
|
600
|
+
// fresh window without leaving the page. Spawns `dsh web` on the first
|
|
601
|
+
// free port of the scan range (never the serving port). The response
|
|
602
|
+
// returns as soon as a port is allocated (a couple seconds max) instead of
|
|
603
|
+
// blocking on the new instance's readiness — the wall's own liveness poll
|
|
604
|
+
// confirms the server when it finishes booting, and a spawn failure is
|
|
605
|
+
// surfaced immediately as ok:false with a concrete reason.
|
|
606
|
+
// POST /multi/api/create (GET also accepted for convenience)
|
|
607
|
+
ctx.effect(() => ctx.webServer.register({
|
|
608
|
+
kind: 'exact',
|
|
609
|
+
path: '/multi/api/create',
|
|
610
|
+
handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
|
|
611
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
612
|
+
res.writeHead(405)
|
|
613
|
+
res.end()
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
const launcher = resolveLauncher()
|
|
617
|
+
const selfPort = ctx.webServer.port
|
|
618
|
+
void pickFreePort(scanFrom, scanTo, selfPort).then(port => {
|
|
619
|
+
if (port === undefined) {
|
|
620
|
+
json(res, { ok: false, error: `no free port in ${scanFrom}–${scanTo}` }, 409)
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
return startInstance(launcher, port).then(result => {
|
|
624
|
+
json(res, result.ok ? { ok: true, port } : { ok: false, error: result.error }, result.ok ? 200 : 500)
|
|
625
|
+
if (!result.ok) ctx.logger.warn(`multi-wall create failed: ${result.error}`)
|
|
626
|
+
})
|
|
627
|
+
}).catch((error: unknown) => {
|
|
628
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
629
|
+
ctx.logger.warn(`multi-wall create error: ${message}`)
|
|
630
|
+
json(res, { ok: false, error: message }, 500)
|
|
631
|
+
})
|
|
632
|
+
},
|
|
633
|
+
}), 'multi-wall: /multi/api/create')
|
|
634
|
+
|
|
635
|
+
// The phone-reachable URL for this instance. The official CLI forbids
|
|
636
|
+
// `--host 0.0.0.0` (it would expose remote code execution), so a loopback
|
|
637
|
+
// instance is reached from a phone through an auth-gated gateway. When
|
|
638
|
+
// `publicUrl` is configured, that URL is reported verbatim. Otherwise this
|
|
639
|
+
// route lazily starts the inline gateway (target 127.0.0.1:<selfPort>) and
|
|
640
|
+
// answers with the LAN URLs plus the generated/fixed login token.
|
|
641
|
+
// GET /multi/api/link
|
|
642
|
+
ctx.effect(() => ctx.webServer.register({
|
|
643
|
+
kind: 'exact',
|
|
644
|
+
path: '/multi/api/link',
|
|
645
|
+
handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
|
|
646
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
647
|
+
res.writeHead(405)
|
|
648
|
+
res.end()
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
const port = ctx.webServer.port
|
|
652
|
+
const host = ctx.webServer.host
|
|
653
|
+
const publicUrl = (config.publicUrl ?? '').replace(/\/+$/, '')
|
|
654
|
+
if (publicUrl !== '') {
|
|
655
|
+
json(res, { port, host, lan: [`${publicUrl}/`], reachable: true })
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Ensure the inline gateway targets THIS instance's port.
|
|
660
|
+
const ensureGateway = (): Promise<GatewayHandle> => {
|
|
661
|
+
if (gateway !== null && gatewayTargetPort === port) {
|
|
662
|
+
return Promise.resolve(gateway)
|
|
663
|
+
}
|
|
664
|
+
// Target changed (or first start): close the stale gateway first.
|
|
665
|
+
if (gateway !== null) {
|
|
666
|
+
gateway.close()
|
|
667
|
+
gateway = null
|
|
668
|
+
}
|
|
669
|
+
const token = config.gatewayToken && config.gatewayToken !== '' ? config.gatewayToken : randomBytes(6).toString('hex')
|
|
670
|
+
const gatewayPort = config.gatewayPort && config.gatewayPort !== 0 ? config.gatewayPort : port + 5000
|
|
671
|
+
gatewayTargetPort = port
|
|
672
|
+
// Allow the gateway's `/gw/<port>` route only for ports a DSH instance
|
|
673
|
+
// can actually live on: the scan range plus any fixed ports.
|
|
674
|
+
const routed: number[] = []
|
|
675
|
+
for (let p = scanFrom; p <= scanTo; p++) routed.push(p)
|
|
676
|
+
for (const p of fixedPorts) if (!routed.includes(p)) routed.push(p)
|
|
677
|
+
return startGateway({
|
|
678
|
+
targetPort: port,
|
|
679
|
+
port: gatewayPort,
|
|
680
|
+
token,
|
|
681
|
+
name: 'DSH',
|
|
682
|
+
routedPorts: routed,
|
|
683
|
+
log: (msg) => ctx.logger.info(`multi-wall gateway: ${msg}`),
|
|
684
|
+
}).then(handle => {
|
|
685
|
+
gateway = handle
|
|
686
|
+
return handle
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
ensureGateway().then(handle => {
|
|
691
|
+
const urls = lanAddresses().map(ip => `http://${ip}:${handle.port}/`)
|
|
692
|
+
json(res, {
|
|
693
|
+
port,
|
|
694
|
+
host,
|
|
695
|
+
lan: urls,
|
|
696
|
+
gatewayPort: handle.port,
|
|
697
|
+
token: handle.token,
|
|
698
|
+
reachable: urls.length > 0,
|
|
699
|
+
hint: urls.length === 0
|
|
700
|
+
? 'no LAN address detected; connect this machine to a network first'
|
|
701
|
+
: undefined,
|
|
702
|
+
})
|
|
703
|
+
}).catch((error: unknown) => {
|
|
704
|
+
ctx.logger.warn(`multi-wall gateway start failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
705
|
+
json(res, {
|
|
706
|
+
port,
|
|
707
|
+
host,
|
|
708
|
+
lan: [],
|
|
709
|
+
reachable: false,
|
|
710
|
+
hint: error instanceof Error ? error.message : String(error),
|
|
711
|
+
}, 500)
|
|
712
|
+
})
|
|
713
|
+
},
|
|
714
|
+
}), 'multi-wall: /multi/api/link')
|
|
715
|
+
}
|