@skillpie/dsh-remote 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)