dsh-device-fleet 0.0.0-stage → 0.1.0-dev.10

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.
Files changed (60) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -2
  4. package/client.js +167 -0
  5. package/cordis.patch.yml +26 -0
  6. package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
  7. package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
  8. package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
  9. package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
  10. package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
  11. package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
  12. package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
  13. package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
  14. package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
  15. package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
  16. package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
  17. package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
  18. package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
  19. package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
  20. package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
  21. package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
  22. package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
  23. package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
  24. package/docs/adr/0019-execution-context-configured-locally.md +24 -0
  25. package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
  26. package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
  27. package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
  28. package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
  29. package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
  30. package/docs/notes/approval-inbox-phase.md +31 -0
  31. package/docs/notes/design-decisions-summary.md +49 -0
  32. package/docs/notes/dev-release.md +67 -0
  33. package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
  34. package/docs/notes/network-v1-v2-implementation.md +71 -0
  35. package/docs/notes/pairing-session-2026-11-09.md +30 -0
  36. package/docs/notes/remote-tests-2026-11-09.md +33 -0
  37. package/docs/notes/room-logic-phase.md +31 -0
  38. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  39. package/package.json +51 -3
  40. package/src/host/approval-inbox.js +135 -0
  41. package/src/host/approval-web.js +95 -0
  42. package/src/host/delegation.js +127 -0
  43. package/src/host/direct-call.js +209 -0
  44. package/src/host/execution-slot.js +96 -0
  45. package/src/host/index.js +10 -0
  46. package/src/host/room-service.js +324 -0
  47. package/src/host/store.js +156 -0
  48. package/src/host/tool-policy.js +151 -0
  49. package/src/host/tools.js +590 -0
  50. package/src/index.js +3 -0
  51. package/src/net/direct.js +69 -0
  52. package/src/net/discovery.js +105 -0
  53. package/src/net/fleet-node.js +509 -0
  54. package/src/net/identity.js +167 -0
  55. package/src/net/index.js +9 -0
  56. package/src/net/protocol.js +73 -0
  57. package/src/net/relay-client.js +258 -0
  58. package/src/net/relay-server.js +366 -0
  59. package/src/net/secure-channel.js +538 -0
  60. package/src/plugin/index.js +380 -0
