/** * Single server connection via HyperDHT + protomux-rpc. * * Auth modes: * - viewer: public key only * - seed: adminProof HMAC from SERVER_SEED * - capability: HMAC grant (from pa1 invite or direct) */ import DHT from 'hyperdht' import ProtomuxRPC from 'protomux-rpc' import b4a from 'b4a' import { EventEmitter } from 'events' import { PROTOCOL, Pushes, Methods, APP_NAME, APP_VERSION } from '../shared/protocol.js' import { encodings } from '../shared/encodings.js' import { normalizeRpcError, unwrapError } from './errors.js' import { getClientIdentity } from './identity.js' import { createAdminProof } from '../shared/crypto-auth.js' /** * @typedef {object} ConnectionOptions * @property {number} [timeoutMs=30000] * @property {string|null} [capability] * @property {string|null} [adminSeed] */ export class PearDataConnection extends EventEmitter { /** * @param {string} publicKeyHex * @param {ConnectionOptions} [opts] */ constructor(publicKeyHex, opts = {}) { super() if (!/^[0-9a-fA-F]{64}$/.test(publicKeyHex)) { throw new Error('Server public key must be 64 hex characters') } this.publicKeyHex = publicKeyHex.toLowerCase() this.publicKey = b4a.from(this.publicKeyHex, 'hex') this.id = this.publicKeyHex.slice(0, 12) this.timeoutMs = opts.timeoutMs ?? 30000 this.capability = opts.capability || null this.adminSeed = opts.adminSeed || null this.dht = null this.socket = null this.rpc = null this.connected = false this.connectedAt = null this.latency = null /** @type {'idle'|'dialing'|'handshaking'|'ready'|'closed'} */ this.state = 'idle' this.role = null this.authMode = null this.protocolVersion = null this.clientPublicKeyHex = null } async connect() { if (this.connected) return this this.state = 'dialing' try { const identity = getClientIdentity() this.clientPublicKeyHex = identity.publicKeyHex this.dht = new DHT({ keyPair: identity.keyPair }) this.socket = this.dht.connect(this.publicKey) await new Promise((resolve, reject) => { let settled = false const timer = setTimeout(() => { if (!settled) { settled = true cleanup() const err = new Error(`Connection timeout after ${this.timeoutMs}ms`) err.code = 'CONNECTION_TIMEOUT' reject(err) } }, this.timeoutMs) const onOpen = () => { if (settled) return settled = true clearTimeout(timer) cleanup() resolve() } const onError = (err) => { if (settled) return settled = true clearTimeout(timer) cleanup() reject(err) } const onClose = () => { if (settled) return settled = true clearTimeout(timer) cleanup() reject(new Error('Connection closed before open')) } const cleanup = () => { this.socket?.off?.('open', onOpen) this.socket?.off?.('connect', onOpen) this.socket?.off?.('error', onError) this.socket?.off?.('close', onClose) } this.socket.once('open', onOpen) this.socket.once('connect', onOpen) this.socket.once('error', onError) this.socket.once('close', onClose) if (this.socket.publicKey && this.socket.rawStream) onOpen() }) this.rpc = new ProtomuxRPC(this.socket, { id: this.publicKey, protocol: PROTOCOL, ...encodings, }) await this.rpc.fullyOpened?.().catch(() => {}) this._registerPushHandlers() this.socket.on('close', () => this._onDisconnect()) this.socket.on('error', (err) => { this.emit('error', err) if (this.connected) this._onDisconnect() }) this.rpc.on('close', () => this._onDisconnect()) this.state = 'handshaking' const hsArgs = { clientName: APP_NAME, clientVersion: APP_VERSION, } if (this.capability) hsArgs.capability = this.capability if (this.adminSeed) { hsArgs.adminProof = createAdminProof(this.adminSeed, { peerId: this.clientPublicKeyHex, serverPublicKeyHex: this.publicKeyHex, }) } let hs try { hs = await this.request(Methods.handshake, hsArgs) } catch (err) { const root = unwrapError(err) const code = root?.code || err?.code const msg = String(root?.message || err?.message || '') const softSpent = this.capability && (code === 'CAPABILITY_SPENT' || code === 'CAPABILITY_EXPIRED' || /already used or revoked|Capability expired/i.test(msg)) if (softSpent) { this.capability = null const retryArgs = { clientName: APP_NAME, clientVersion: APP_VERSION, } if (this.adminSeed) { retryArgs.adminProof = createAdminProof(this.adminSeed, { peerId: this.clientPublicKeyHex, serverPublicKeyHex: this.publicKeyHex, }) } hs = await this.request(Methods.handshake, retryArgs) } else { throw err } } this.role = hs?.role || null this.authMode = hs?.auth?.mode || null this.protocolVersion = hs?.protocolVersion ?? null this.connected = true this.connectedAt = Date.now() this.state = 'ready' this.emit('connected', hs) return this } catch (err) { this.state = 'closed' await this.destroy().catch(() => {}) throw normalizeRpcError(err, 'connect') } } /** * @param {string} method * @param {object} [args] */ async request(method, args = {}) { if (!this.rpc) { const err = new Error('Not connected') err.code = 'NOT_CONNECTED' throw err } try { const t0 = Date.now() const res = await this.rpc.request(method, args, encodings) this.latency = Date.now() - t0 return res } catch (err) { throw normalizeRpcError(err, method) } } async ping() { return this.request(Methods.ping, {}) } _registerPushHandlers() { for (const push of Object.values(Pushes)) { this.rpc.on(push, (data) => { this.emit('push', { type: push, data }) this.emit(push, data) }) } } _onDisconnect() { if (!this.connected && this.state === 'closed') return const was = this.connected this.connected = false this.state = 'closed' if (was) this.emit('disconnected') } async destroy() { this.connected = false this.state = 'closed' try { this.rpc?.destroy?.() } catch { // ignore } try { this.socket?.destroy?.() } catch { // ignore } try { await this.dht?.destroy?.() } catch { // ignore } this.rpc = null this.socket = null this.dht = null } }