@skillpie/dsh-remote 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,475 @@
1
+ /**
2
+ * dsh-remote — Cordis entry (both roles in one activation).
3
+ *
4
+ * Host role (default on): dials the configured relay origin (e.g.
5
+ * wss://dsh.skillpie.cn) over one outbound WebSocket and registers this
6
+ * machine's 8-digit code. Visitors opening `https://<relay>/<code>/` use
7
+ * this machine's real DSH Web GUI through the tunnel (`mode: 'gui'`,
8
+ * default) or a lightweight read/control console (`mode: 'console'`).
9
+ * With `direct: true` the same surface also mounts locally at `/<code>`
10
+ * (LAN / loopback use and end-to-end testing without a relay).
11
+ *
12
+ * Relay role (`relay.enabled`, for a server-side profile such as
13
+ * dsh.skillpie.cn): mounts `/dsh-remote/host` for host registrations and
14
+ * serves `/<code>/` for registered hosts. The registry keeps token hashes
15
+ * only; GUI traffic rides spliced frames the relay never interprets.
16
+ *
17
+ * Identity (code / hostId / token) persists as a credential record
18
+ * `dsh-remote/identity` (GrantRecord payload); an explicit `code` in the
19
+ * row config pins it instead.
20
+ *
21
+ * Owner panel: same-origin JSON API at /dsh-remote/api behind the web GUI's
22
+ * own session auth (status / regenerate / toggles), consumed by the client
23
+ * half's sidebar panel. Host roles only — a relay-only server row mounts no
24
+ * owner API.
25
+ */
26
+ import os from 'node:os'
27
+ import { HOST_UPGRADE_PATH, OWNER_API_PATH, PROTOCOL_VERSION, generateCode, generateId, isValidCode } from './protocol.js'
28
+ import { HostEngine } from './engine.js'
29
+ import { GuiBridge } from './bridge.js'
30
+ import { RelayLink } from './remote.js'
31
+ import { RelayCore } from './relay-core.js'
32
+ import { consoleHtml } from './console.js'
33
+ import { createPipePair } from './pipe.js'
34
+
35
+ const DEFAULT_RELAY_URL = 'wss://dsh.skillpie.cn'
36
+ const CREDENTIAL_KEY = 'dsh-remote/identity'
37
+ const MAX_BODY = 32 * 1024
38
+
39
+ /* ── config ──────────────────────────────────────────────────────────── */
40
+
41
+ /** One issue in the Standard Schema config vocabulary. */
42
+ const issue = (message, ...path) => ({ message, path: path.length ? path : undefined })
43
+
44
+ function bool(value, fallback) {
45
+ if (value === undefined || value === null) return fallback
46
+ if (typeof value === 'boolean') return value
47
+ return null
48
+ }
49
+
50
+ export const Config = {
51
+ '~standard': {
52
+ version: 1,
53
+ vendor: 'dsh-remote',
54
+ validate(input) {
55
+ if (input === undefined || input === null) return { value: {} }
56
+ if (typeof input !== 'object' || Array.isArray(input)) return { issues: [issue('expected an object')] }
57
+ const issues = []
58
+ const value = {}
59
+ const bools = [
60
+ ['enabled', true],
61
+ ['allowControl', true],
62
+ ['direct', true],
63
+ ]
64
+ for (const [key, fallback] of bools) {
65
+ const parsed = bool(input[key], fallback)
66
+ if (parsed === null) issues.push(issue('expected a boolean', key))
67
+ else value[key] = parsed
68
+ }
69
+ if (input.mode !== undefined) {
70
+ if (input.mode !== 'gui' && input.mode !== 'console') {
71
+ issues.push(issue("expected 'gui' or 'console'", 'mode'))
72
+ } else value.mode = input.mode
73
+ }
74
+ if (input.relayUrl !== undefined) {
75
+ if (typeof input.relayUrl !== 'string' || !input.relayUrl.trim()) {
76
+ issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
77
+ } else {
78
+ try {
79
+ const url = new URL(input.relayUrl.trim())
80
+ if (!['ws:', 'wss:', 'http:', 'https:'].includes(url.protocol) || (url.pathname && url.pathname !== '/')) {
81
+ issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
82
+ } else {
83
+ value.relayUrl = url.origin
84
+ }
85
+ } catch {
86
+ issues.push(issue('expected a ws(s):// or http(s):// origin', 'relayUrl'))
87
+ }
88
+ }
89
+ }
90
+ for (const key of ['name', 'hostId', 'token']) {
91
+ const raw = input[key]
92
+ if (raw === undefined) continue
93
+ if (typeof raw !== 'string' || !raw.trim()) {
94
+ issues.push(issue('expected a non-empty string', key))
95
+ continue
96
+ }
97
+ value[key] = raw.trim()
98
+ }
99
+ if (input.code !== undefined) {
100
+ if (!isValidCode(input.code)) issues.push(issue('expected an 8-digit code (no leading zero)', 'code'))
101
+ else value.code = input.code
102
+ }
103
+ if (input.relay !== undefined) {
104
+ if (typeof input.relay !== 'object' || input.relay === null || Array.isArray(input.relay)) {
105
+ issues.push(issue('expected an object', 'relay'))
106
+ } else {
107
+ const relay = {}
108
+ const relayEnabled = bool(input.relay.enabled, false)
109
+ if (relayEnabled === null) issues.push(issue('expected a boolean', 'relay', 'enabled'))
110
+ else relay.enabled = relayEnabled
111
+ if (input.relay.mode !== undefined) {
112
+ if (input.relay.mode !== 'gui' && input.relay.mode !== 'console') {
113
+ issues.push(issue("expected 'gui' or 'console'", 'relay', 'mode'))
114
+ } else relay.mode = input.relay.mode
115
+ }
116
+ if (input.relay.maxVisitorsPerHost !== undefined) {
117
+ const n = input.relay.maxVisitorsPerHost
118
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 64) {
119
+ issues.push(issue('expected an integer between 1 and 64', 'relay', 'maxVisitorsPerHost'))
120
+ } else relay.maxVisitorsPerHost = n
121
+ }
122
+ value.relay = relay
123
+ }
124
+ }
125
+ return issues.length ? { issues } : { value }
126
+ },
127
+ },
128
+ }
129
+
130
+ /* ── apply ───────────────────────────────────────────────────────────── */
131
+
132
+ export default {
133
+ name: '@skillpie/dsh-remote',
134
+ Config,
135
+ inject: [],
136
+ apply(ctx, rawConfig = null) {
137
+ const cfg = rawConfig ?? {}
138
+ const enabled = cfg.enabled ?? true
139
+ const allowControl = cfg.allowControl ?? true
140
+ const direct = cfg.direct ?? true
141
+ const mode = cfg.mode === 'console' ? 'console' : 'gui'
142
+ const relayUrl = cfg.relayUrl ?? DEFAULT_RELAY_URL
143
+ const relayFaceEnabled = cfg.relay?.enabled === true
144
+ const relayMode = cfg.relay?.mode === 'console' ? 'console' : 'gui'
145
+ const maxVisitorsPerHost = cfg.relay?.maxVisitorsPerHost ?? 8
146
+
147
+ const logRing = []
148
+ const logLine = (kind, text) => {
149
+ logRing.push({ time: Date.now(), kind, text })
150
+ if (logRing.length > 200) logRing.shift()
151
+ }
152
+
153
+ // Provisional identity; boot() may replace it with the stored record
154
+ // before any route or connection mounts.
155
+ const identity = {
156
+ code: isValidCode(cfg.code) ? cfg.code : generateCode(),
157
+ hostId: typeof cfg.hostId === 'string' && cfg.hostId ? cfg.hostId : generateId(),
158
+ token: typeof cfg.token === 'string' ? cfg.token : '',
159
+ name: cfg.name ?? os.hostname(),
160
+ dsh: `node ${process.version}`,
161
+ }
162
+
163
+ // Everything boot() builds registers its disposer here; the effect is
164
+ // registered synchronously so teardown is guaranteed even when boot has
165
+ // not finished.
166
+ const state = { peer: undefined, link: undefined, directCore: undefined, relayCore: undefined }
167
+ const disposers = []
168
+ ctx.effect(() => () => {
169
+ state.link?.stop()
170
+ if (mode === 'gui') state.peer?.detach?.()
171
+ else state.peer?.close?.()
172
+ for (const dispose of disposers.splice(0)) {
173
+ try { dispose() } catch { /* best-effort teardown */ }
174
+ }
175
+ logLine('boot', 'dsh-remote 已卸载')
176
+ }, 'dsh-remote: lifetime')
177
+
178
+ // Services arrive through optional injection; boot() starts the parts
179
+ // that need them as they appear.
180
+ const services = { sessionController: undefined, webServer: undefined, webServerOrigin: undefined }
181
+ let webServerWaiter = undefined
182
+ const whenWebServer = () => services.webServer
183
+ ? Promise.resolve()
184
+ : new Promise((resolve) => { webServerWaiter = resolve })
185
+
186
+ ctx.inject(['sessionController'], (child) => {
187
+ services.sessionController = child.sessionController
188
+ })
189
+ ctx.inject(['webServer'], (child) => {
190
+ services.webServer = child.webServer
191
+ try { services.webServerOrigin = child.webServer.getOrigin() } catch { services.webServerOrigin = undefined }
192
+ if (webServerWaiter) { const resolve = webServerWaiter; webServerWaiter = undefined; resolve() }
193
+ })
194
+
195
+ void boot().catch((error) => {
196
+ logLine('boot', `启动失败: ${error?.message ?? error}`)
197
+ })
198
+
199
+ async function boot() {
200
+ // 1. Adopt the stored identity unless the row config pins one.
201
+ const credentials = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
202
+ let stored = undefined
203
+ if (credentials) {
204
+ try {
205
+ const record = await credentials.readRecord(CREDENTIAL_KEY)
206
+ if (record?.kind === 'grant' && record.payload && typeof record.payload === 'object') {
207
+ stored = record.payload
208
+ }
209
+ } catch (error) {
210
+ logLine('identity', `凭据读取失败: ${error?.message ?? error}`)
211
+ }
212
+ if (stored && !cfg.code) {
213
+ if (isValidCode(stored.code)) identity.code = stored.code
214
+ if (typeof stored.hostId === 'string' && stored.hostId) identity.hostId = stored.hostId
215
+ if (typeof stored.token === 'string') identity.token = stored.token
216
+ }
217
+ if (!cfg.code || !cfg.hostId) {
218
+ // Persist the (possibly fresh) identity; no restart involved.
219
+ try {
220
+ await saveIdentity(credentials, { code: identity.code, hostId: identity.hostId, token: identity.token })
221
+ } catch (error) {
222
+ logLine('identity', `身份持久化失败(重启后会再次生成): ${error?.message ?? error}`)
223
+ }
224
+ }
225
+ } else {
226
+ logLine('identity', '凭据服务不可用,身份仅本次会话有效')
227
+ }
228
+
229
+ // 2. The web surface carries everything below.
230
+ await whenWebServer()
231
+ if (!services.webServer) return
232
+ const webServer = services.webServer
233
+
234
+ // 3. Build the visitor-facing peer: the GUI bridge (gui mode) or the
235
+ // lightweight command engine (console mode).
236
+ let peer
237
+ if (mode === 'gui') {
238
+ peer = new GuiBridge({
239
+ services: {
240
+ webServer: () => services.webServer,
241
+ connection: () => (typeof ctx.get === 'function' ? ctx.get('connection') : undefined),
242
+ },
243
+ log: logLine,
244
+ })
245
+ } else {
246
+ const engine = new HostEngine({
247
+ services: { sessionController: () => services.sessionController },
248
+ host: { get name() { return identity.name }, get dsh() { return identity.dsh }, get code() { return identity.code } },
249
+ log: logLine,
250
+ })
251
+ engine.allowControl = allowControl
252
+ disposers.push(engine.observeSessionEvents((name, handler) => ctx.on(name, handler)))
253
+ peer = engine
254
+ }
255
+ state.peer = peer
256
+
257
+ // 4. Outbound relay link.
258
+ if (enabled) {
259
+ const link = new RelayLink({
260
+ relayUrl,
261
+ identity,
262
+ peer,
263
+ log: logLine,
264
+ onToken: (token) => {
265
+ identity.token = token
266
+ const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
267
+ if (creds) saveIdentity(creds, { token }).catch(() => {})
268
+ },
269
+ })
270
+ state.link = link
271
+ link.start()
272
+ logLine('boot', `dsh-remote 已加载(远程码 ${identity.code},模式 ${mode})`)
273
+ } else {
274
+ logLine('boot', `dsh-remote 已加载(远程码 ${identity.code},远程访问已关闭)`)
275
+ }
276
+
277
+ // 5. Owner panel API (same-origin, behind the GUI session auth). Host
278
+ // roles only — a relay-only server row mounts no owner API.
279
+ if (enabled || direct) {
280
+ disposers.push(webServer.register({
281
+ kind: 'exact',
282
+ path: OWNER_API_PATH,
283
+ handler: (req, res) => { void ownerApi(req, res) },
284
+ }))
285
+ logLine('owner-api', `属主 API 已挂载(${OWNER_API_PATH})`)
286
+ }
287
+
288
+ // 6. Direct mode: the same surface on the local web server.
289
+ if (direct) {
290
+ const [hostEnd, relayEnd] = createPipePair()
291
+ const directCore = new RelayCore({
292
+ webServer,
293
+ consoleHtml,
294
+ log: logLine,
295
+ maxVisitorsPerHost,
296
+ mode,
297
+ })
298
+ directCore.acceptHostSocket(relayEnd)
299
+ // The pipe replaces RelayLink's transport, so it speaks the same
300
+ // registration handshake before the peer attaches.
301
+ hostEnd.send({
302
+ t: 'hello',
303
+ v: PROTOCOL_VERSION,
304
+ hostId: identity.hostId,
305
+ code: identity.code,
306
+ token: identity.token ?? '',
307
+ name: identity.name,
308
+ dsh: identity.dsh,
309
+ })
310
+ peer.attach(hostEnd)
311
+ state.directCore = directCore
312
+ disposers.push(() => {
313
+ directCore.detachAll()
314
+ hostEnd.close()
315
+ })
316
+ logLine('direct', `本机直连路由已挂载(/${identity.code}/,模式 ${mode})`)
317
+ }
318
+
319
+ // 7. Relay face (server deployments only).
320
+ if (relayFaceEnabled) {
321
+ const relayCore = new RelayCore({
322
+ webServer,
323
+ consoleHtml,
324
+ log: logLine,
325
+ maxVisitorsPerHost,
326
+ mode: relayMode,
327
+ })
328
+ state.relayCore = relayCore
329
+ disposers.push(webServer.registerUpgrade({
330
+ path: HOST_UPGRADE_PATH,
331
+ handler: (req, socket, head) => relayCore.acceptHostUpgrade(req, socket, head),
332
+ }))
333
+ disposers.push(() => relayCore.detachAll())
334
+ logLine('relay-face', `中转面已挂载(${HOST_UPGRADE_PATH},模式 ${relayMode})`)
335
+ }
336
+ }
337
+
338
+ async function ownerApi(req, res) {
339
+ const send = (status, payload) => {
340
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
341
+ res.end(JSON.stringify(payload))
342
+ }
343
+ const origin = req.headers?.origin
344
+ if (typeof origin === 'string' && origin !== '') {
345
+ let sameOrigin = false
346
+ try { sameOrigin = new URL(origin).host === req.headers.host } catch { sameOrigin = false }
347
+ if (!sameOrigin) return send(403, { ok: false, error: 'cross-origin request refused' })
348
+ }
349
+ let body = {}
350
+ if (req.method === 'POST') {
351
+ body = await readBody(req).catch(() => null)
352
+ if (body === null) return send(200, { ok: false, error: 'invalid JSON body' })
353
+ } else if (req.method !== 'GET') {
354
+ return send(200, { ok: true, service: 'dsh-remote', protocol: PROTOCOL_VERSION })
355
+ }
356
+ try {
357
+ send(200, await handleOwnerAction(body))
358
+ } catch (error) {
359
+ send(200, { ok: false, error: error?.message ?? String(error) })
360
+ }
361
+ }
362
+
363
+ async function handleOwnerAction(body) {
364
+ const action = typeof body?.action === 'string' ? body.action : 'status'
365
+ switch (action) {
366
+ case 'status':
367
+ return { ok: true, status: statusSnapshot() }
368
+ case 'regenerate': {
369
+ const code = generateCode()
370
+ identity.code = code
371
+ identity.token = ''
372
+ const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
373
+ if (creds) await saveIdentity(creds, { code, hostId: identity.hostId, token: '' }).catch(() => {})
374
+ logLine('owner', `属主重新生成远程码 → ${code}`)
375
+ return { ok: true, code }
376
+ }
377
+ case 'set-enabled': {
378
+ const next = body.value === true
379
+ if (next && !state.link) {
380
+ const link = new RelayLink({
381
+ relayUrl,
382
+ identity,
383
+ peer: state.peer,
384
+ log: logLine,
385
+ onToken: (token) => {
386
+ identity.token = token
387
+ const creds = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined
388
+ if (creds) saveIdentity(creds, { token }).catch(() => {})
389
+ },
390
+ })
391
+ state.link = link
392
+ link.start()
393
+ } else if (!next && state.link) {
394
+ state.link.stop()
395
+ state.link = undefined
396
+ }
397
+ logLine('owner', `属主${next ? '开启' : '关闭'}远程控制`)
398
+ return { ok: true, enabled: next }
399
+ }
400
+ case 'set-control': {
401
+ const next = body.value === true
402
+ if (state.peer?.allowControl !== undefined) {
403
+ state.peer.allowControl = next
404
+ broadcastHostUpdate()
405
+ }
406
+ logLine('owner', `属主${next ? '允许' : '禁止'}访客控制会话`)
407
+ return { ok: true, allowControl: next }
408
+ }
409
+ default:
410
+ throw new Error(`unknown action: ${action}`)
411
+ }
412
+ }
413
+
414
+ function broadcastHostUpdate() {
415
+ const peer = state.peer
416
+ if (!peer?.visitors) return
417
+ const frame = { t: 'host-updated', host: { name: identity.name, dsh: identity.dsh, allowControl: peer.allowControl } }
418
+ for (const visitorId of peer.visitors.keys()) peer.pushTo(visitorId, frame)
419
+ }
420
+
421
+ function statusSnapshot() {
422
+ const link = state.link
423
+ return {
424
+ protocol: PROTOCOL_VERSION,
425
+ mode,
426
+ enabled: Boolean(link),
427
+ allowControl: state.peer?.allowControl ?? allowControl,
428
+ direct,
429
+ relayUrl,
430
+ name: identity.name,
431
+ hostId: identity.hostId,
432
+ code: identity.code,
433
+ relay: link ? link.status() : { connected: false, relayUrl },
434
+ directUrl: direct && services.webServerOrigin ? `${services.webServerOrigin}/${identity.code}/` : undefined,
435
+ relayUrlPublic: relayUrl.replace(/^ws/, 'http') + '/' + identity.code + '/',
436
+ visitors: state.peer?.status ? state.peer.status().visitors : [],
437
+ relayFace: relayFaceEnabled ? (state.relayCore?.status() ?? { hosts: [] }) : undefined,
438
+ log: logRing.slice(-80),
439
+ }
440
+ }
441
+ },
442
+ }
443
+
444
+ /* ── helpers ─────────────────────────────────────────────────────────── */
445
+
446
+ async function saveIdentity(credentials, patch) {
447
+ await credentials.modifyRecord(CREDENTIAL_KEY, (current) => {
448
+ const payload = current?.kind === 'grant' && current.payload && typeof current.payload === 'object'
449
+ ? { ...current.payload }
450
+ : {}
451
+ return { kind: 'grant', payload: { ...payload, ...patch } }
452
+ })
453
+ }
454
+
455
+ function readBody(req) {
456
+ return new Promise((resolvePromise, rejectPromise) => {
457
+ let data = ''
458
+ req.on('data', (chunk) => {
459
+ data += chunk
460
+ if (data.length > MAX_BODY) {
461
+ req.destroy()
462
+ rejectPromise(new Error('request body too large'))
463
+ }
464
+ })
465
+ req.on('end', () => {
466
+ if (!data.trim()) return resolvePromise({})
467
+ try {
468
+ resolvePromise(JSON.parse(data))
469
+ } catch {
470
+ rejectPromise(new Error('invalid JSON body'))
471
+ }
472
+ })
473
+ req.on('error', rejectPromise)
474
+ })
475
+ }
package/lib/pipe.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * dsh-remote — in-process ws-like pipe pair.
3
+ *
4
+ * Direct mode reuses the exact relay code path on the local web server: the
5
+ * engine or GUI bridge speaks the same frames to one end of a pipe while a
6
+ * RelayCore instance serves the other end. JSON text frames cross
7
+ * serialization (so framing bugs show up locally too); binary splice frames
8
+ * pass through by reference.
9
+ *
10
+ * Both ends expose the subset of the `ws` surface the plugin uses:
11
+ * send(obj) JSON text frame
12
+ * sendRaw(buffer) binary passthrough
13
+ * onMessage(cb) cb(text | Buffer)
14
+ * onClose(cb), close()
15
+ */
16
+ function makeEnd(getPeer) {
17
+ const end = {
18
+ open: true,
19
+ messageHandlers: new Set(),
20
+ closeHandlers: new Set(),
21
+ send(obj) {
22
+ if (!end.open) return
23
+ const text = JSON.stringify(obj)
24
+ const peer = getPeer()
25
+ // Deliver on a microtask so neither side re-enters synchronously.
26
+ queueMicrotask(() => {
27
+ if (!end.open || !peer || !peer.open) return
28
+ for (const handler of peer.messageHandlers) {
29
+ try { handler(text) } catch { /* contained */ }
30
+ }
31
+ })
32
+ },
33
+ sendRaw(buffer) {
34
+ if (!end.open) return
35
+ const peer = getPeer()
36
+ queueMicrotask(() => {
37
+ if (!end.open || !peer || !peer.open) return
38
+ for (const handler of peer.messageHandlers) {
39
+ try { handler(buffer) } catch { /* contained */ }
40
+ }
41
+ })
42
+ },
43
+ sendText(text) {
44
+ if (!end.open) return
45
+ const peer = getPeer()
46
+ queueMicrotask(() => {
47
+ if (!end.open || !peer || !peer.open) return
48
+ for (const handler of peer.messageHandlers) {
49
+ try { handler(text) } catch { /* contained */ }
50
+ }
51
+ })
52
+ },
53
+ onMessage(handler) {
54
+ end.messageHandlers.add(handler)
55
+ return () => end.messageHandlers.delete(handler)
56
+ },
57
+ onClose(handler) {
58
+ end.closeHandlers.add(handler)
59
+ return () => end.closeHandlers.delete(handler)
60
+ },
61
+ close() {
62
+ if (!end.open) return
63
+ end.open = false
64
+ const handlers = [...end.closeHandlers]
65
+ end.messageHandlers.clear()
66
+ end.closeHandlers.clear()
67
+ for (const handler of handlers) handler()
68
+ const peer = getPeer()
69
+ if (peer && peer.open) peer.close()
70
+ },
71
+ }
72
+ return end
73
+ }
74
+
75
+ export function createPipePair() {
76
+ let peerOfA
77
+ let peerOfB
78
+ const a = makeEnd(() => peerOfA)
79
+ const b = makeEnd(() => peerOfB)
80
+ peerOfA = b
81
+ peerOfB = a
82
+ return [a, b]
83
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * dsh-remote — shared wire constants and small helpers.
3
+ *
4
+ * Control channel (relay ↔ host, JSON text frames over one WebSocket):
5
+ * host → relay { t:'hello', v, hostId, code, token, name, dsh }
6
+ * { t:'hb' } heartbeat, every 15s
7
+ * { t:'res', id, ok, data?, error? } command receipts
8
+ * { t:'push', to, f } event push addressed to one visitor
9
+ * relay → host { t:'hello', v, ok, token?, error? } ack; token minted on first registration
10
+ * { t:'hb' } heartbeat echo
11
+ * { t:'cmd', id, from, cmd, p? } visitor command (from = visitor id)
12
+ *
13
+ * Visitor channel (relay ↔ browser, JSON text frames over /<code>/ws):
14
+ * relay → visitor { t:'welcome', v, visitor, host, sessions }
15
+ * { t:'res', id, ok, data?, error? }
16
+ * { t:'push', f }
17
+ * visitor → relay { t:'cmd', id, cmd, p? }
18
+ *
19
+ * Visitor command set lives in VISITOR_COMMANDS; 'visitor.hello' and
20
+ * 'visitor.gone' are relay-internal and never offered to the console.
21
+ */
22
+ import { randomInt, randomUUID, createHash } from 'node:crypto'
23
+
24
+ export const PROTOCOL_VERSION = 1
25
+
26
+ /** Exact upgrade path where hosts dial the relay. */
27
+ export const HOST_UPGRADE_PATH = '/dsh-remote/host'
28
+
29
+ /** Owner panel JSON API, same-origin and behind the web GUI's session auth. */
30
+ export const OWNER_API_PATH = '/dsh-remote/api'
31
+
32
+ /** Commands the visitor console may issue. */
33
+ export const VISITOR_COMMANDS = new Set([
34
+ 'host.info',
35
+ 'sessions.list',
36
+ 'sessions.state',
37
+ 'sessions.create',
38
+ 'sessions.send',
39
+ 'sessions.cancel',
40
+ 'sessions.observe',
41
+ 'sessions.unobserve',
42
+ ])
43
+
44
+ /** Relay-internal commands bridged to the engine, invisible to the console. */
45
+ export const INTERNAL_COMMANDS = new Set(['visitor.hello', 'visitor.gone'])
46
+
47
+ /** An 8-digit code: 10000000..99999999, no leading zero. */
48
+ export function generateCode() {
49
+ return String(randomInt(10_000_000, 100_000_000))
50
+ }
51
+
52
+ export function isValidCode(code) {
53
+ return typeof code === 'string' && /^[1-9]\d{7}$/.test(code)
54
+ }
55
+
56
+ export function generateId() {
57
+ return randomUUID()
58
+ }
59
+
60
+ /** Relay-side only: hosts carry plaintext tokens, the registry stores hashes. */
61
+ export function hashToken(token) {
62
+ return createHash('sha256').update(String(token)).digest('hex')
63
+ }
64
+
65
+ /** Safe JSON frame parse with a hard byte cap. */
66
+ export function parseFrame(text, maxBytes = 64 * 1024) {
67
+ if (typeof text !== 'string' || text.length > maxBytes) return null
68
+ try {
69
+ const frame = JSON.parse(text)
70
+ return frame && typeof frame === 'object' ? frame : null
71
+ } catch {
72
+ return null
73
+ }
74
+ }
75
+
76
+ export function nowSeconds() {
77
+ return Math.floor(Date.now() / 1000)
78
+ }
79
+
80
+ /* ── GUI splice mode ────────────────────────────────────────────────────
81
+ *
82
+ * In `gui` mode the relay transparently mounts the host's real web GUI
83
+ * under `/<code>/`: HTTP requests and the GUI's single mux WebSocket
84
+ * (`/api/remote.mux`) are bridged frame-by-frame, the host side serving
85
+ * them from its own web server over loopback with an automatically minted
86
+ * owner session cookie.
87
+ *
88
+ * Frames (JSON text unless noted):
89
+ * relay → host { t:'hreq', id, m, p, h } begin HTTP proxy request
90
+ * { t:'hend', id } request body complete
91
+ * { t:'wopen', id, p, h } open spliced WebSocket
92
+ * { t:'wclose', id, code, reason }
93
+ * host → relay { t:'hres', id, s, h, sc } status, headers, set-cookie[]
94
+ * { t:'hfail', id, error }
95
+ * { t:'wok', id } / { t:'wfail', id, error }
96
+ * { t:'wclose', id, code, reason }
97
+ * both { t:'hchunk', id } (binary) request/response body chunk
98
+ * { t:'wdata', id } (binary) spliced socket bytes
99
+ *
100
+ * Binary envelope: `JSON(meta) 0x00 payload` — JSON never contains a raw
101
+ * NUL, so the first 0x00 byte separates header from payload.
102
+ */
103
+
104
+ export function encodeBin(meta, payload) {
105
+ const header = Buffer.from(JSON.stringify(meta), 'utf8')
106
+ const body = payload === undefined ? EMPTY : Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
107
+ return Buffer.concat([header, SEPARATOR, body])
108
+ }
109
+
110
+ export function decodeBin(buffer) {
111
+ const at = buffer.indexOf(0)
112
+ if (at === -1) return undefined
113
+ try {
114
+ const meta = JSON.parse(buffer.slice(0, at).toString('utf8'))
115
+ return meta && typeof meta === 'object' ? { meta, payload: buffer.slice(at + 1) } : undefined
116
+ } catch {
117
+ return undefined
118
+ }
119
+ }
120
+
121
+ const SEPARATOR = Buffer.from([0])
122
+ const EMPTY = Buffer.alloc(0)