@@ -0,0 +1,105 @@
1
+ // LAN discovery over UDP multicast. Announcements carry only a fleet hash,
2
+ // device id, display name and the direct listening port; the real trust
3
+ // decision happens in the secure-channel handshake.
4
+ import dgram from 'node:dgram'
5
+ import { EventEmitter } from 'node:events'
6
+ import { fleetHash } from './identity.js'
7
+
8
+ export const DEFAULT_DISCOVERY_GROUP = '239.255.42.99'
9
+ export const DEFAULT_DISCOVERY_PORT = 47833
10
+
11
+ export class LanDiscovery extends EventEmitter {
12
+ constructor({
13
+ fleetId,
14
+ deviceId,
15
+ name = 'device',
16
+ directPort,
17
+ group = DEFAULT_DISCOVERY_GROUP,
18
+ port = DEFAULT_DISCOVERY_PORT,
19
+ intervalMs = 3_000,
20
+ }) {
21
+ super()
22
+ this.fleetId = fleetId
23
+ this.deviceId = deviceId
24
+ this.name = name
25
+ this.directPort = directPort
26
+ this.group = group
27
+ this.port = port
28
+ this.intervalMs = intervalMs
29
+ this.socket = null
30
+ this.timer = null
31
+ this.closed = false
32
+ this.peers = new Map()
33
+ }
34
+
35
+ start() {
36
+ if (this.socket) return
37
+ this.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
38
+ this.socket.on('message', (buffer, rinfo) => this.onMessage(buffer, rinfo))
39
+ this.socket.on('error', (error) => this.emit('error', error))
40
+ this.socket.bind(this.port, () => {
41
+ try {
42
+ this.socket.addMembership(this.group)
43
+ this.socket.setMulticastTTL(1)
44
+ this.socket.setMulticastLoopback(true)
45
+ } catch (error) {
46
+ this.emit('error', error)
47
+ }
48
+ this.announce()
49
+ this.timer = setInterval(() => this.announce(), this.intervalMs)
50
+ this.timer.unref?.()
51
+ this.emit('listening', { group: this.group, port: this.port })
52
+ })
53
+ }
54
+
55
+ announce() {
56
+ if (!this.socket || this.closed) return
57
+ const payload = Buffer.from(
58
+ JSON.stringify({
59
+ v: 1,
60
+ fleetHash: fleetHash(this.fleetId),
61
+ deviceId: this.deviceId,
62
+ name: this.name,
63
+ port: this.directPort,
64
+ ts: Date.now(),
65
+ }),
66
+ )
67
+ this.socket.send(payload, 0, payload.length, this.port, this.group, () => {})
68
+ }
69
+
70
+ onMessage(buffer, rinfo) {
71
+ if (this.closed) return
72
+ let message
73
+ try {
74
+ message = JSON.parse(buffer.toString('utf8'))
75
+ } catch {
76
+ return
77
+ }
78
+ if (message?.v !== 1) return
79
+ if (message.fleetHash !== fleetHash(this.fleetId)) return
80
+ if (typeof message.deviceId !== 'string' || message.deviceId === this.deviceId) return
81
+ if (typeof message.port !== 'number' || message.port <= 0) return
82
+ const peer = {
83
+ deviceId: message.deviceId,
84
+ name: typeof message.name === 'string' ? message.name : 'device',
85
+ host: rinfo.address,
86
+ port: message.port,
87
+ lastSeen: Date.now(),
88
+ }
89
+ this.peers.set(peer.deviceId, peer)
90
+ this.emit('peer', peer)
91
+ }
92
+
93
+ close() {
94
+ this.closed = true
95
+ clearInterval(this.timer)
96
+ if (this.socket) {
97
+ try {
98
+ this.socket.close()
99
+ } catch {
100
+ /* ignore */
101
+ }
102
+ this.socket = null
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,509 @@
1
+ // FleetNode: one device's networking facade.
2
+ //
3
+ // It owns the identity, the fleet roster, a direct listener, optional LAN
4
+ // discovery, an optional relay client, and the RPC layer that runs over
5
+ // authenticated SecureChannels.
6
+ import { EventEmitter } from 'node:events'
7
+ import { randomUUID } from 'node:crypto'
8
+ import { createDirectServer, connectDirect } from './direct.js'
9
+ import { LanDiscovery } from './discovery.js'
10
+ import { RelayClient } from './relay-client.js'
11
+ import { SecureChannel } from './secure-channel.js'
12
+ import {
13
+ FRAME,
14
+ PROTOCOL_VERSION,
15
+ eventFrame,
16
+ rpcError,
17
+ rpcRequest,
18
+ rpcResult,
19
+ serializeError,
20
+ } from './protocol.js'
21
+
22
+ const DEFAULT_CONNECT_TIMEOUT_MS = 12_000
23
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
24
+
25
+ export class FleetNode extends EventEmitter {
26
+ /**
27
+ * @param {object} options
28
+ * @param {object} options.identity device identity (generateIdentity)
29
+ * @param {{fleetId: string, fleetKey: string, name?: string}} options.fleet
30
+ * @param {string} [options.name]
31
+ * @param {string|null} [options.accountFingerprint]
32
+ * @param {{host?: string, port?: number}|false} [options.direct] direct listener (false disables)
33
+ * @param {false|{group?: string, port?: number, intervalMs?: number}} [options.discovery]
34
+ * @param {null|{host: string, port: number, dataPort: number, reconnect?: object}} [options.relay]
35
+ * @param {Array<{deviceId: string, edPublic: string, xPublic: string, name?: string}>} [options.roster]
36
+ * @param {boolean} [options.trustOnFirstUse]
37
+ * @param {boolean} [options.acceptRelayConnections]
38
+ */
39
+ constructor({
40
+ identity,
41
+ fleet,
42
+ name = identity?.name ?? 'device',
43
+ accountFingerprint = null,
44
+ direct = { host: '127.0.0.1', port: 0 },
45
+ discovery = false,
46
+ relay = null,
47
+ roster = [],
48
+ peerHints = [],
49
+ trustOnFirstUse = true,
50
+ acceptRelayConnections = true,
51
+ connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
52
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
53
+ pluginVersion = '0.1.0',
54
+ dshVersion = 'unknown',
55
+ capabilities = [],
56
+ } = {}) {
57
+ super()
58
+ if (!identity?.deviceId || !identity.edPrivate || !identity.edPublic) throw new Error('FleetNode needs an identity')
59
+ if (!fleet?.fleetId || !fleet?.fleetKey) throw new Error('FleetNode needs a fleet')
60
+ this.identity = identity
61
+ this.fleet = fleet
62
+ this.name = name
63
+ this.accountFingerprint = accountFingerprint
64
+ this.directConfig = direct
65
+ this.discoveryConfig = discovery
66
+ this.relayConfig = relay
67
+ this.trustOnFirstUse = trustOnFirstUse
68
+ this.acceptRelayConnections = acceptRelayConnections
69
+ this.connectTimeoutMs = connectTimeoutMs
70
+ this.requestTimeoutMs = requestTimeoutMs
71
+ this.pluginVersion = pluginVersion
72
+ this.dshVersion = dshVersion
73
+ this.capabilities = capabilities
74
+
75
+ this.roster = new Map()
76
+ for (const entry of roster) this.roster.set(entry.deviceId, { name: 'device', ...entry })
77
+ this.discovered = new Map()
78
+ this.peerHints = []
79
+ for (const hint of peerHints) this.addPeerHint(hint)
80
+ this.hintTimer = null
81
+ this.hintAttempt = 0
82
+ this.channels = new Map()
83
+ this.pendingRpc = new Map()
84
+ this.requestHandler = null
85
+ this.eventHandler = null
86
+
87
+ this.directServer = null
88
+ this.discovery = null
89
+ this.relayClient = null
90
+ this.started = false
91
+ this.closed = false
92
+ }
93
+
94
+ get directPort() {
95
+ return this.directAddress?.port ?? null
96
+ }
97
+
98
+ rosterEntries() {
99
+ return [...this.roster.values()]
100
+ }
101
+
102
+ upsertRoster(entry) {
103
+ const existing = this.roster.get(entry.deviceId)
104
+ this.roster.set(entry.deviceId, { ...existing, name: 'device', ...entry })
105
+ this.emit('roster', this.rosterEntries())
106
+ }
107
+
108
+ onRequest(handler) {
109
+ this.requestHandler = handler
110
+ }
111
+
112
+ onEvent(handler) {
113
+ this.eventHandler = handler
114
+ }
115
+
116
+ async start() {
117
+ if (this.started) return this
118
+ this.started = true
119
+
120
+ if (this.directConfig !== false) {
121
+ this.directServer = createDirectServer({
122
+ host: this.directConfig.host ?? '0.0.0.0',
123
+ port: this.directConfig.port ?? 0,
124
+ })
125
+ this.directAddress = await this.directServer.ready
126
+ this.directServer.server.on('connection', (socket) => this.acceptInbound(socket, 'direct'))
127
+ }
128
+
129
+ if (this.discoveryConfig !== false && this.discoveryConfig !== undefined) {
130
+ if (!this.directAddress) throw new Error('discovery needs the direct listener')
131
+ const config = this.discoveryConfig === true ? {} : this.discoveryConfig
132
+ this.discovery = new LanDiscovery({
133
+ fleetId: this.fleet.fleetId,
134
+ deviceId: this.identity.deviceId,
135
+ name: this.name,
136
+ directPort: this.directAddress.port,
137
+ group: config.group,
138
+ port: config.port,
139
+ intervalMs: config.intervalMs,
140
+ })
141
+ this.discovery.on('peer', (peer) => {
142
+ this.discovered.set(peer.deviceId, peer)
143
+ this.emit('discovered', peer)
144
+ })
145
+ this.discovery.on('error', (error) => this.emit('warning', error))
146
+ this.discovery.start()
147
+ }
148
+
149
+ if (this.relayConfig) {
150
+ this.relayClient = new RelayClient({
151
+ host: this.relayConfig.host,
152
+ port: this.relayConfig.port,
153
+ dataPort: this.relayConfig.dataPort,
154
+ fleetId: this.fleet.fleetId,
155
+ deviceId: this.identity.deviceId,
156
+ fleetKey: this.fleet.fleetKey,
157
+ reconnect: this.relayConfig.reconnect,
158
+ })
159
+ this.relayClient.on('online', () => this.emit('relay-online'))
160
+ this.relayClient.on('offline', () => this.emit('relay-offline'))
161
+ this.relayClient.on('relay-error', (error) => this.emit('warning', error))
162
+ this.relayClient.on('incoming', (controller) => this.acceptIncomingRelay(controller))
163
+ try {
164
+ await this.relayClient.start(5_000)
165
+ } catch (error) {
166
+ this.emit('warning', error)
167
+ }
168
+ }
169
+
170
+ this.emit('started', { direct: this.directAddress ?? null })
171
+ this.scheduleHintConnects()
172
+ return this
173
+ }
174
+
175
+ /** Remember an address hint for a peer (from an invite or a manual `fleet_connect`). */
176
+ addPeerHint(hint) {
177
+ if (!hint || typeof hint.host !== 'string' || !Number.isInteger(hint.port) || hint.port <= 0) return
178
+ const entry = {
179
+ deviceId: typeof hint.deviceId === 'string' ? hint.deviceId : undefined,
180
+ host: hint.host,
181
+ port: hint.port,
182
+ }
183
+ if (entry.deviceId === this.identity.deviceId) return
184
+ const exists = this.peerHints.some(
185
+ (item) => item.host === entry.host && item.port === entry.port && item.deviceId === entry.deviceId,
186
+ )
187
+ if (!exists) this.peerHints.push(entry)
188
+ }
189
+
190
+ /** Connect to a raw address; learns the peer id from the handshake. */
191
+ async connectAddress(host, port, { expectedDeviceId = null, timeoutMs = this.connectTimeoutMs } = {}) {
192
+ if (!host || !Number.isInteger(port) || port <= 0) throw new Error('connectAddress needs host and port')
193
+ const expected = expectedDeviceId ? { deviceId: expectedDeviceId } : null
194
+ const socket = await connectDirect({ host, port, timeoutMs })
195
+ const channel = this.createInitiator(socket, expected)
196
+ await this.waitReady(channel, timeoutMs)
197
+ return channel.peer
198
+ }
199
+
200
+ /** Retry the invite/manual hints in the background until one peer connects. */
201
+ scheduleHintConnects() {
202
+ if (this.hintTimer || this.closed) return
203
+ const attempt = async () => {
204
+ this.hintTimer = null
205
+ if (this.closed) return
206
+ if (this.peerHints.length === 0 || this.channels.size > 0) return
207
+ try {
208
+ for (const hint of this.peerHints) {
209
+ if (this.closed || this.channels.size > 0) return
210
+ try {
211
+ const peer = await this.connectAddress(hint.host, hint.port, { expectedDeviceId: hint.deviceId })
212
+ this.emit('hint-connected', { hint, peer })
213
+ return
214
+ } catch {
215
+ // try the next hint; the timer below retries
216
+ }
217
+ }
218
+ } finally {
219
+ if (!this.closed && this.channels.size === 0) {
220
+ this.hintAttempt += 1
221
+ const delay = Math.min(10_000, 1000 * 2 ** Math.min(this.hintAttempt, 4))
222
+ this.hintTimer = setTimeout(() => void attempt(), delay)
223
+ this.hintTimer.unref?.()
224
+ }
225
+ }
226
+ }
227
+ this.hintTimer = setTimeout(() => void attempt(), 1500)
228
+ this.hintTimer.unref?.()
229
+ }
230
+
231
+ status() {
232
+ return {
233
+ deviceId: this.identity.deviceId,
234
+ name: this.name,
235
+ pluginVersion: this.pluginVersion,
236
+ dshVersion: this.dshVersion,
237
+ direct: this.directAddress ? { host: this.directAddress.host, port: this.directAddress.port } : null,
238
+ relay: this.relayClient ? { online: this.relayClient.online === true } : null,
239
+ discovery: this.discovery ? { enabled: true, peers: [...this.discovered.keys()] } : { enabled: false, peers: [] },
240
+ roster: this.rosterEntries().map((entry) => ({ deviceId: entry.deviceId, name: entry.name ?? 'device' })),
241
+ connected: this.peerIds(),
242
+ peerHints: [...this.peerHints],
243
+ }
244
+ }
245
+
246
+ async acceptIncomingRelay(controller) {
247
+ if (!this.acceptRelayConnections) {
248
+ controller.reject()
249
+ return
250
+ }
251
+ try {
252
+ const socket = await controller.accept()
253
+ this.acceptInbound(socket, 'relay')
254
+ } catch (error) {
255
+ this.emit('warning', error)
256
+ }
257
+ }
258
+
259
+ acceptInbound(socket, via) {
260
+ const channel = new SecureChannel({
261
+ socket,
262
+ identity: this.identity,
263
+ fleet: this.fleet,
264
+ role: 'responder',
265
+ accountFingerprint: this.accountFingerprint,
266
+ verifyPeer: (peer) => this.authorizePeer(peer),
267
+ pluginVersion: this.pluginVersion,
268
+ dshVersion: this.dshVersion,
269
+ capabilities: this.capabilities,
270
+ })
271
+ this.attachChannel(channel, via)
272
+ }
273
+
274
+ attachChannel(channel, via) {
275
+ channel.on('error', (error) => this.emit('warning', error))
276
+ channel.on('ready', (peer) => {
277
+ this.registerChannel(peer, channel, via)
278
+ })
279
+ channel.on('close', () => {
280
+ const current = this.channels.get(channel.peer?.deviceId)
281
+ if (current === channel) this.channels.delete(channel.peer.deviceId)
282
+ this.rejectPendingForChannel(channel, Object.assign(new Error('peer connection closed'), { code: 'peer-offline' }))
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
+ }
292
+ })
293
+ }
294
+
295
+ registerChannel(peer, channel, via) {
296
+ this.channels.set(peer.deviceId, channel)
297
+ channel.on('frame', (message) => this.onFrame(peer.deviceId, message, channel))
298
+ this.emit('peer', { deviceId: peer.deviceId, name: peer.name, via, channel })
299
+ }
300
+
301
+ authorizePeer(peer) {
302
+ const existing = this.roster.get(peer.deviceId)
303
+ if (existing) {
304
+ return existing.edPublic === peer.edPublic && existing.xPublic === peer.xPublic
305
+ }
306
+ if (!this.trustOnFirstUse) return false
307
+ this.roster.set(peer.deviceId, {
308
+ deviceId: peer.deviceId,
309
+ name: peer.name ?? 'device',
310
+ edPublic: peer.edPublic,
311
+ xPublic: peer.xPublic,
312
+ addedAt: Date.now(),
313
+ })
314
+ this.emit('roster', this.rosterEntries())
315
+ return true
316
+ }
317
+
318
+ async connect(deviceId, { host, port, timeoutMs = this.connectTimeoutMs } = {}) {
319
+ if (typeof deviceId !== 'string' || deviceId === '') throw new Error('connect needs a device id')
320
+ if (deviceId === this.identity.deviceId) throw new Error('cannot connect to self')
321
+ const existing = this.channels.get(deviceId)
322
+ if (existing && !existing.closed) return existing
323
+
324
+ const expected = this.roster.get(deviceId) ?? { deviceId }
325
+ const discovered = this.discovered.get(deviceId)
326
+ const hinted = this.peerHints.find((entry) => entry.deviceId === deviceId)
327
+ const endpoint =
328
+ host && port
329
+ ? { host, port }
330
+ : discovered
331
+ ? { host: discovered.host, port: discovered.port }
332
+ : hinted
333
+ ? { host: hinted.host, port: hinted.port }
334
+ : null
335
+ let directError = null
336
+ if (endpoint) {
337
+ try {
338
+ const socket = await connectDirect({ host: endpoint.host, port: endpoint.port, timeoutMs })
339
+ const channel = this.createInitiator(socket, expected)
340
+ await this.waitReady(channel, timeoutMs)
341
+ return channel
342
+ } catch (error) {
343
+ directError = error
344
+ }
345
+ }
346
+ if (this.relayClient) {
347
+ try {
348
+ const socket = await this.relayClient.requestConnect(deviceId, timeoutMs)
349
+ const channel = this.createInitiator(socket, expected)
350
+ await this.waitReady(channel, timeoutMs)
351
+ return channel
352
+ } catch (error) {
353
+ throw Object.assign(new Error(`cannot reach ${deviceId} via relay: ${String(error.message)}`), {
354
+ code: 'unreachable',
355
+ cause: error,
356
+ })
357
+ }
358
+ }
359
+ throw Object.assign(
360
+ new Error(`cannot reach ${deviceId}${directError ? `: ${String(directError.message)}` : ' (no endpoint)'}`),
361
+ { code: 'unreachable', cause: directError ?? undefined },
362
+ )
363
+ }
364
+
365
+ createInitiator(socket, expectedPeer) {
366
+ const channel = new SecureChannel({
367
+ socket,
368
+ identity: this.identity,
369
+ fleet: this.fleet,
370
+ role: 'initiator',
371
+ expectedPeer,
372
+ accountFingerprint: this.accountFingerprint,
373
+ verifyPeer: (peer) => this.authorizePeer(peer),
374
+ pluginVersion: this.pluginVersion,
375
+ dshVersion: this.dshVersion,
376
+ capabilities: this.capabilities,
377
+ })
378
+ this.attachChannel(channel, 'outbound')
379
+ return channel
380
+ }
381
+
382
+ waitReady(channel, timeoutMs) {
383
+ return new Promise((resolve, reject) => {
384
+ if (channel.authenticated) return resolve(channel)
385
+ const timer = setTimeout(() => {
386
+ cleanup()
387
+ reject(Object.assign(new Error('secure channel handshake timed out'), { code: 'handshake-timeout' }))
388
+ channel.destroy()
389
+ }, timeoutMs)
390
+ timer.unref?.()
391
+ const onReady = () => {
392
+ cleanup()
393
+ resolve(channel)
394
+ }
395
+ const onClose = () => {
396
+ cleanup()
397
+ reject(Object.assign(new Error('secure channel closed during handshake'), { code: 'handshake-closed' }))
398
+ }
399
+ const cleanup = () => {
400
+ clearTimeout(timer)
401
+ channel.off('ready', onReady)
402
+ channel.off('close', onClose)
403
+ }
404
+ channel.once('ready', onReady)
405
+ channel.once('close', onClose)
406
+ })
407
+ }
408
+
409
+ onFrame(deviceId, message, channel) {
410
+ switch (message.t) {
411
+ case FRAME.RPC_REQUEST: {
412
+ const handler = this.requestHandler
413
+ if (typeof handler !== 'function') {
414
+ channel.sendFrame(rpcError({ id: message.id, code: 'no-handler', message: 'no request handler' }))
415
+ return
416
+ }
417
+ Promise.resolve()
418
+ .then(() => handler({ deviceId, method: message.method, params: message.params }))
419
+ .then((result) => channel.sendFrame(rpcResult({ id: message.id, result })))
420
+ .catch((error) => {
421
+ channel.sendFrame(rpcError({ id: message.id, ...serializeError(error) }))
422
+ })
423
+ return
424
+ }
425
+ case FRAME.RPC_RESULT: {
426
+ const pending = this.pendingRpc.get(message.id)
427
+ if (!pending) return
428
+ this.pendingRpc.delete(message.id)
429
+ clearTimeout(pending.timer)
430
+ pending.resolve(message.result)
431
+ return
432
+ }
433
+ case FRAME.RPC_ERROR: {
434
+ const pending = this.pendingRpc.get(message.id)
435
+ if (!pending) return
436
+ this.pendingRpc.delete(message.id)
437
+ clearTimeout(pending.timer)
438
+ const error = new Error(message.error?.message ?? 'remote error')
439
+ error.code = message.error?.code ?? 'remote-error'
440
+ error.data = message.error?.data ?? null
441
+ pending.reject(error)
442
+ return
443
+ }
444
+ case FRAME.EVENT: {
445
+ this.emit('event', { deviceId, topic: message.topic, data: message.data })
446
+ return
447
+ }
448
+ default:
449
+ return
450
+ }
451
+ }
452
+
453
+ rejectPendingForChannel(channel, error) {
454
+ for (const [id, pending] of this.pendingRpc) {
455
+ if (pending.channel === channel) {
456
+ this.pendingRpc.delete(id)
457
+ clearTimeout(pending.timer)
458
+ pending.reject(error)
459
+ }
460
+ }
461
+ }
462
+
463
+ async request(deviceId, method, params, { timeoutMs = this.requestTimeoutMs } = {}) {
464
+ const channel = await this.connect(deviceId)
465
+ const id = randomUUID()
466
+ return new Promise((resolve, reject) => {
467
+ const timer = setTimeout(() => {
468
+ this.pendingRpc.delete(id)
469
+ reject(Object.assign(new Error(`request ${method} to ${deviceId} timed out`), { code: 'timeout' }))
470
+ }, timeoutMs)
471
+ timer.unref?.()
472
+ this.pendingRpc.set(id, { resolve, reject, timer, channel })
473
+ try {
474
+ channel.sendFrame(rpcRequest({ id, method, params }))
475
+ } catch (error) {
476
+ this.pendingRpc.delete(id)
477
+ clearTimeout(timer)
478
+ reject(error)
479
+ }
480
+ })
481
+ }
482
+
483
+ async sendEvent(deviceId, topic, data) {
484
+ const channel = await this.connect(deviceId)
485
+ channel.sendFrame(eventFrame({ topic, data }))
486
+ }
487
+
488
+ peerIds() {
489
+ return [...this.channels.keys()]
490
+ }
491
+
492
+ async close() {
493
+ this.closed = true
494
+ clearTimeout(this.hintTimer)
495
+ this.hintTimer = null
496
+ for (const channel of this.channels.values()) channel.close()
497
+ this.channels.clear()
498
+ for (const [id, pending] of this.pendingRpc) {
499
+ this.pendingRpc.delete(id)
500
+ clearTimeout(pending.timer)
501
+ pending.reject(Object.assign(new Error('node closed'), { code: 'closed' }))
502
+ }
503
+ this.relayClient?.close()
504
+ this.discovery?.close()
505
+ if (this.directServer) await this.directServer.close()
506
+ }
507
+ }
508
+
509
+ export { PROTOCOL_VERSION }