import { readFile, writeFile, rename, unlink, mkdir } from 'fs/promises'
import { join, dirname } from 'path'
const CACHE_FILENAME = 'bootstrap-cache.json'
const SAVE_INTERVAL = 15 * 60 * 1000 // 15 minutes
const DEFAULT_MAX_PEERS = 50
export class BootstrapCache {
constructor (storagePath, opts = {}) {
this._storagePath = storagePath
this._filePath = join(storagePath, CACHE_FILENAME)
this._maxPeers = opts.maxPeers || DEFAULT_MAX_PEERS
this._enabled = opts.enabled !== false
this._peers = [] // { host, port, lastSeen }
this._seenPeers = new Map() // 'host:port' -> { host, port, lastSeen }
this._interval = null
this._swarm = null
this._connectionHandler = null
}
async load () {
if (!this._enabled) return
try {
const raw = await readFile(this._filePath, 'utf8')
const data = JSON.parse(raw)
if (data && Array.isArray(data.peers)) {
this._peers = data.peers.slice(0, this._maxPeers)
for (const p of this._peers) {
if (p.host && p.port) {
const key = p.host + ':' + p.port
this._seenPeers.set(key, { host: p.host, port: p.port, lastSeen: p.lastSeen || 0 })
}
}
}
} catch {
// Missing or corrupt cache — start fresh
this._peers = []
}
}
async save () {
if (!this._enabled) return
const peers = this._collectPeers()
const tmpPath = this._filePath + '.tmp'
try {
await mkdir(dirname(this._filePath), { recursive: true })
const data = {
updatedAt: Date.now(),
peers: peers.map(p => ({ host: p.host, port: p.port }))
}
// Atomic write (tmp + rename) so a crash mid-write can't corrupt the
// bootstrap cache.
await writeFile(tmpPath, JSON.stringify(data, null, 2) + '\n')
await rename(tmpPath, this._filePath)
} catch {
// Best-effort — don't crash if write fails. Clean up any partial tmp.
try { await unlink(tmpPath) } catch {}
}
}
merge (configuredBootstrap) {
if (!this._enabled) return configuredBootstrap || undefined
const cachedPeers = this._collectPeers()
if (!cachedPeers.length) return configuredBootstrap || undefined
// If no explicit bootstrap nodes configured (null = use HyperDHT defaults),
// return undefined so Hyperswarm uses its built-in bootstrap servers.
// Cached peers alone should never replace the default bootstrap nodes.
if (!configuredBootstrap || !Array.isArray(configuredBootstrap) || !configuredBootstrap.length) {
return undefined
}
const seen = new Set()
const merged = []
// Configured bootstrap nodes take priority
for (const node of configuredBootstrap) {
const key = node.host + ':' + node.port
if (!seen.has(key)) {
seen.add(key)
merged.push({ host: node.host, port: node.port })
}
}
// Append cached peers as supplementary bootstrap nodes
for (const p of cachedPeers) {
const key = p.host + ':' + p.port
if (!seen.has(key)) {
seen.add(key)
merged.push({ host: p.host, port: p.port })
}
}
return merged.length ? merged : (configuredBootstrap || undefined)
}
start (swarm) {
if (!this._enabled) return
this._swarm = swarm
// Track peers from swarm connections
this._connectionHandler = (conn) => {
const addr = conn.rawStream && conn.rawStream.remoteHost
const port = conn.rawStream && conn.rawStream.remotePort
if (addr && port) {
this._seenPeers.set(addr + ':' + port, { host: addr, port, lastSeen: Date.now() })
}
}
swarm.on('connection', this._connectionHandler)
// Periodically snapshot to disk
this._interval = setInterval(() => {
this.save().catch(err => {
this.emit?.('save-error', { error: err.message })
})
}, SAVE_INTERVAL)
if (this._interval.unref) this._interval.unref()
}
stop () {
if (this._interval) {
clearInterval(this._interval)
this._interval = null
}
if (this._swarm && this._connectionHandler) {
this._swarm.removeListener('connection', this._connectionHandler)
this._connectionHandler = null
}
this._swarm = null
}
_collectPeers () {
const all = Array.from(this._seenPeers.values())
all.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0))
return all.slice(0, this._maxPeers)
}
}