253 lines
6.9 KiB
JavaScript
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
|
|
}
|
|
}
|