dsh-device-fleet 0.1.0-dev.3 → 0.1.0-dev.31

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,139 @@
1
+ // Read/action HTTP surface for the Fleet console: device overview, room list and
2
+ // detail, room user actions, pairing actions, and approval history.
3
+ const MAX_BODY_BYTES = 128 * 1024
4
+
5
+ function json(res, status, value) {
6
+ res.statusCode = status
7
+ res.setHeader('content-type', 'application/json; charset=utf-8')
8
+ res.setHeader('cache-control', 'no-store')
9
+ res.end(JSON.stringify(value))
10
+ }
11
+
12
+ async function readJsonBody(req) {
13
+ const chunks = []
14
+ let size = 0
15
+ for await (const chunk of req) {
16
+ size += chunk.length
17
+ if (size > MAX_BODY_BYTES) throw Object.assign(new Error('request body is too large'), { code: 'body-too-large' })
18
+ chunks.push(chunk)
19
+ }
20
+ if (chunks.length === 0) return {}
21
+ try {
22
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
23
+ } catch {
24
+ throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
25
+ }
26
+ }
27
+
28
+ function statusFor(error) {
29
+ if (error?.code === 'unknown-room') return 404
30
+ return ['bad-request', 'bad-json', 'body-too-large', 'readonly', 'unknown-member', 'unknown-task'].includes(error?.code)
31
+ ? 400
32
+ : 500
33
+ }
34
+
35
+ function actionRoute(webServer, path, method, run) {
36
+ return webServer.register({
37
+ kind: 'exact',
38
+ path,
39
+ handler: async (req, res) => {
40
+ if (req.method !== method) return json(res, 405, { error: 'method not allowed' })
41
+ try {
42
+ const body = method === 'POST' ? await readJsonBody(req) : {}
43
+ json(res, 200, (await run(body, req)) ?? { ok: true })
44
+ } catch (error) {
45
+ json(res, statusFor(error), { error: String(error?.message ?? error), code: error?.code ?? 'error' })
46
+ }
47
+ },
48
+ })
49
+ }
50
+
51
+ /**
52
+ * @param {object} options
53
+ * @param {object} options.webServer ctx.webServer
54
+ * @param {object} options.inbox ApprovalInbox
55
+ * @param {object} options.rooms RoomService
56
+ * @param {() => object} options.getOverview
57
+ * @param {object} [options.actions] UI actions (room send/task/close, pairing)
58
+ */
59
+ export function registerUiRoutes({ webServer, inbox, rooms, getOverview, actions = null, logger = null }) {
60
+ if (!webServer?.register || !rooms) return () => {}
61
+ const disposers = []
62
+ disposers.push(
63
+ actionRoute(webServer, '/device-fleet/overview', 'GET', async () => ({
64
+ ...getOverview(),
65
+ approvals: inbox?.list?.() ?? [],
66
+ approvalHistory: inbox?.history?.() ?? [],
67
+ })),
68
+ )
69
+ disposers.push(
70
+ actionRoute(webServer, '/device-fleet/rooms', 'GET', async () => ({ rooms: rooms.list() })),
71
+ )
72
+ disposers.push(
73
+ actionRoute(webServer, '/device-fleet/room', 'GET', async (_body, req) => {
74
+ let roomId = ''
75
+ try {
76
+ roomId = new URL(req.url ?? '/', 'http://local').searchParams.get('id') ?? ''
77
+ } catch {
78
+ roomId = ''
79
+ }
80
+ if (roomId === '') throw Object.assign(new Error('room id is required'), { code: 'bad-request' })
81
+ return { room: rooms.detail(roomId, { limit: 100 }) }
82
+ }),
83
+ )
84
+ disposers.push(
85
+ actionRoute(webServer, '/device-fleet/room/send', 'POST', async (body) => {
86
+ if (!actions?.sendRoomMessage) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
87
+ return { ok: true, result: await actions.sendRoomMessage(body ?? {}) }
88
+ }),
89
+ )
90
+ disposers.push(
91
+ actionRoute(webServer, '/device-fleet/room/task', 'POST', async (body) => {
92
+ if (!actions?.roomTask) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
93
+ return { ok: true, result: await actions.roomTask(body ?? {}) }
94
+ }),
95
+ )
96
+ disposers.push(
97
+ actionRoute(webServer, '/device-fleet/room/close', 'POST', async (body) => {
98
+ if (!actions?.closeRoom) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
99
+ return { ok: true, result: await actions.closeRoom(body ?? {}) }
100
+ }),
101
+ )
102
+ disposers.push(
103
+ actionRoute(webServer, '/device-fleet/room/member/remove', 'POST', async (body) => {
104
+ if (!actions?.removeMember) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
105
+ return { ok: true, result: await actions.removeMember(body ?? {}) }
106
+ }),
107
+ )
108
+ disposers.push(
109
+ actionRoute(webServer, '/device-fleet/room/upgrade', 'POST', async (body) => {
110
+ if (!actions?.upgradeRoom) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
111
+ return { ok: true, room: await actions.upgradeRoom(body ?? {}) }
112
+ }),
113
+ )
114
+ disposers.push(
115
+ actionRoute(webServer, '/device-fleet/room/focus', 'POST', async (body) => {
116
+ if (!actions?.setRoomFocus) throw Object.assign(new Error('room actions are unavailable'), { code: 'unsupported' })
117
+ return { ok: true, result: actions.setRoomFocus(body ?? {}) }
118
+ }),
119
+ )
120
+ disposers.push(
121
+ actionRoute(webServer, '/device-fleet/pair/invite', 'POST', async () => {
122
+ if (!actions?.createInvite) throw Object.assign(new Error('pairing actions are unavailable'), { code: 'unsupported' })
123
+ return { ok: true, code: await actions.createInvite() }
124
+ }),
125
+ )
126
+ disposers.push(
127
+ actionRoute(webServer, '/device-fleet/pair/connect', 'POST', async (body) => {
128
+ if (!actions?.connectAddress) throw Object.assign(new Error('pairing actions are unavailable'), { code: 'unsupported' })
129
+ return { ok: true, result: await actions.connectAddress(body ?? {}) }
130
+ }),
131
+ )
132
+ disposers.push(
133
+ actionRoute(webServer, '/device-fleet/approvals/history', 'GET', async () => ({ history: inbox?.history?.() ?? [] })),
134
+ )
135
+ if (logger) logger.debug?.('[device-fleet] UI action routes registered')
136
+ return () => {
137
+ for (const dispose of disposers) dispose()
138
+ }
139
+ }
@@ -281,6 +281,14 @@ export class FleetNode extends EventEmitter {
281
281
  if (current === channel) this.channels.delete(channel.peer.deviceId)
282
282
  this.rejectPendingForChannel(channel, Object.assign(new Error('peer connection closed'), { code: 'peer-offline' }))
283
283
  this.emit('peer-disconnected', { deviceId: channel.peer?.deviceId, via })
284
+ // Recover automatically: the peer may have restarted or moved; retry the
285
+ // known addresses instead of waiting for the next process start.
286
+ if (!this.closed && this.channels.size === 0 && this.peerHints.length > 0) {
287
+ clearTimeout(this.hintTimer)
288
+ this.hintTimer = null
289
+ this.hintAttempt = 0
290
+ this.scheduleHintConnects()
291
+ }
284
292
  })
285
293
  }
286
294
 
@@ -481,6 +489,28 @@ export class FleetNode extends EventEmitter {
481
489
  return [...this.channels.keys()]
482
490
  }
483
491
 
492
+ /** Apply live network changes without recreating the node or its identity. */
493
+ async reconfigure({ name, direct, discovery, relay } = {}) {
494
+ if (this.closed) return
495
+ if (typeof name === 'string' && name !== '') this.name = name
496
+ const networkChanged = direct !== undefined || discovery !== undefined || relay !== undefined
497
+ if (!networkChanged) return
498
+ const wasStarted = this.started
499
+ await this.close()
500
+ this.closed = false
501
+ this.started = false
502
+ this.hintAttempt = 0
503
+ this.discovered.clear()
504
+ if (direct !== undefined) this.directConfig = direct
505
+ if (discovery !== undefined) this.discoveryConfig = discovery
506
+ if (relay !== undefined) this.relayConfig = relay
507
+ this.directServer = null
508
+ this.discovery = null
509
+ this.relayClient = null
510
+ this.directAddress = null
511
+ if (wasStarted) await this.start()
512
+ }
513
+
484
514
  async close() {
485
515
  this.closed = true
486
516
  clearTimeout(this.hintTimer)