dsh-remote-control-plugin 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/host/fabric.js ADDED
@@ -0,0 +1,595 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import dgram from 'node:dgram'
3
+ import http from 'node:http'
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+
7
+ const ANNOUNCE_KIND = 'dsh-remote/announce'
8
+ const ANNOUNCE_INTERVAL_MS = 4000
9
+ const PEER_TTL_MS = 15000
10
+ const REQUEST_TTL_MS = 10 * 60 * 1000
11
+ const CLOCK_SKEW_MS = 5 * 60 * 1000
12
+
13
+ /**
14
+ * Peer fabric: LAN discovery, one-time approval, and the authenticated
15
+ * peer-to-peer control calls used by the Devices panel.
16
+ *
17
+ * Everything here is plain Node so it can be exercised without Cordis:
18
+ * `sessions` is a small facade over the Host's session controller.
19
+ */
20
+ export function createFabric({ log, config, self, sessions, stateFile }) {
21
+ const peers = new Map() // deviceId -> peer record
22
+ const requests = new Map() // requestId -> inbound approval request
23
+ const outgoing = new Map() // deviceId -> { requestId, at }
24
+ const approvals = new Map() // deviceId -> { deviceName, secret, approvedAt }
25
+ const sockets = []
26
+ let announceTimer
27
+ let reaper
28
+ let loaded = false
29
+ let relay
30
+
31
+ const discoveryPort = config.discoveryPort
32
+ const enabled = config.discovery !== false
33
+
34
+ async function start() {
35
+ await loadApprovals()
36
+ if (!enabled) return
37
+
38
+ const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
39
+ sockets.push(socket)
40
+ socket.on('error', (error) => log.warn(`[remote-control] discovery socket: ${error?.message || error}`))
41
+ socket.on('message', (buffer, rinfo) => {
42
+ try {
43
+ onAnnounce(JSON.parse(buffer.toString('utf8')), rinfo)
44
+ } catch { /* ignore malformed datagrams */ }
45
+ })
46
+ await new Promise((resolve) => {
47
+ socket.bind(discoveryPort, () => {
48
+ try { socket.setBroadcast(true) } catch { /* ignore */ }
49
+ resolve()
50
+ })
51
+ })
52
+
53
+ announceTimer = setInterval(announce, ANNOUNCE_INTERVAL_MS)
54
+ announceTimer.unref?.()
55
+ announce()
56
+ reaper = setInterval(reap, 5000)
57
+ reaper.unref?.()
58
+ }
59
+
60
+ function stop() {
61
+ clearInterval(announceTimer)
62
+ clearInterval(reaper)
63
+ for (const socket of sockets) {
64
+ try { socket.close() } catch { /* ignore */ }
65
+ }
66
+ sockets.length = 0
67
+ }
68
+
69
+ function announce() {
70
+ const who = self()
71
+ if (!who.deviceId) return
72
+ const payload = Buffer.from(JSON.stringify({
73
+ v: 1,
74
+ kind: ANNOUNCE_KIND,
75
+ deviceId: who.deviceId,
76
+ deviceName: who.deviceName,
77
+ userId: who.userId,
78
+ port: who.port,
79
+ proto: who.proto,
80
+ ts: Date.now(),
81
+ }))
82
+ for (const socket of sockets) {
83
+ try { socket.send(payload, 0, payload.length, discoveryPort, '255.255.255.255') } catch { /* ignore */ }
84
+ try { socket.send(payload, 0, payload.length, discoveryPort, '127.0.0.1') } catch { /* ignore */ }
85
+ }
86
+ }
87
+
88
+ function onAnnounce(message, rinfo) {
89
+ if (!message || message.kind !== ANNOUNCE_KIND) return
90
+ if (typeof message.deviceId !== 'string' || !message.deviceId) return
91
+ const who = self()
92
+ if (message.deviceId === who.deviceId) return
93
+
94
+ const existing = peers.get(message.deviceId)
95
+ peers.set(message.deviceId, {
96
+ deviceId: message.deviceId,
97
+ deviceName: typeof message.deviceName === 'string' ? message.deviceName : 'DSH device',
98
+ userId: typeof message.userId === 'string' ? message.userId : null,
99
+ address: rinfo.address,
100
+ port: Number(message.port) || 57330,
101
+ proto: message.proto === 'https' ? 'https' : 'http',
102
+ lastSeen: Date.now(),
103
+ approved: approvals.has(message.deviceId) || Boolean(existing?.approved),
104
+ secret: approvals.get(message.deviceId)?.secret ?? existing?.secret,
105
+ })
106
+ }
107
+
108
+ function reap() {
109
+ const now = Date.now()
110
+ for (const [deviceId, peer] of peers) {
111
+ if (peer.proto === 'relay') continue
112
+ if (now - peer.lastSeen > PEER_TTL_MS) peers.delete(deviceId)
113
+ }
114
+ for (const [requestId, request] of requests) {
115
+ if (now - request.at > REQUEST_TTL_MS) requests.delete(requestId)
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Merge relay presence into the peer table. A directly discovered LAN peer
121
+ * keeps its direct route; relay presence covers devices behind NAT.
122
+ */
123
+ function syncRelayPeers() {
124
+ if (!relay) return
125
+ const present = new Set()
126
+ for (const item of relay.peers()) {
127
+ present.add(item.deviceId)
128
+ const existing = peers.get(item.deviceId)
129
+ if (existing && existing.proto !== 'relay') continue
130
+ peers.set(item.deviceId, {
131
+ deviceId: item.deviceId,
132
+ deviceName: item.name,
133
+ userId: item.userId,
134
+ address: 'relay',
135
+ port: 0,
136
+ proto: 'relay',
137
+ lastSeen: item.lastSeen,
138
+ approved: approvals.has(item.deviceId) || Boolean(existing?.approved),
139
+ secret: approvals.get(item.deviceId)?.secret ?? existing?.secret,
140
+ })
141
+ }
142
+ // Drop relay peers the relay no longer lists, so a device that leaves or a
143
+ // relay that goes away cannot leave a peer that always answers 503.
144
+ for (const [deviceId, peer] of peers) {
145
+ if (peer.proto === 'relay' && !present.has(deviceId)) peers.delete(deviceId)
146
+ }
147
+ }
148
+
149
+ async function loadApprovals() {
150
+ if (loaded) return
151
+ loaded = true
152
+ try {
153
+ const raw = JSON.parse(await readFile(stateFile, 'utf8'))
154
+ for (const [deviceId, entry] of Object.entries(raw?.peers ?? {})) {
155
+ if (entry && typeof entry.secret === 'string') {
156
+ approvals.set(deviceId, {
157
+ deviceName: entry.deviceName ?? 'DSH device',
158
+ secret: entry.secret,
159
+ approvedAt: entry.approvedAt ?? Date.now(),
160
+ })
161
+ }
162
+ }
163
+ } catch (error) {
164
+ if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${stateFile}: ${error?.message || error}`)
165
+ }
166
+ }
167
+
168
+ async function saveApprovals() {
169
+ const peersObject = {}
170
+ for (const [deviceId, entry] of approvals) peersObject[deviceId] = entry
171
+ try {
172
+ await mkdir(path.dirname(stateFile), { recursive: true })
173
+ await writeFile(stateFile, JSON.stringify({ version: 1, peers: peersObject }, null, 2), { mode: 0o600 })
174
+ } catch (error) {
175
+ log.warn(`[remote-control] could not persist ${stateFile}: ${error?.message || error}`)
176
+ }
177
+ }
178
+
179
+ /* ---------------- inbound: hello + approval ---------------- */
180
+
181
+ function createHello(req, res) {
182
+ readJson(req).then(async (body) => {
183
+ const deviceId = typeof body?.deviceId === 'string' ? body.deviceId.trim() : ''
184
+ if (!deviceId) return json(res, 400, { error: 'missing-device' })
185
+ if (requests.size >= 20) return json(res, 429, { error: 'too-many-requests' })
186
+ const requestId = randomBytes(16).toString('hex')
187
+ requests.set(requestId, {
188
+ requestId,
189
+ deviceId,
190
+ deviceName: typeof body.deviceName === 'string' ? body.deviceName : 'DSH device',
191
+ userId: typeof body.userId === 'string' ? body.userId : null,
192
+ address: req.socket?.remoteAddress ?? '',
193
+ at: Date.now(),
194
+ status: 'pending',
195
+ })
196
+ log.info(`[remote-control] approval requested by ${deviceId}`)
197
+ return json(res, 200, { requestId, status: 'pending' })
198
+ }).catch((error) => json(res, 400, { error: 'bad-request', message: String(error?.message || error) }))
199
+ }
200
+
201
+ function helloStatus(res, requestId) {
202
+ const request = requests.get(requestId)
203
+ if (!request) return json(res, 404, { error: 'unknown-request' })
204
+ if (request.status === 'approved') {
205
+ return json(res, 200, { status: 'approved', secret: request.secret, deviceName: self().deviceName })
206
+ }
207
+ return json(res, 200, { status: request.status })
208
+ }
209
+
210
+ async function approveRequest(requestId) {
211
+ const request = requests.get(requestId)
212
+ if (!request) return { ok: false, error: 'unknown-request' }
213
+ const secret = randomBytes(32).toString('hex')
214
+ request.status = 'approved'
215
+ request.secret = secret
216
+ approvals.set(request.deviceId, { deviceName: request.deviceName, secret, approvedAt: Date.now() })
217
+ await saveApprovals()
218
+ const peer = peers.get(request.deviceId)
219
+ if (peer) { peer.approved = true; peer.secret = secret }
220
+ log.info(`[remote-control] approved ${request.deviceName} (${request.deviceId})`)
221
+ return { ok: true }
222
+ }
223
+
224
+ function denyRequest(requestId) {
225
+ const request = requests.get(requestId)
226
+ if (!request) return { ok: false, error: 'unknown-request' }
227
+ request.status = 'denied'
228
+ return { ok: true }
229
+ }
230
+
231
+ /* ---------------- outbound: connect to a peer ---------------- */
232
+
233
+ async function connect(deviceId) {
234
+ syncRelayPeers()
235
+ const peer = peers.get(deviceId)
236
+ if (!peer) return { ok: false, error: 'unknown-peer' }
237
+ if (approvals.has(deviceId)) return { ok: true, approved: true }
238
+
239
+ const who = self()
240
+ const reply = await peerCall(peer, 'POST', '/peer/hello', {
241
+ deviceId: who.deviceId,
242
+ deviceName: who.deviceName,
243
+ userId: who.userId,
244
+ })
245
+ if (!reply.ok || typeof reply.json?.requestId !== 'string') {
246
+ return { ok: false, error: reply.json?.error || reply.error || 'hello-failed' }
247
+ }
248
+ outgoing.set(deviceId, { requestId: reply.json.requestId, at: Date.now() })
249
+
250
+ // Poll in the background; the target approves in its own Devices panel.
251
+ pollApproval(peer, reply.json.requestId).catch(() => {})
252
+ return { ok: true, approved: false, requestId: reply.json.requestId }
253
+ }
254
+
255
+ async function pollApproval(peer, requestId) {
256
+ for (let attempt = 0; attempt < 300; attempt += 1) {
257
+ await delay(1000)
258
+ if (!outgoing.has(peer.deviceId)) return
259
+ const reply = await peerCall(peer, 'GET', `/peer/hello/${requestId}`)
260
+ const status = reply.json?.status
261
+ if (status === 'approved' && typeof reply.json.secret === 'string') {
262
+ approvals.set(peer.deviceId, { deviceName: peer.deviceName, secret: reply.json.secret, approvedAt: Date.now() })
263
+ await saveApprovals()
264
+ peer.approved = true
265
+ peer.secret = reply.json.secret
266
+ outgoing.delete(peer.deviceId)
267
+ log.info(`[remote-control] ${peer.deviceName} approved this device`)
268
+ return
269
+ }
270
+ if (status === 'denied') {
271
+ outgoing.delete(peer.deviceId)
272
+ return
273
+ }
274
+ }
275
+ outgoing.delete(peer.deviceId)
276
+ }
277
+
278
+ /* ---------------- inbound: authenticated peer calls ---------------- */
279
+
280
+ async function handlePeer(req, res, url) {
281
+ const pathname = url.pathname
282
+ if (pathname === '/peer/hello' && req.method === 'POST') return createHello(req, res)
283
+ const helloMatch = pathname.match(/^\/peer\/hello\/([0-9a-f]{32})$/)
284
+ if (helloMatch && req.method === 'GET') return helloStatus(res, helloMatch[1])
285
+
286
+ const verdict = verifyPeer(req, url)
287
+ if (!verdict.ok) return json(res, 403, { error: verdict.error })
288
+
289
+ if (pathname === '/peer/sessions' && req.method === 'GET') {
290
+ const value = await sessions.list()
291
+ return json(res, 200, { items: value.map((item) => ({ ...item, title: item.title })) })
292
+ }
293
+ if (pathname === '/peer/stream' && req.method === 'GET') {
294
+ return streamSession(req, res, url)
295
+ }
296
+ if (pathname === '/peer/prompt' && req.method === 'POST') {
297
+ const body = await readJson(req)
298
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
299
+ const text = typeof body?.text === 'string' ? body.text : ''
300
+ if (!sessionId || !text.trim()) return json(res, 400, { error: 'bad-request' })
301
+ const value = await sessions.prompt(sessionId, text)
302
+ return json(res, 200, value)
303
+ }
304
+ if (pathname === '/peer/cancel' && req.method === 'POST') {
305
+ const body = await readJson(req)
306
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
307
+ if (!sessionId) return json(res, 400, { error: 'bad-request' })
308
+ return json(res, 200, sessions.cancel(sessionId))
309
+ }
310
+ return json(res, 404, { error: 'not-found' })
311
+ }
312
+
313
+ function verifyPeer(req, url) {
314
+ const deviceId = String(req.headers['x-dsh-peer-id'] || '')
315
+ const timestamp = String(req.headers['x-dsh-peer-ts'] || '')
316
+ const signature = String(req.headers['x-dsh-peer-sig'] || '')
317
+ if (!deviceId || !timestamp || !signature) return { ok: false, error: 'missing-signature' }
318
+ const approval = approvals.get(deviceId)
319
+ if (!approval) return { ok: false, error: 'unapproved-device' }
320
+ if (!/^\d{1,16}$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > CLOCK_SKEW_MS) {
321
+ return { ok: false, error: 'stale-signature' }
322
+ }
323
+ const expected = sign(approval.secret, deviceId, req.method || 'GET', url.pathname + url.search, timestamp, '')
324
+ const actual = Buffer.from(signature, 'hex')
325
+ const wanted = Buffer.from(expected, 'hex')
326
+ if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) return { ok: false, error: 'bad-signature' }
327
+ return { ok: true }
328
+ }
329
+
330
+ async function streamSession(req, res, url) {
331
+ const sessionId = String(url.searchParams.get('sessionId') || '')
332
+ if (!sessionId) return json(res, 400, { error: 'missing-session' })
333
+ const controller = new AbortController()
334
+ res.writeHead(200, {
335
+ 'content-type': 'text/event-stream; charset=utf-8',
336
+ 'cache-control': 'no-store, no-transform',
337
+ connection: 'keep-alive',
338
+ 'x-accel-buffering': 'no',
339
+ })
340
+ res.write(': connected\n\n')
341
+ const heartbeat = setInterval(() => { try { res.write(': ping\n\n') } catch { /* ignore */ } }, 15000)
342
+ heartbeat.unref?.()
343
+ let closed = false
344
+ const cleanup = () => {
345
+ if (closed) return
346
+ closed = true
347
+ clearInterval(heartbeat)
348
+ try { controller.abort() } catch { /* ignore */ }
349
+ try { res.end() } catch { /* ignore */ }
350
+ }
351
+ req.on('close', cleanup)
352
+ req.on('aborted', cleanup)
353
+ try {
354
+ for await (const frame of sessions.follow(sessionId, controller.signal)) {
355
+ if (res.writableEnded || res.destroyed) break
356
+ res.write(`data: ${JSON.stringify(frame)}\n\n`)
357
+ }
358
+ } catch (error) {
359
+ if (!controller.signal.aborted) {
360
+ try {
361
+ res.write(`data: ${JSON.stringify({ type: 'remote-error', code: error?.code ?? 'error', message: error?.message ?? String(error) })}\n\n`)
362
+ } catch { /* ignore */ }
363
+ }
364
+ } finally {
365
+ cleanup()
366
+ }
367
+ }
368
+
369
+ /* ---------------- outbound: control a peer ---------------- */
370
+
371
+ /**
372
+ * One signed call to a peer over whichever transport reaches it: the relay
373
+ * when the peer was discovered there, direct HTTP for a LAN peer.
374
+ */
375
+ async function peerCall(peer, method, pathname, body, secret) {
376
+ if (peer.proto !== 'relay') {
377
+ return callRaw(peer, method, pathname, body, secret, self().deviceId)
378
+ }
379
+ if (!relay) return { ok: false, status: 503, json: { error: 'relay-offline' } }
380
+ const callerId = self().deviceId
381
+ const timestamp = String(Date.now())
382
+ const headers = { accept: 'application/json' }
383
+ if (secret) {
384
+ headers['x-dsh-peer-id'] = callerId
385
+ headers['x-dsh-peer-ts'] = timestamp
386
+ headers['x-dsh-peer-sig'] = sign(secret, callerId, method, pathname, timestamp, '')
387
+ }
388
+ if (body !== undefined) headers['content-type'] = 'application/json'
389
+ let reply
390
+ try {
391
+ reply = await relay.request(peer.deviceId, {
392
+ method,
393
+ path: pathname,
394
+ headers,
395
+ body: body === undefined ? undefined : JSON.stringify(body),
396
+ })
397
+ } catch (error) {
398
+ return { ok: false, status: 502, json: { error: 'relay-failed', message: error?.message ?? String(error) } }
399
+ }
400
+ let parsed
401
+ try { parsed = reply.body ? JSON.parse(reply.body) : {} } catch { parsed = {} }
402
+ return { ok: reply.status >= 200 && reply.status < 300, status: reply.status, json: parsed }
403
+ }
404
+
405
+ async function callPeer(deviceId, method, pathname, body) {
406
+ syncRelayPeers()
407
+ const peer = peers.get(deviceId)
408
+ if (!peer) return { ok: false, status: 404, json: { error: 'unknown-peer' } }
409
+ const approval = approvals.get(deviceId)
410
+ if (!approval) return { ok: false, status: 403, json: { error: 'not-approved' } }
411
+ return peerCall(peer, method, pathname, body, approval.secret)
412
+ }
413
+
414
+ /** Relay one peer's SSE transcript to a local response (Devices panel). */
415
+ function proxyStream(deviceId, sessionId, res) {
416
+ syncRelayPeers()
417
+ const peer = peers.get(deviceId)
418
+ const approval = approvals.get(deviceId)
419
+ if (!peer || !approval) {
420
+ json(res, 404, { error: 'unknown-peer' })
421
+ return
422
+ }
423
+ const target = `/peer/stream?sessionId=${encodeURIComponent(sessionId)}`
424
+ const timestamp = String(Date.now())
425
+ const callerId = self().deviceId
426
+ if (peer.proto === 'relay') {
427
+ if (!relay) {
428
+ json(res, 503, { error: 'relay-offline' })
429
+ return
430
+ }
431
+ relay.stream(peer.deviceId, {
432
+ path: target,
433
+ headers: {
434
+ accept: 'text/event-stream',
435
+ 'x-dsh-peer-id': callerId,
436
+ 'x-dsh-peer-ts': timestamp,
437
+ 'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
438
+ },
439
+ }, res)
440
+ return
441
+ }
442
+ const upstream = http.request({
443
+ host: peer.address,
444
+ port: peer.port,
445
+ path: target,
446
+ method: 'GET',
447
+ headers: {
448
+ accept: 'text/event-stream',
449
+ 'x-dsh-peer-id': callerId,
450
+ 'x-dsh-peer-ts': timestamp,
451
+ 'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
452
+ },
453
+ }, (response) => {
454
+ res.writeHead(response.statusCode ?? 502, {
455
+ 'content-type': response.headers['content-type'] || 'text/event-stream; charset=utf-8',
456
+ 'cache-control': 'no-store, no-transform',
457
+ connection: 'keep-alive',
458
+ 'x-accel-buffering': 'no',
459
+ })
460
+ response.pipe(res)
461
+ response.on('end', () => { try { res.end() } catch { /* ignore */ } })
462
+ })
463
+ upstream.on('error', () => {
464
+ try { json(res, 502, { error: 'peer-unreachable' }) } catch { /* ignore */ }
465
+ })
466
+ res.on('close', () => { try { upstream.destroy() } catch { /* ignore */ } })
467
+ upstream.end()
468
+ }
469
+
470
+ return {
471
+ start,
472
+ stop,
473
+ connect,
474
+ approveRequest,
475
+ denyRequest,
476
+ callPeer,
477
+ proxyStream,
478
+ handlePeer,
479
+ setRelay(link) {
480
+ relay = link
481
+ if (!link) {
482
+ // Without a relay every relay-only peer is unreachable; keeping it
483
+ // would make each call fail with a misleading 503.
484
+ for (const [deviceId, peer] of peers) {
485
+ if (peer.proto === 'relay') peers.delete(deviceId)
486
+ }
487
+ }
488
+ },
489
+
490
+ snapshot() {
491
+ syncRelayPeers()
492
+ const who = self()
493
+ return {
494
+ self: who,
495
+ peers: [...peers.values()].map((peer) => ({
496
+ deviceId: peer.deviceId,
497
+ deviceName: peer.deviceName,
498
+ userId: peer.userId,
499
+ address: peer.address,
500
+ port: peer.port,
501
+ lastSeen: peer.lastSeen,
502
+ approved: approvals.has(peer.deviceId),
503
+ connecting: outgoing.has(peer.deviceId),
504
+ transport: peer.proto === 'relay' ? 'relay' : 'lan',
505
+ })),
506
+ requests: [...requests.values()]
507
+ .filter((request) => request.status === 'pending')
508
+ .map((request) => ({
509
+ requestId: request.requestId,
510
+ deviceId: request.deviceId,
511
+ deviceName: request.deviceName,
512
+ userId: request.userId,
513
+ at: request.at,
514
+ })),
515
+ }
516
+ },
517
+ }
518
+ }
519
+
520
+ /* ---------------- HTTP helpers ---------------- */
521
+
522
+ function sign(secret, deviceId, method, pathname, timestamp, body) {
523
+ return createHmac('sha256', secret)
524
+ .update(`${deviceId}\n${method}\n${pathname}\n${timestamp}\n${body}`)
525
+ .digest('hex')
526
+ }
527
+
528
+ function callRaw(peer, method, pathname, body, secret, callerId) {
529
+ return new Promise((resolve) => {
530
+ const text = body === undefined ? '' : JSON.stringify(body)
531
+ const headers = { accept: 'application/json' }
532
+ if (text) {
533
+ headers['content-type'] = 'application/json'
534
+ headers['content-length'] = Buffer.byteLength(text)
535
+ }
536
+ if (secret && callerId) {
537
+ const timestamp = String(Date.now())
538
+ headers['x-dsh-peer-id'] = callerId
539
+ headers['x-dsh-peer-ts'] = timestamp
540
+ headers['x-dsh-peer-sig'] = sign(secret, callerId, method, pathname, timestamp, '')
541
+ }
542
+ const transport = peer.proto === 'https' ? null : http
543
+ if (!transport) return resolve({ ok: false, error: 'tls-unsupported', json: { error: 'tls-unsupported' } })
544
+ const req = transport.request({
545
+ host: peer.address,
546
+ port: peer.port,
547
+ path: pathname,
548
+ method,
549
+ headers,
550
+ timeout: 15000,
551
+ }, (res) => {
552
+ let data = ''
553
+ res.on('data', (chunk) => { data += chunk })
554
+ res.on('end', () => {
555
+ let parsed
556
+ try { parsed = data ? JSON.parse(data) : {} } catch { parsed = {} }
557
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: parsed })
558
+ })
559
+ })
560
+ req.on('timeout', () => { req.destroy(new Error('timeout')) })
561
+ req.on('error', (error) => resolve({ ok: false, error: error?.message || 'request-failed', json: {} }))
562
+ req.end(text || undefined)
563
+ })
564
+ }
565
+
566
+ function readJson(req) {
567
+ return new Promise((resolve, reject) => {
568
+ const chunks = []
569
+ let size = 0
570
+ req.on('data', (chunk) => {
571
+ size += chunk.length
572
+ if (size > 1024 * 1024) { reject(new Error('too large')); req.destroy(); return }
573
+ chunks.push(chunk)
574
+ })
575
+ req.on('end', () => {
576
+ if (chunks.length === 0) return resolve({})
577
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (error) { reject(error) }
578
+ })
579
+ req.on('error', reject)
580
+ })
581
+ }
582
+
583
+ function json(res, status, value) {
584
+ const body = JSON.stringify(value)
585
+ res.writeHead(status, {
586
+ 'content-type': 'application/json; charset=utf-8',
587
+ 'content-length': Buffer.byteLength(body),
588
+ 'cache-control': 'no-store',
589
+ })
590
+ res.end(body)
591
+ }
592
+
593
+ function delay(ms) {
594
+ return new Promise((resolve) => setTimeout(resolve, ms))
595
+ }