Files
peardata/client/connection.js
T
Raven Scott 015d92a257
Release rolling / release (push) Has been cancelled
CI / test (push) Has been cancelled
first commit
2026-07-18 16:17:38 -04:00

253 lines
6.9 KiB
JavaScript

/**
* 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
}
}