Files
peardata/client/manager.js
T
Raven Scott d3edb56062
CI / test (push) Successful in 1m2s
Updates
2026-07-18 20:48:35 -04:00

243 lines
7.1 KiB
JavaScript

/**
* Multi-connection manager with reconnect + active selection.
* PearDock-style: many peers live; one active drives RPC.
*/
import { EventEmitter } from 'events'
import { PearDataConnection } from './connection.js'
import { classifyConnectionInput } from '../shared/crypto-auth.js'
import { setLastActivePeerId } from './peerCache.js'
export class ConnectionManager extends EventEmitter {
constructor() {
super()
/** @type {Map<string, PearDataConnection>} */
this.connections = new Map()
/** @type {PearDataConnection|null} */
this.active = null
/** @type {Map<string, ReturnType<typeof setTimeout>>} */
this._reconnectTimers = new Map()
/** @type {Map<string, object>} */
this._reconnectOpts = new Map()
this.maxReconnectTries =
Number(
(typeof process !== 'undefined' && process.env?.PEARDATA_MAX_RECONNECT) || 20
) || 20
/** @type {Map<string, number>} */
this._tries = new Map()
}
/**
* @param {string} input - public key, pd1 invite, or capability+key object fields
* @param {{
* adminSeed?: string,
* alias?: string,
* autoReconnect?: boolean,
* capability?: string,
* skipActivate?: boolean,
* setActive?: boolean,
* persistActive?: boolean,
* }} [opts]
*/
async connect(input, opts = {}) {
const parsed = typeof input === 'string' ? classifyConnectionInput(input) : input
let publicKeyHex
let capability = opts.capability || null
if (parsed.kind === 'invite') {
publicKeyHex = parsed.publicKeyHex
capability = parsed.capability
} else if (parsed.kind === 'publicKey') {
publicKeyHex = parsed.publicKeyHex
} else if (parsed.publicKeyHex) {
publicKeyHex = parsed.publicKeyHex
capability = parsed.capability || capability
} else {
throw new Error(parsed.error || 'Invalid connection input')
}
publicKeyHex = String(publicKeyHex).toLowerCase()
// Already connected — optionally activate
const existing = this.connections.get(publicKeyHex)
if (existing?.connected) {
const shouldActivate =
opts.skipActivate !== true && opts.setActive !== false
if (shouldActivate) this.setActive(publicKeyHex, { persist: opts.persistActive !== false })
return existing
}
await this.disconnect(publicKeyHex, { forgetReconnect: false })
const connOpts = {
capability,
adminSeed: opts.adminSeed || null,
}
const conn = new PearDataConnection(publicKeyHex, connOpts)
const reconnectOpts = {
...opts,
capability,
adminSeed: opts.adminSeed || null,
autoReconnect: opts.autoReconnect !== false,
skipActivate: true, // never steal active on reconnect
setActive: false,
persistActive: false,
}
this._reconnectOpts.set(publicKeyHex, reconnectOpts)
conn.on('disconnected', () => {
this.emit('disconnected', conn)
if (reconnectOpts.autoReconnect !== false) {
this._scheduleReconnect(publicKeyHex, reconnectOpts)
}
})
conn.on('push', (ev) => this.emit('push', ev, conn))
conn.on('error', (err) => this.emit('error', err, conn))
await conn.connect()
this.connections.set(publicKeyHex, conn)
this._tries.set(publicKeyHex, 0)
// Never auto-activate when skipActivate (boot restore dials many peers first).
const shouldActivate =
opts.skipActivate !== true && opts.setActive !== false
if (shouldActivate) {
this.setActive(publicKeyHex, {
persist: opts.persistActive !== false,
})
}
this.emit('connected', conn)
return conn
}
/**
* @param {string} publicKeyHex
* @param {{ persist?: boolean }} [opts]
*/
setActive(publicKeyHex, opts = {}) {
const id = String(publicKeyHex).toLowerCase()
const conn = this.connections.get(id)
if (!conn) return false
this.active = conn
if (opts.persist !== false) {
try {
setLastActivePeerId(id)
} catch {
// ignore
}
}
this.emit('active', conn)
return true
}
/**
* @param {string} method
* @param {object} [args]
*/
async request(method, args) {
if (!this.active?.connected) {
const err = new Error('No active connection')
err.code = 'NOT_CONNECTED'
throw err
}
return this.active.request(method, args)
}
list() {
return [...this.connections.values()]
}
/**
* Reconnect budget / state for Fleet cards (PearDock-style).
* @param {string} publicKeyHex
*/
getReconnectInfo(publicKeyHex) {
const id = String(publicKeyHex || '').toLowerCase()
const attempts = this._tries.get(id) || 0
const maxAttempts = this.maxReconnectTries
const reconnecting = this._reconnectTimers.has(id)
const failed =
!reconnecting &&
maxAttempts > 0 &&
attempts >= maxAttempts &&
!this.connections.get(id)?.connected
return { attempts, maxAttempts, reconnecting, failed }
}
/**
* @param {string} [publicKeyHex]
* @param {{ forgetReconnect?: boolean }} [opts]
*/
async disconnect(publicKeyHex, opts = {}) {
if (!publicKeyHex) {
for (const id of [...this.connections.keys()]) {
await this.disconnect(id, opts)
}
return
}
const id = String(publicKeyHex).toLowerCase()
const timer = this._reconnectTimers.get(id)
if (timer) {
clearTimeout(timer)
this._reconnectTimers.delete(id)
}
if (opts.forgetReconnect !== false) {
this._reconnectOpts.delete(id)
this._tries.delete(id)
}
const conn = this.connections.get(id)
if (conn) {
this.connections.delete(id)
if (this.active === conn) this.active = null
await conn.destroy()
}
}
/**
* Close sockets but keep reconnect opts / roster for next boot.
*/
async disconnectAll({ forget = false } = {}) {
for (const id of [...this.connections.keys()]) {
await this.disconnect(id, { forgetReconnect: forget })
}
}
_scheduleReconnect(publicKeyHex, opts) {
const id = String(publicKeyHex).toLowerCase()
if (this._reconnectTimers.has(id)) return
const tries = (this._tries.get(id) || 0) + 1
this._tries.set(id, tries)
const max =
Number(opts.maxReconnectTries) > 0
? Number(opts.maxReconnectTries)
: this.maxReconnectTries
if (max > 0 && tries > max) {
this.emit('reconnect-exhausted', { publicKeyHex: id, tries })
return
}
const delay = Math.min(30_000, 1000 * 2 ** Math.min(tries, 5))
const timer = setTimeout(async () => {
this._reconnectTimers.delete(id)
try {
const wasActive = this.active?.publicKeyHex === id
await this.connect(id, {
...opts,
autoReconnect: true,
skipActivate: !wasActive,
setActive: wasActive,
persistActive: wasActive,
})
// Prefer last-active if it comes back and nothing else is active
if (!this.active) this.setActive(id, { persist: false })
} catch (err) {
this.emit('reconnect-failed', { publicKeyHex: id, err, tries })
this._scheduleReconnect(id, opts)
}
}, delay)
this._reconnectTimers.set(id, timer)
}
}
export const manager = new ConnectionManager()