243 lines
7.1 KiB
JavaScript
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()
|