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.
package/host/fabric.js ADDED
@@ -0,0 +1,578 @@
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
+ for (const item of relay.peers()) {
126
+ const existing = peers.get(item.deviceId)
127
+ if (existing && existing.proto !== 'relay') continue
128
+ peers.set(item.deviceId, {
129
+ deviceId: item.deviceId,
130
+ deviceName: item.name,
131
+ userId: item.userId,
132
+ address: 'relay',
133
+ port: 0,
134
+ proto: 'relay',
135
+ lastSeen: item.lastSeen,
136
+ approved: approvals.has(item.deviceId) || Boolean(existing?.approved),
137
+ secret: approvals.get(item.deviceId)?.secret ?? existing?.secret,
138
+ })
139
+ }
140
+ }
141
+
142
+ async function loadApprovals() {
143
+ if (loaded) return
144
+ loaded = true
145
+ try {
146
+ const raw = JSON.parse(await readFile(stateFile, 'utf8'))
147
+ for (const [deviceId, entry] of Object.entries(raw?.peers ?? {})) {
148
+ if (entry && typeof entry.secret === 'string') {
149
+ approvals.set(deviceId, {
150
+ deviceName: entry.deviceName ?? 'DSH device',
151
+ secret: entry.secret,
152
+ approvedAt: entry.approvedAt ?? Date.now(),
153
+ })
154
+ }
155
+ }
156
+ } catch (error) {
157
+ if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${stateFile}: ${error?.message || error}`)
158
+ }
159
+ }
160
+
161
+ async function saveApprovals() {
162
+ const peersObject = {}
163
+ for (const [deviceId, entry] of approvals) peersObject[deviceId] = entry
164
+ try {
165
+ await mkdir(path.dirname(stateFile), { recursive: true })
166
+ await writeFile(stateFile, JSON.stringify({ version: 1, peers: peersObject }, null, 2), { mode: 0o600 })
167
+ } catch (error) {
168
+ log.warn(`[remote-control] could not persist ${stateFile}: ${error?.message || error}`)
169
+ }
170
+ }
171
+
172
+ /* ---------------- inbound: hello + approval ---------------- */
173
+
174
+ function createHello(req, res) {
175
+ readJson(req).then(async (body) => {
176
+ const deviceId = typeof body?.deviceId === 'string' ? body.deviceId.trim() : ''
177
+ if (!deviceId) return json(res, 400, { error: 'missing-device' })
178
+ if (requests.size >= 20) return json(res, 429, { error: 'too-many-requests' })
179
+ const requestId = randomBytes(16).toString('hex')
180
+ requests.set(requestId, {
181
+ requestId,
182
+ deviceId,
183
+ deviceName: typeof body.deviceName === 'string' ? body.deviceName : 'DSH device',
184
+ userId: typeof body.userId === 'string' ? body.userId : null,
185
+ address: req.socket?.remoteAddress ?? '',
186
+ at: Date.now(),
187
+ status: 'pending',
188
+ })
189
+ log.info(`[remote-control] approval requested by ${deviceId}`)
190
+ return json(res, 200, { requestId, status: 'pending' })
191
+ }).catch((error) => json(res, 400, { error: 'bad-request', message: String(error?.message || error) }))
192
+ }
193
+
194
+ function helloStatus(res, requestId) {
195
+ const request = requests.get(requestId)
196
+ if (!request) return json(res, 404, { error: 'unknown-request' })
197
+ if (request.status === 'approved') {
198
+ return json(res, 200, { status: 'approved', secret: request.secret, deviceName: self().deviceName })
199
+ }
200
+ return json(res, 200, { status: request.status })
201
+ }
202
+
203
+ async function approveRequest(requestId) {
204
+ const request = requests.get(requestId)
205
+ if (!request) return { ok: false, error: 'unknown-request' }
206
+ const secret = randomBytes(32).toString('hex')
207
+ request.status = 'approved'
208
+ request.secret = secret
209
+ approvals.set(request.deviceId, { deviceName: request.deviceName, secret, approvedAt: Date.now() })
210
+ await saveApprovals()
211
+ const peer = peers.get(request.deviceId)
212
+ if (peer) { peer.approved = true; peer.secret = secret }
213
+ log.info(`[remote-control] approved ${request.deviceName} (${request.deviceId})`)
214
+ return { ok: true }
215
+ }
216
+
217
+ function denyRequest(requestId) {
218
+ const request = requests.get(requestId)
219
+ if (!request) return { ok: false, error: 'unknown-request' }
220
+ request.status = 'denied'
221
+ return { ok: true }
222
+ }
223
+
224
+ /* ---------------- outbound: connect to a peer ---------------- */
225
+
226
+ async function connect(deviceId) {
227
+ syncRelayPeers()
228
+ const peer = peers.get(deviceId)
229
+ if (!peer) return { ok: false, error: 'unknown-peer' }
230
+ if (approvals.has(deviceId)) return { ok: true, approved: true }
231
+
232
+ const who = self()
233
+ const reply = await peerCall(peer, 'POST', '/peer/hello', {
234
+ deviceId: who.deviceId,
235
+ deviceName: who.deviceName,
236
+ userId: who.userId,
237
+ })
238
+ if (!reply.ok || typeof reply.json?.requestId !== 'string') {
239
+ return { ok: false, error: reply.json?.error || reply.error || 'hello-failed' }
240
+ }
241
+ outgoing.set(deviceId, { requestId: reply.json.requestId, at: Date.now() })
242
+
243
+ // Poll in the background; the target approves in its own Devices panel.
244
+ pollApproval(peer, reply.json.requestId).catch(() => {})
245
+ return { ok: true, approved: false, requestId: reply.json.requestId }
246
+ }
247
+
248
+ async function pollApproval(peer, requestId) {
249
+ for (let attempt = 0; attempt < 300; attempt += 1) {
250
+ await delay(1000)
251
+ if (!outgoing.has(peer.deviceId)) return
252
+ const reply = await peerCall(peer, 'GET', `/peer/hello/${requestId}`)
253
+ const status = reply.json?.status
254
+ if (status === 'approved' && typeof reply.json.secret === 'string') {
255
+ approvals.set(peer.deviceId, { deviceName: peer.deviceName, secret: reply.json.secret, approvedAt: Date.now() })
256
+ await saveApprovals()
257
+ peer.approved = true
258
+ peer.secret = reply.json.secret
259
+ outgoing.delete(peer.deviceId)
260
+ log.info(`[remote-control] ${peer.deviceName} approved this device`)
261
+ return
262
+ }
263
+ if (status === 'denied') {
264
+ outgoing.delete(peer.deviceId)
265
+ return
266
+ }
267
+ }
268
+ outgoing.delete(peer.deviceId)
269
+ }
270
+
271
+ /* ---------------- inbound: authenticated peer calls ---------------- */
272
+
273
+ async function handlePeer(req, res, url) {
274
+ const pathname = url.pathname
275
+ if (pathname === '/peer/hello' && req.method === 'POST') return createHello(req, res)
276
+ const helloMatch = pathname.match(/^\/peer\/hello\/([0-9a-f]{32})$/)
277
+ if (helloMatch && req.method === 'GET') return helloStatus(res, helloMatch[1])
278
+
279
+ const verdict = verifyPeer(req, url)
280
+ if (!verdict.ok) return json(res, 403, { error: verdict.error })
281
+
282
+ if (pathname === '/peer/sessions' && req.method === 'GET') {
283
+ const value = await sessions.list()
284
+ return json(res, 200, { items: value.map((item) => ({ ...item, title: item.title })) })
285
+ }
286
+ if (pathname === '/peer/stream' && req.method === 'GET') {
287
+ return streamSession(req, res, url)
288
+ }
289
+ if (pathname === '/peer/prompt' && req.method === 'POST') {
290
+ const body = await readJson(req)
291
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
292
+ const text = typeof body?.text === 'string' ? body.text : ''
293
+ if (!sessionId || !text.trim()) return json(res, 400, { error: 'bad-request' })
294
+ const value = await sessions.prompt(sessionId, text)
295
+ return json(res, 200, value)
296
+ }
297
+ if (pathname === '/peer/cancel' && req.method === 'POST') {
298
+ const body = await readJson(req)
299
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
300
+ if (!sessionId) return json(res, 400, { error: 'bad-request' })
301
+ return json(res, 200, sessions.cancel(sessionId))
302
+ }
303
+ return json(res, 404, { error: 'not-found' })
304
+ }
305
+
306
+ function verifyPeer(req, url) {
307
+ const deviceId = String(req.headers['x-dsh-peer-id'] || '')
308
+ const timestamp = String(req.headers['x-dsh-peer-ts'] || '')
309
+ const signature = String(req.headers['x-dsh-peer-sig'] || '')
310
+ if (!deviceId || !timestamp || !signature) return { ok: false, error: 'missing-signature' }
311
+ const approval = approvals.get(deviceId)
312
+ if (!approval) return { ok: false, error: 'unapproved-device' }
313
+ if (!/^\d{1,16}$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > CLOCK_SKEW_MS) {
314
+ return { ok: false, error: 'stale-signature' }
315
+ }
316
+ const expected = sign(approval.secret, deviceId, req.method || 'GET', url.pathname + url.search, timestamp, '')
317
+ const actual = Buffer.from(signature, 'hex')
318
+ const wanted = Buffer.from(expected, 'hex')
319
+ if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) return { ok: false, error: 'bad-signature' }
320
+ return { ok: true }
321
+ }
322
+
323
+ async function streamSession(req, res, url) {
324
+ const sessionId = String(url.searchParams.get('sessionId') || '')
325
+ if (!sessionId) return json(res, 400, { error: 'missing-session' })
326
+ const controller = new AbortController()
327
+ res.writeHead(200, {
328
+ 'content-type': 'text/event-stream; charset=utf-8',
329
+ 'cache-control': 'no-store, no-transform',
330
+ connection: 'keep-alive',
331
+ 'x-accel-buffering': 'no',
332
+ })
333
+ res.write(': connected\n\n')
334
+ const heartbeat = setInterval(() => { try { res.write(': ping\n\n') } catch { /* ignore */ } }, 15000)
335
+ heartbeat.unref?.()
336
+ let closed = false
337
+ const cleanup = () => {
338
+ if (closed) return
339
+ closed = true
340
+ clearInterval(heartbeat)
341
+ try { controller.abort() } catch { /* ignore */ }
342
+ try { res.end() } catch { /* ignore */ }
343
+ }
344
+ req.on('close', cleanup)
345
+ req.on('aborted', cleanup)
346
+ try {
347
+ for await (const frame of sessions.follow(sessionId, controller.signal)) {
348
+ if (res.writableEnded || res.destroyed) break
349
+ res.write(`data: ${JSON.stringify(frame)}\n\n`)
350
+ }
351
+ } catch (error) {
352
+ if (!controller.signal.aborted) {
353
+ try {
354
+ res.write(`data: ${JSON.stringify({ type: 'remote-error', code: error?.code ?? 'error', message: error?.message ?? String(error) })}\n\n`)
355
+ } catch { /* ignore */ }
356
+ }
357
+ } finally {
358
+ cleanup()
359
+ }
360
+ }
361
+
362
+ /* ---------------- outbound: control a peer ---------------- */
363
+
364
+ /**
365
+ * One signed call to a peer over whichever transport reaches it: the relay
366
+ * when the peer was discovered there, direct HTTP for a LAN peer.
367
+ */
368
+ async function peerCall(peer, method, pathname, body, secret) {
369
+ if (peer.proto !== 'relay') {
370
+ return callRaw(peer, method, pathname, body, secret, self().deviceId)
371
+ }
372
+ if (!relay) return { ok: false, status: 503, json: { error: 'relay-offline' } }
373
+ const callerId = self().deviceId
374
+ const timestamp = String(Date.now())
375
+ const headers = { accept: 'application/json' }
376
+ if (secret) {
377
+ headers['x-dsh-peer-id'] = callerId
378
+ headers['x-dsh-peer-ts'] = timestamp
379
+ headers['x-dsh-peer-sig'] = sign(secret, callerId, method, pathname, timestamp, '')
380
+ }
381
+ if (body !== undefined) headers['content-type'] = 'application/json'
382
+ let reply
383
+ try {
384
+ reply = await relay.request(peer.deviceId, {
385
+ method,
386
+ path: pathname,
387
+ headers,
388
+ body: body === undefined ? undefined : JSON.stringify(body),
389
+ })
390
+ } catch (error) {
391
+ return { ok: false, status: 502, json: { error: 'relay-failed', message: error?.message ?? String(error) } }
392
+ }
393
+ let parsed
394
+ try { parsed = reply.body ? JSON.parse(reply.body) : {} } catch { parsed = {} }
395
+ return { ok: reply.status >= 200 && reply.status < 300, status: reply.status, json: parsed }
396
+ }
397
+
398
+ async function callPeer(deviceId, method, pathname, body) {
399
+ syncRelayPeers()
400
+ const peer = peers.get(deviceId)
401
+ if (!peer) return { ok: false, status: 404, json: { error: 'unknown-peer' } }
402
+ const approval = approvals.get(deviceId)
403
+ if (!approval) return { ok: false, status: 403, json: { error: 'not-approved' } }
404
+ return peerCall(peer, method, pathname, body, approval.secret)
405
+ }
406
+
407
+ /** Relay one peer's SSE transcript to a local response (Devices panel). */
408
+ function proxyStream(deviceId, sessionId, res) {
409
+ syncRelayPeers()
410
+ const peer = peers.get(deviceId)
411
+ const approval = approvals.get(deviceId)
412
+ if (!peer || !approval) {
413
+ json(res, 404, { error: 'unknown-peer' })
414
+ return
415
+ }
416
+ const target = `/peer/stream?sessionId=${encodeURIComponent(sessionId)}`
417
+ const timestamp = String(Date.now())
418
+ const callerId = self().deviceId
419
+ if (peer.proto === 'relay') {
420
+ if (!relay) {
421
+ json(res, 503, { error: 'relay-offline' })
422
+ return
423
+ }
424
+ relay.stream(peer.deviceId, {
425
+ path: target,
426
+ headers: {
427
+ accept: 'text/event-stream',
428
+ 'x-dsh-peer-id': callerId,
429
+ 'x-dsh-peer-ts': timestamp,
430
+ 'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
431
+ },
432
+ }, res)
433
+ return
434
+ }
435
+ const upstream = http.request({
436
+ host: peer.address,
437
+ port: peer.port,
438
+ path: target,
439
+ method: 'GET',
440
+ headers: {
441
+ accept: 'text/event-stream',
442
+ 'x-dsh-peer-id': callerId,
443
+ 'x-dsh-peer-ts': timestamp,
444
+ 'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
445
+ },
446
+ }, (response) => {
447
+ res.writeHead(response.statusCode ?? 502, {
448
+ 'content-type': response.headers['content-type'] || 'text/event-stream; charset=utf-8',
449
+ 'cache-control': 'no-store, no-transform',
450
+ connection: 'keep-alive',
451
+ 'x-accel-buffering': 'no',
452
+ })
453
+ response.pipe(res)
454
+ response.on('end', () => { try { res.end() } catch { /* ignore */ } })
455
+ })
456
+ upstream.on('error', () => {
457
+ try { json(res, 502, { error: 'peer-unreachable' }) } catch { /* ignore */ }
458
+ })
459
+ res.on('close', () => { try { upstream.destroy() } catch { /* ignore */ } })
460
+ upstream.end()
461
+ }
462
+
463
+ return {
464
+ start,
465
+ stop,
466
+ connect,
467
+ approveRequest,
468
+ denyRequest,
469
+ callPeer,
470
+ proxyStream,
471
+ handlePeer,
472
+ setRelay(link) { relay = link },
473
+ snapshot() {
474
+ syncRelayPeers()
475
+ const who = self()
476
+ return {
477
+ self: who,
478
+ peers: [...peers.values()].map((peer) => ({
479
+ deviceId: peer.deviceId,
480
+ deviceName: peer.deviceName,
481
+ userId: peer.userId,
482
+ address: peer.address,
483
+ port: peer.port,
484
+ lastSeen: peer.lastSeen,
485
+ approved: approvals.has(peer.deviceId),
486
+ connecting: outgoing.has(peer.deviceId),
487
+ transport: peer.proto === 'relay' ? 'relay' : 'lan',
488
+ })),
489
+ requests: [...requests.values()]
490
+ .filter((request) => request.status === 'pending')
491
+ .map((request) => ({
492
+ requestId: request.requestId,
493
+ deviceId: request.deviceId,
494
+ deviceName: request.deviceName,
495
+ userId: request.userId,
496
+ at: request.at,
497
+ })),
498
+ }
499
+ },
500
+ }
501
+ }
502
+
503
+ /* ---------------- HTTP helpers ---------------- */
504
+
505
+ function sign(secret, deviceId, method, pathname, timestamp, body) {
506
+ return createHmac('sha256', secret)
507
+ .update(`${deviceId}\n${method}\n${pathname}\n${timestamp}\n${body}`)
508
+ .digest('hex')
509
+ }
510
+
511
+ function callRaw(peer, method, pathname, body, secret, callerId) {
512
+ return new Promise((resolve) => {
513
+ const text = body === undefined ? '' : JSON.stringify(body)
514
+ const headers = { accept: 'application/json' }
515
+ if (text) {
516
+ headers['content-type'] = 'application/json'
517
+ headers['content-length'] = Buffer.byteLength(text)
518
+ }
519
+ if (secret && callerId) {
520
+ const timestamp = String(Date.now())
521
+ headers['x-dsh-peer-id'] = callerId
522
+ headers['x-dsh-peer-ts'] = timestamp
523
+ headers['x-dsh-peer-sig'] = sign(secret, callerId, method, pathname, timestamp, '')
524
+ }
525
+ const transport = peer.proto === 'https' ? null : http
526
+ if (!transport) return resolve({ ok: false, error: 'tls-unsupported', json: { error: 'tls-unsupported' } })
527
+ const req = transport.request({
528
+ host: peer.address,
529
+ port: peer.port,
530
+ path: pathname,
531
+ method,
532
+ headers,
533
+ timeout: 15000,
534
+ }, (res) => {
535
+ let data = ''
536
+ res.on('data', (chunk) => { data += chunk })
537
+ res.on('end', () => {
538
+ let parsed
539
+ try { parsed = data ? JSON.parse(data) : {} } catch { parsed = {} }
540
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: parsed })
541
+ })
542
+ })
543
+ req.on('timeout', () => { req.destroy(new Error('timeout')) })
544
+ req.on('error', (error) => resolve({ ok: false, error: error?.message || 'request-failed', json: {} }))
545
+ req.end(text || undefined)
546
+ })
547
+ }
548
+
549
+ function readJson(req) {
550
+ return new Promise((resolve, reject) => {
551
+ const chunks = []
552
+ let size = 0
553
+ req.on('data', (chunk) => {
554
+ size += chunk.length
555
+ if (size > 1024 * 1024) { reject(new Error('too large')); req.destroy(); return }
556
+ chunks.push(chunk)
557
+ })
558
+ req.on('end', () => {
559
+ if (chunks.length === 0) return resolve({})
560
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (error) { reject(error) }
561
+ })
562
+ req.on('error', reject)
563
+ })
564
+ }
565
+
566
+ function json(res, status, value) {
567
+ const body = JSON.stringify(value)
568
+ res.writeHead(status, {
569
+ 'content-type': 'application/json; charset=utf-8',
570
+ 'content-length': Buffer.byteLength(body),
571
+ 'cache-control': 'no-store',
572
+ })
573
+ res.end(body)
574
+ }
575
+
576
+ function delay(ms) {
577
+ return new Promise((resolve) => setTimeout(resolve, ms))
578
+ }