dsh-remote-control-plugin 0.0.0-stage → 0.3.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,189 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ /**
4
+ * Rendezvous relay core.
5
+ *
6
+ * Mountable in two ways: standalone (`relay/server.js`) or inside a DSH Host
7
+ * (the Devices panel's "Create relay"), which is why it exposes `handle`
8
+ * instead of owning a socket. The protocol is plain HTTP long-poll so it works
9
+ * through any proxy and needs no dependency.
10
+ *
11
+ * Groups are keyed by sha256(workspaceKey), computed by the client, so the
12
+ * relay never sees the workspace key. Peer control stays end-to-end signed by
13
+ * the devices themselves; this process only routes.
14
+ */
15
+ export function createRelay({ log = console } = {}) {
16
+ const devices = new Map() // deviceId -> record
17
+ const tokens = new Map() // token -> deviceId
18
+
19
+ const DEVICE_TTL_MS = 60_000
20
+ const MAX_QUEUE = 200
21
+ const MAX_BODY_BYTES = 2 * 1024 * 1024
22
+ const MAX_WAIT_SECONDS = 30
23
+
24
+ const reaper = setInterval(() => {
25
+ const now = Date.now()
26
+ for (const [deviceId, record] of devices) {
27
+ if (now - record.lastSeen <= DEVICE_TTL_MS) continue
28
+ devices.delete(deviceId)
29
+ tokens.delete(record.token)
30
+ }
31
+ }, 15_000)
32
+ reaper.unref?.()
33
+
34
+ async function handle(req, res) {
35
+ const url = new URL(req.url ?? '/', 'http://relay')
36
+ if (req.method === 'GET' && url.pathname === '/health') {
37
+ return send(res, 200, { ok: true, devices: devices.size })
38
+ }
39
+ if (req.method === 'POST' && url.pathname === '/v1/register') return register(req, res)
40
+ if (req.method === 'POST' && url.pathname === '/v1/heartbeat') return heartbeat(req, res)
41
+ if (req.method === 'POST' && url.pathname === '/v1/send') return forward(req, res)
42
+ if (req.method === 'GET' && url.pathname === '/v1/inbox') return inbox(url, res)
43
+ return send(res, 404, { error: 'not-found' })
44
+ }
45
+
46
+ async function register(req, res) {
47
+ const body = await readJson(req)
48
+ const groupId = typeof body?.groupId === 'string' ? body.groupId : ''
49
+ const deviceId = typeof body?.deviceId === 'string' ? body.deviceId : ''
50
+ if (!/^[0-9a-f]{32,64}$/.test(groupId)) return send(res, 400, { error: 'bad-group' })
51
+ if (!deviceId || deviceId.length > 128) return send(res, 400, { error: 'bad-device' })
52
+
53
+ let record = devices.get(deviceId)
54
+ if (record && record.groupId !== groupId) return send(res, 409, { error: 'device-in-use' })
55
+ if (!record) {
56
+ record = {
57
+ deviceId,
58
+ groupId,
59
+ token: randomBytes(24).toString('hex'),
60
+ name: typeof body?.name === 'string' ? body.name.slice(0, 80) : 'DSH device',
61
+ userId: typeof body?.userId === 'string' ? body.userId : null,
62
+ lastSeen: Date.now(),
63
+ queue: [],
64
+ waiters: new Set(),
65
+ }
66
+ devices.set(deviceId, record)
67
+ tokens.set(record.token, deviceId)
68
+ } else {
69
+ record.lastSeen = Date.now()
70
+ if (typeof body?.name === 'string') record.name = body.name.slice(0, 80)
71
+ if (typeof body?.userId === 'string') record.userId = body.userId
72
+ }
73
+ log.info?.(`[relay] register ${record.name} (${deviceId.slice(0, 12)})`)
74
+ return send(res, 200, { token: record.token, peers: peerList(record) })
75
+ }
76
+
77
+ async function heartbeat(req, res) {
78
+ const body = await readJson(req)
79
+ const record = authorize(body?.token)
80
+ if (!record) return send(res, 401, { error: 'unauthorized' })
81
+ record.lastSeen = Date.now()
82
+ return send(res, 200, { ok: true, peers: peerList(record) })
83
+ }
84
+
85
+ async function forward(req, res) {
86
+ const body = await readJson(req)
87
+ const from = authorize(body?.token)
88
+ if (!from) return send(res, 401, { error: 'unauthorized' })
89
+ const to = devices.get(String(body?.to || ''))
90
+ if (!to || to.groupId !== from.groupId) return send(res, 404, { error: 'unknown-peer' })
91
+ const message = body?.message
92
+ if (message === undefined || JSON.stringify(message).length > MAX_BODY_BYTES) {
93
+ return send(res, 413, { error: 'too-large' })
94
+ }
95
+ if (to.queue.length >= MAX_QUEUE) to.queue.shift()
96
+ to.queue.push({ from: from.deviceId, message, at: Date.now() })
97
+ wake(to)
98
+ return send(res, 200, { ok: true })
99
+ }
100
+
101
+ function inbox(url, res) {
102
+ const record = authorize(url.searchParams.get('token'))
103
+ if (!record) return send(res, 401, { error: 'unauthorized' })
104
+ record.lastSeen = Date.now()
105
+ if (record.queue.length > 0) return send(res, 200, { messages: drain(record), peers: peerList(record) })
106
+
107
+ const waitSeconds = Math.min(Number(url.searchParams.get('wait') || 25) || 25, MAX_WAIT_SECONDS)
108
+ let settled = false
109
+ const finish = () => {
110
+ if (settled) return
111
+ settled = true
112
+ clearTimeout(timer)
113
+ record.waiters.delete(finish)
114
+ try { send(res, 200, { messages: drain(record), peers: peerList(record) }) } catch { /* client gone */ }
115
+ }
116
+ record.waiters.add(finish)
117
+ const timer = setTimeout(finish, waitSeconds * 1000)
118
+ res.on('close', () => {
119
+ if (settled) return
120
+ settled = true
121
+ clearTimeout(timer)
122
+ record.waiters.delete(finish)
123
+ })
124
+ }
125
+
126
+ function drain(record) {
127
+ const messages = record.queue
128
+ record.queue = []
129
+ return messages
130
+ }
131
+
132
+ function wake(record) {
133
+ for (const waiter of [...record.waiters]) waiter()
134
+ }
135
+
136
+ function authorize(token) {
137
+ if (typeof token !== 'string' || !token) return undefined
138
+ const deviceId = tokens.get(token)
139
+ if (!deviceId) return undefined
140
+ const record = devices.get(deviceId)
141
+ if (!record) { tokens.delete(token); return undefined }
142
+ return record
143
+ }
144
+
145
+ function peerList(self) {
146
+ const now = Date.now()
147
+ const items = []
148
+ for (const record of devices.values()) {
149
+ if (record.groupId !== self.groupId || record.deviceId === self.deviceId) continue
150
+ if (now - record.lastSeen > DEVICE_TTL_MS) continue
151
+ items.push({ deviceId: record.deviceId, name: record.name, userId: record.userId, lastSeen: record.lastSeen })
152
+ }
153
+ return items
154
+ }
155
+
156
+ return {
157
+ handle,
158
+ stats: () => ({ devices: devices.size }),
159
+ close: () => clearInterval(reaper),
160
+ }
161
+ }
162
+
163
+ function send(res, status, value) {
164
+ if (res.writableEnded) return
165
+ const body = JSON.stringify(value)
166
+ res.writeHead(status, {
167
+ 'content-type': 'application/json; charset=utf-8',
168
+ 'content-length': Buffer.byteLength(body),
169
+ 'cache-control': 'no-store',
170
+ })
171
+ res.end(body)
172
+ }
173
+
174
+ function readJson(req) {
175
+ return new Promise((resolve, reject) => {
176
+ const chunks = []
177
+ let size = 0
178
+ req.on('data', (chunk) => {
179
+ size += chunk.length
180
+ if (size > 2 * 1024 * 1024) { reject(new Error('too large')); req.destroy(); return }
181
+ chunks.push(chunk)
182
+ })
183
+ req.on('end', () => {
184
+ if (chunks.length === 0) return resolve({})
185
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (error) { reject(error) }
186
+ })
187
+ req.on('error', reject)
188
+ })
189
+ }
@@ -0,0 +1,335 @@
1
+ import { createHash } from 'node:crypto'
2
+ import http from 'node:http'
3
+
4
+ /**
5
+ * Outbound link to the remote-control relay.
6
+ *
7
+ * The relay is a rendezvous point, not a trust anchor: this link tunnels the
8
+ * same signed `/peer/*` HTTP calls the LAN transport uses, so the receiving
9
+ * Host still verifies the per-peer HMAC end to end. A compromised relay can
10
+ * drop or delay traffic, but cannot forge a control command.
11
+ *
12
+ * Messages:
13
+ * http / http-response unary call and its reply
14
+ * stream-open / stream-status / stream-chunk / stream-end / stream-cancel
15
+ * SSE transcript relayed frame by frame
16
+ */
17
+ export function createRelayLink({ url, key, deviceId, deviceName, userId, localPort, log }) {
18
+ const base = String(url).replace(/\/+$/, '')
19
+ const groupId = createHash('sha256').update(String(key)).digest('hex')
20
+ const peers = new Map()
21
+ const pending = new Map()
22
+ const streams = new Map()
23
+ const locals = new Map()
24
+
25
+ let token
26
+ let stopped = false
27
+ let heartbeatTimer
28
+ let sequence = 0
29
+
30
+ async function start() {
31
+ await register()
32
+ heartbeatTimer = setInterval(() => { heartbeat().catch(() => {}) }, 5000)
33
+ heartbeatTimer.unref?.()
34
+ inboxLoop().catch((error) => log.warn(`[remote-control] relay inbox stopped: ${error?.message || error}`))
35
+ }
36
+
37
+ function stop() {
38
+ stopped = true
39
+ clearInterval(heartbeatTimer)
40
+ for (const entry of pending.values()) entry.resolve({ status: 503, body: '{"error":"relay-stopped"}' })
41
+ pending.clear()
42
+ for (const entry of streams.values()) entry.onEnd()
43
+ streams.clear()
44
+ for (const request of locals.values()) { try { request.destroy() } catch { /* ignore */ } }
45
+ locals.clear()
46
+ }
47
+
48
+ async function register() {
49
+ const response = await fetch(`${base}/v1/register`, {
50
+ method: 'POST',
51
+ headers: { 'content-type': 'application/json' },
52
+ body: JSON.stringify({ groupId, deviceId, name: deviceName, userId }),
53
+ })
54
+ if (!response.ok) throw new Error(`relay register failed: ${response.status}`)
55
+ const data = await response.json()
56
+ token = data.token
57
+ applyPeers(data.peers)
58
+ log.info(`[remote-control] relay connected: ${base}`)
59
+ }
60
+
61
+ async function heartbeat() {
62
+ if (!token || stopped) return
63
+ try {
64
+ const response = await fetch(`${base}/v1/heartbeat`, {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/json' },
67
+ body: JSON.stringify({ token }),
68
+ })
69
+ if (response.status === 401) { token = undefined; return }
70
+ if (!response.ok) return
71
+ const data = await response.json()
72
+ applyPeers(data.peers)
73
+ } catch { /* relay unreachable; next tick retries */ }
74
+ }
75
+
76
+ function applyPeers(list) {
77
+ if (!Array.isArray(list)) return
78
+ const seen = new Set()
79
+ for (const item of list) {
80
+ if (!item || typeof item.deviceId !== 'string') continue
81
+ seen.add(item.deviceId)
82
+ peers.set(item.deviceId, {
83
+ deviceId: item.deviceId,
84
+ name: typeof item.name === 'string' ? item.name : 'DSH device',
85
+ userId: typeof item.userId === 'string' ? item.userId : null,
86
+ lastSeen: Number(item.lastSeen) || Date.now(),
87
+ })
88
+ }
89
+ for (const deviceId of peers.keys()) if (!seen.has(deviceId)) peers.delete(deviceId)
90
+ }
91
+
92
+ async function inboxLoop() {
93
+ while (!stopped) {
94
+ if (!token) {
95
+ await delay(1500)
96
+ try { await register() } catch { /* retry */ }
97
+ continue
98
+ }
99
+ try {
100
+ const controller = new AbortController()
101
+ const timer = setTimeout(() => controller.abort(), 40000)
102
+ const response = await fetch(`${base}/v1/inbox?token=${encodeURIComponent(token)}&wait=25`, { signal: controller.signal })
103
+ clearTimeout(timer)
104
+ if (response.status === 401) { token = undefined; continue }
105
+ if (!response.ok) { await delay(1000); continue }
106
+ const data = await response.json()
107
+ if (data.peers) applyPeers(data.peers)
108
+ for (const item of data.messages ?? []) {
109
+ dispatch(item.from, item.message).catch((error) => {
110
+ log.warn(`[remote-control] relay dispatch failed: ${error?.message || error}`)
111
+ })
112
+ }
113
+ } catch {
114
+ if (stopped) return
115
+ await delay(1000)
116
+ }
117
+ }
118
+ }
119
+
120
+ async function dispatch(from, message) {
121
+ if (!message || typeof message !== 'object') return
122
+ switch (message.kind) {
123
+ case 'http': {
124
+ const result = await localRequest(message.method, message.path, message.headers, message.body)
125
+ await send(from, { kind: 'http-response', id: message.id, status: result.status, body: result.body })
126
+ return
127
+ }
128
+ case 'http-response': {
129
+ const entry = pending.get(message.id)
130
+ if (!entry) return
131
+ pending.delete(message.id)
132
+ entry.resolve({ status: message.status, body: message.body })
133
+ return
134
+ }
135
+ case 'stream-open':
136
+ openLocalStream(from, message)
137
+ return
138
+ case 'stream-status': {
139
+ const entry = streams.get(message.id)
140
+ entry?.onStatus?.(message.status)
141
+ return
142
+ }
143
+ case 'stream-chunk': {
144
+ streams.get(message.id)?.onChunk(message.chunk)
145
+ return
146
+ }
147
+ case 'stream-end': {
148
+ const entry = streams.get(message.id)
149
+ if (!entry) return
150
+ streams.delete(message.id)
151
+ entry.onEnd()
152
+ return
153
+ }
154
+ case 'stream-cancel': {
155
+ const request = locals.get(message.id)
156
+ if (!request) return
157
+ locals.delete(message.id)
158
+ try { request.destroy() } catch { /* ignore */ }
159
+ return
160
+ }
161
+ default:
162
+ return
163
+ }
164
+ }
165
+
166
+ function localRequest(method, pathname, headers, body) {
167
+ return new Promise((resolve) => {
168
+ const request = http.request({
169
+ host: '127.0.0.1',
170
+ port: localPort,
171
+ path: pathname,
172
+ method,
173
+ headers: { ...headers, host: `127.0.0.1:${localPort}` },
174
+ }, (response) => {
175
+ let data = ''
176
+ response.on('data', (chunk) => { data += chunk })
177
+ response.on('end', () => resolve({ status: response.statusCode ?? 502, body: data }))
178
+ })
179
+ request.on('error', (error) => resolve({
180
+ status: 502,
181
+ body: JSON.stringify({ error: 'local-unreachable', message: error?.message || String(error) }),
182
+ }))
183
+ if (body !== undefined && body !== null) request.write(body)
184
+ request.end()
185
+ })
186
+ }
187
+
188
+ function openLocalStream(from, message) {
189
+ const id = message.id
190
+ const request = http.request({
191
+ host: '127.0.0.1',
192
+ port: localPort,
193
+ path: message.path,
194
+ method: 'GET',
195
+ headers: { ...message.headers, host: `127.0.0.1:${localPort}` },
196
+ }, (response) => {
197
+ send(from, { kind: 'stream-status', id, status: response.statusCode ?? 502 }).catch(() => {})
198
+ if ((response.statusCode ?? 500) >= 400) {
199
+ response.resume()
200
+ locals.delete(id)
201
+ send(from, { kind: 'stream-end', id }).catch(() => {})
202
+ return
203
+ }
204
+ response.on('data', (chunk) => {
205
+ send(from, { kind: 'stream-chunk', id, chunk: chunk.toString('utf8') }).catch(() => {})
206
+ })
207
+ response.on('end', () => {
208
+ locals.delete(id)
209
+ send(from, { kind: 'stream-end', id }).catch(() => {})
210
+ })
211
+ response.on('error', () => {
212
+ locals.delete(id)
213
+ send(from, { kind: 'stream-end', id }).catch(() => {})
214
+ })
215
+ })
216
+ request.on('error', () => {
217
+ locals.delete(id)
218
+ send(from, { kind: 'stream-end', id }).catch(() => {})
219
+ })
220
+ locals.set(id, request)
221
+ request.end()
222
+ }
223
+
224
+ async function send(to, message) {
225
+ if (!token) throw new Error('relay not registered')
226
+ const response = await fetch(`${base}/v1/send`, {
227
+ method: 'POST',
228
+ headers: { 'content-type': 'application/json' },
229
+ body: JSON.stringify({ token, to, message }),
230
+ })
231
+ if (response.status === 401) { token = undefined; throw new Error('relay unauthorized') }
232
+ if (!response.ok) throw new Error(`relay send failed: ${response.status}`)
233
+ }
234
+
235
+ async function request(target, options) {
236
+ if (!token) return { status: 503, body: '{"error":"relay-offline"}' }
237
+ const id = nextId()
238
+ const reply = new Promise((resolve) => {
239
+ const timer = setTimeout(() => {
240
+ pending.delete(id)
241
+ resolve({ status: 504, body: '{"error":"relay-timeout"}' })
242
+ }, 20000)
243
+ timer.unref?.()
244
+ pending.set(id, { resolve: (value) => { clearTimeout(timer); resolve(value) } })
245
+ })
246
+ await send(target, {
247
+ kind: 'http',
248
+ id,
249
+ method: options.method,
250
+ path: options.path,
251
+ headers: options.headers,
252
+ body: options.body,
253
+ })
254
+ return reply
255
+ }
256
+
257
+ function stream(target, options, res) {
258
+ const id = nextId()
259
+ let opened = false
260
+ const entry = {
261
+ onStatus(status) {
262
+ if (opened) return
263
+ opened = true
264
+ if (status >= 400) {
265
+ streams.delete(id)
266
+ try {
267
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
268
+ res.end('{"error":"peer-refused"}')
269
+ } catch { /* ignore */ }
270
+ return
271
+ }
272
+ try {
273
+ res.writeHead(200, {
274
+ 'content-type': 'text/event-stream; charset=utf-8',
275
+ 'cache-control': 'no-store, no-transform',
276
+ connection: 'keep-alive',
277
+ 'x-accel-buffering': 'no',
278
+ })
279
+ } catch { /* ignore */ }
280
+ },
281
+ onChunk(chunk) { try { res.write(chunk) } catch { /* ignore */ } },
282
+ onEnd() { try { res.end() } catch { /* ignore */ } },
283
+ }
284
+ streams.set(id, entry)
285
+ const timeout = setTimeout(() => {
286
+ if (opened) return
287
+ opened = true
288
+ streams.delete(id)
289
+ try {
290
+ res.writeHead(504, { 'content-type': 'application/json; charset=utf-8' })
291
+ res.end('{"error":"relay-timeout"}')
292
+ } catch { /* ignore */ }
293
+ }, 15000)
294
+ timeout.unref?.()
295
+
296
+ res.on('close', () => {
297
+ clearTimeout(timeout)
298
+ if (!streams.has(id)) return
299
+ streams.delete(id)
300
+ send(target, { kind: 'stream-cancel', id }).catch(() => {})
301
+ })
302
+
303
+ send(target, {
304
+ kind: 'stream-open',
305
+ id,
306
+ path: options.path,
307
+ headers: options.headers,
308
+ }).catch((error) => {
309
+ clearTimeout(timeout)
310
+ streams.delete(id)
311
+ if (!res.writableEnded) {
312
+ try { res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: error?.message || 'relay-send-failed' })) } catch { /* ignore */ }
313
+ }
314
+ })
315
+ }
316
+
317
+ function nextId() {
318
+ sequence += 1
319
+ return `${deviceId}-${Date.now().toString(36)}-${sequence}`
320
+ }
321
+
322
+ return {
323
+ start,
324
+ stop,
325
+ request,
326
+ stream,
327
+ peers: () => [...peers.values()],
328
+ url: () => base,
329
+ connected: () => Boolean(token),
330
+ }
331
+ }
332
+
333
+ function delay(ms) {
334
+ return new Promise((resolve) => setTimeout(resolve, ms))
335
+ }
package/icon.svg ADDED
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Remote Control">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#2f6bff"/>
5
+ <stop offset="1" stop-color="#7a3cff"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="16" fill="url(#bg)"/>
9
+ <rect x="20" y="12" width="24" height="40" rx="6" fill="none" stroke="#ffffff" stroke-width="3.2"/>
10
+ <line x1="28" y1="17" x2="36" y2="17" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round"/>
11
+ <path d="M27 30l-4 5 4 5" fill="none" stroke="#dce6ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
12
+ <line x1="35" y1="29" x2="35" y2="41" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
13
+ <path d="M44 24c3 2.5 3 13.5 0 16" fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.9"/>
14
+ <path d="M49 19c5.5 4.5 5.5 21.5 0 26" fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.55"/>
15
+ </svg>