dsh-device-fleet 0.1.0-dev.2 → 0.1.0-dev.20

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.
@@ -45,6 +45,7 @@ export class FleetNode extends EventEmitter {
45
45
  discovery = false,
46
46
  relay = null,
47
47
  roster = [],
48
+ peerHints = [],
48
49
  trustOnFirstUse = true,
49
50
  acceptRelayConnections = true,
50
51
  connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
@@ -74,6 +75,10 @@ export class FleetNode extends EventEmitter {
74
75
  this.roster = new Map()
75
76
  for (const entry of roster) this.roster.set(entry.deviceId, { name: 'device', ...entry })
76
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
77
82
  this.channels = new Map()
78
83
  this.pendingRpc = new Map()
79
84
  this.requestHandler = null
@@ -163,9 +168,81 @@ export class FleetNode extends EventEmitter {
163
168
  }
164
169
 
165
170
  this.emit('started', { direct: this.directAddress ?? null })
171
+ this.scheduleHintConnects()
166
172
  return this
167
173
  }
168
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
+
169
246
  async acceptIncomingRelay(controller) {
170
247
  if (!this.acceptRelayConnections) {
171
248
  controller.reject()
@@ -204,6 +281,14 @@ export class FleetNode extends EventEmitter {
204
281
  if (current === channel) this.channels.delete(channel.peer.deviceId)
205
282
  this.rejectPendingForChannel(channel, Object.assign(new Error('peer connection closed'), { code: 'peer-offline' }))
206
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
+ }
207
292
  })
208
293
  }
209
294
 
@@ -238,7 +323,15 @@ export class FleetNode extends EventEmitter {
238
323
 
239
324
  const expected = this.roster.get(deviceId) ?? { deviceId }
240
325
  const discovered = this.discovered.get(deviceId)
241
- const endpoint = host && port ? { host, port } : discovered ? { host: discovered.host, port: discovered.port } : null
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
242
335
  let directError = null
243
336
  if (endpoint) {
244
337
  try {
@@ -396,8 +489,32 @@ export class FleetNode extends EventEmitter {
396
489
  return [...this.channels.keys()]
397
490
  }
398
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
+
399
514
  async close() {
400
515
  this.closed = true
516
+ clearTimeout(this.hintTimer)
517
+ this.hintTimer = null
401
518
  for (const channel of this.channels.values()) channel.close()
402
519
  this.channels.clear()
403
520
  for (const [id, pending] of this.pendingRpc) {
@@ -70,7 +70,7 @@ export function createFleet({ name = 'fleet' } = {}) {
70
70
  }
71
71
  }
72
72
 
73
- export function encodeInvite({ fleet, accountFingerprint = null, relay = null }) {
73
+ export function encodeInvite({ fleet, accountFingerprint = null, relay = null, endpoints = [] }) {
74
74
  const payload = {
75
75
  v: 1,
76
76
  fleetId: fleet.fleetId,
@@ -78,6 +78,7 @@ export function encodeInvite({ fleet, accountFingerprint = null, relay = null })
78
78
  name: fleet.name,
79
79
  accountFingerprint,
80
80
  relay,
81
+ endpoints: Array.isArray(endpoints) ? endpoints : [],
81
82
  createdAt: Date.now(),
82
83
  }
83
84
  return INVITE_PREFIX + Buffer.from(JSON.stringify(payload)).toString('base64url')
@@ -99,6 +100,16 @@ export function decodeInvite(code) {
99
100
  fleet: { fleetId: payload.fleetId, fleetKey: payload.fleetKey, name: payload.name ?? 'fleet' },
100
101
  accountFingerprint: payload.accountFingerprint ?? null,
101
102
  relay: payload.relay ?? null,
103
+ endpoints: Array.isArray(payload.endpoints)
104
+ ? payload.endpoints.filter(
105
+ (entry) =>
106
+ entry &&
107
+ typeof entry.host === 'string' &&
108
+ Number.isInteger(entry.port) &&
109
+ entry.port > 0 &&
110
+ (entry.deviceId === undefined || typeof entry.deviceId === 'string'),
111
+ )
112
+ : [],
102
113
  createdAt: payload.createdAt ?? null,
103
114
  }
104
115
  }