/**
* Relay Client — HTTP fast-path for Hyperdrive content
*
* Uses bare-http1/bare-https for HTTP requests (fetch() doesn't exist in Bare).
* Fetches content from HiveRelay gateway endpoints.
*/
const http = require('bare-http1')
const https = require('bare-https')
const b4a = require('b4a')
const { getUserFriendlyError } = require('./hyper-proxy')
const { mergeRelayDirectory } = require('./relay-directory')
const { resolveBootstrapRelays } = require('./relay-record')
const DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
const DEFAULT_MAX_CONTROL_RESPONSE_BYTES = 1024 * 1024
const ENV = (typeof Bare !== 'undefined' && Bare.env) ||
(typeof process !== 'undefined' && process.env) || {}
function relayDefaultPort (parsed) {
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`unsupported relay URL protocol: ${parsed.protocol}`)
}
return parsed.protocol === 'https:' ? 443 : 80
}
function relayTransportForUrl (parsed) {
if (parsed.protocol === 'http:') return http
if (parsed.protocol === 'https:') return https
throw new Error(`unsupported relay URL protocol: ${parsed.protocol}`)
}
function relayRequestOptions (parsed, headers) {
const opts = {
hostname: parsed.hostname,
port: parsed.port ? parseInt(parsed.port, 10) : relayDefaultPort(parsed),
path: parsed.pathname + parsed.search
}
if (headers) opts.headers = headers
return opts
}
function positiveIntegerOption (value, fallback) {
return Number.isSafeInteger(value) && value > 0 ? value : fallback
}
function closeRelayRequest (req) {
req?.destroy?.()
}
function hasHeader (headers, name) {
const wanted = name.toLowerCase()
return Object.keys(headers).some(header => header.toLowerCase() === wanted)
}
class RelayClient {
constructor (opts = {}) {
this.relays = opts.relays || ['http://127.0.0.1:9100']
this.timeout = opts.timeout || 5000
this.enabled = opts.enabled !== false // default on; explicit false disables hybrid fetch
this.apiKey = opts.apiKey || ENV.HIVE_RELAY_API_KEY || null
this._maxResponseBytes = positiveIntegerOption(opts.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES)
this._maxControlResponseBytes = positiveIntegerOption(opts.maxControlResponseBytes, DEFAULT_MAX_CONTROL_RESPONSE_BYTES)
this._stats = { hits: 0, misses: 0, errors: 0 }
// Circuit breaker state per relay
this._circuitBreakers = new Map() // relayUrl -> { failures, lastFailure, open }
this._maxFailures = 3
this._circuitTimeout = 60000 // 1 minute
}
/**
* Reconfigure the relay list at runtime. Clears circuit-breaker state so
* a user-provided URL gets a fresh chance.
*/
setRelays (relays) {
if (!Array.isArray(relays)) throw new TypeError('relays must be an array')
const valid = []
for (const url of relays) {
if (typeof url !== 'string') continue
const clean = url.trim()
if (!clean) continue
// Accept http(s) only
if (!/^https?:\/\//i.test(clean)) continue
valid.push(clean.replace(/\/+$/, ''))
}
if (valid.length === 0) {
console.warn('[RelayClient] setRelays called with no valid urls; keeping current list')
return false
}
this.relays = valid
this._circuitBreakers.clear()
console.log(`[RelayClient] relays updated: ${valid.join(', ')}`)
return true
}
/** Toggle the relay on/off. When off, hybrid fetch falls through to P2P. */
setEnabled (enabled) {
this.enabled = !!enabled
console.log(`[RelayClient] enabled=${this.enabled}`)
}
/** Current config snapshot for UI display. */
getConfig () {
return {
relays: [...this.relays],
enabled: this.enabled,
timeout: this.timeout,
stats: { ...this._stats },
circuitBreakers: Array.from(this._circuitBreakers.entries()).map(([url, cb]) => ({
url,
failures: cb.failures,
open: cb.open,
})),
}
}
/**
* Check if circuit is closed (ok to use)
*/
_checkCircuit (relayUrl) {
const cb = this._circuitBreakers.get(relayUrl)
if (!cb) return true // Circuit closed (ok)
if (cb.open) {
// Check if circuit should close
if (Date.now() - cb.lastFailure > this._circuitTimeout) {
cb.open = false
cb.failures = 0
return true
}
return false // Circuit still open
}
return true
}
/**
* Record successful request - reset circuit breaker
*/
_recordSuccess (relayUrl) {
this._circuitBreakers.delete(relayUrl)
}
/**
* Record failed request - update circuit breaker
*/
_recordFailure (relayUrl) {
let cb = this._circuitBreakers.get(relayUrl)
if (!cb) {
cb = { failures: 0, lastFailure: 0, open: false }
this._circuitBreakers.set(relayUrl, cb)
}
cb.failures++
cb.lastFailure = Date.now()
if (cb.failures >= this._maxFailures) {
cb.open = true
console.warn(`Circuit breaker opened for ${relayUrl}`)
}
}
/**
* Try to fetch a file from any relay gateway with retry logic
* Returns { content, contentType, source } or null
*/
async fetch (keyHex, filePath, retries = 3) {
// When the user has disabled the relay, skip the fast-path entirely
// and let hybrid fetch fall through to P2P
if (!this.enabled) return null
for (const relayUrl of this.relays) {
// Skip if circuit is open
if (!this._checkCircuit(relayUrl)) continue
let lastError = null
for (let attempt = 0; attempt < retries; attempt++) {
try {
const result = await this._httpGet(
`${relayUrl}/v1/hyper/${keyHex}${filePath}`,
this.timeout
)
if (result.status === 200) {
this._recordSuccess(relayUrl)
this._stats.hits++
return {
content: result.body,
contentType: result.contentType,
source: relayUrl
}
}
// Non-200 is not a retryable error
break
} catch (err) {
lastError = err
if (attempt < retries - 1) {
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, attempt) * 1000
await new Promise(r => setTimeout(r, delay))
}
}
}
// All retries failed
this._recordFailure(relayUrl)
}
this._stats.misses++
return null
}
/**
* Health check endpoint that tests all relays
* Returns array of { url, ok, latency } for each relay
*/
async checkHealth () {
const results = []
for (const relayUrl of this.relays) {
const start = Date.now()
try {
const result = await this._httpGet(`${relayUrl}/health`, 3000)
const latency = Date.now() - start
results.push({
url: relayUrl,
ok: result.status === 200,
latency,
circuitOpen: !this._checkCircuit(relayUrl)
})
} catch {
results.push({
url: relayUrl,
ok: false,
latency: Date.now() - start,
circuitOpen: !this._checkCircuit(relayUrl)
})
}
}
return results
}
async checkCapability (relayUrl, timeout = this.timeout) {
const clean = typeof relayUrl === 'string' ? relayUrl.trim().replace(/\/+$/, '') : ''
if (!clean || !/^https?:\/\//i.test(clean)) {
return { ok: false, error: 'invalid relay URL' }
}
try {
const result = await this._httpGet(`${clean}/.well-known/hiverelay.json`, timeout)
if (result.status !== 200) {
return { ok: false, status: result.status, error: `HTTP ${result.status}` }
}
let doc = null
try {
doc = JSON.parse(b4a.toString(result.body, 'utf8'))
} catch {
return { ok: false, status: result.status, error: 'invalid capability JSON' }
}
return { ok: true, status: result.status, doc }
} catch (err) {
return { ok: false, error: err && err.message ? err.message : String(err) }
}
}
async requestSeed (keyHex) {
if (!keyHex || typeof keyHex !== 'string') {
return { ok: false, error: 'requestSeed requires a hex app key' }
}
const headers = { 'Content-Type': 'application/json' }
if (this.apiKey) {
headers.Authorization = `Bearer ${this.apiKey}`
headers['x-api-key'] = this.apiKey
}
const body = JSON.stringify({ appKey: keyHex })
let lastError = 'no relays available'
let lastStatus = 0
let lastRelay = null
for (const relayUrl of this.relays) {
// Skip if circuit is open
if (!this._checkCircuit(relayUrl)) {
lastError = 'circuit open'
lastRelay = relayUrl
continue
}
lastRelay = relayUrl
try {
const result = await this._httpPost(
`${relayUrl}/seed`,
body,
5000,
headers
)
if (result.status >= 200 && result.status < 300) {
this._recordSuccess(relayUrl)
return { ok: true, relay: relayUrl }
}
this._recordFailure(relayUrl)
lastStatus = result.status
const detail = result.body && result.body.length
? b4a.toString(result.body, 'utf8').slice(0, 200)
: ''
lastError = `relay returned HTTP ${result.status}${detail ? `: ${detail}` : ''}`
} catch (err) {
this._recordFailure(relayUrl)
lastError = err && err.message ? err.message : String(err)
}
}
return { ok: false, error: lastError, status: lastStatus, relay: lastRelay }
}
/**
* HTTP GET using bare-http1/bare-https.
*/
_httpGet (url, timeout) {
return new Promise((resolve, reject) => {
const parsed = new URL(url)
const transport = relayTransportForUrl(parsed)
const maxBytes = this._maxResponseBytes
let settled = false
let req = null
const fail = (err) => {
if (settled) return
settled = true
clearTimeout(timer)
reject(err)
}
const done = (value) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(value)
}
const timer = setTimeout(() => {
closeRelayRequest(req)
fail(new Error(getUserFriendlyError('Timeout')))
}, timeout)
try {
req = transport.get(relayRequestOptions(parsed), (res) => {
const chunks = []
let total = 0
res.on('data', (chunk) => {
if (settled) return
total += chunk.byteLength
if (total > maxBytes) {
closeRelayRequest(req)
fail(new Error(`relay response exceeded ${maxBytes} bytes`))
return
}
chunks.push(chunk)
})
res.on('end', () => {
done({
status: res.statusCode,
contentType: (res.headers && res.headers['content-type']) || 'application/octet-stream',
body: b4a.concat(chunks)
})
})
res.on('error', fail)
})
req.on('error', fail)
} catch (err) {
fail(err)
}
})
}
/**
* HTTP POST using bare-http1/bare-https.
*/
_httpPost (url, body, timeout, headers = {}) {
return new Promise((resolve, reject) => {
const parsed = new URL(url)
const transport = relayTransportForUrl(parsed)
const requestHeaders = { 'Content-Type': 'application/json', ...headers }
if (!hasHeader(requestHeaders, 'content-length')) {
requestHeaders['Content-Length'] = String(b4a.byteLength(body))
}
const maxBytes = this._maxControlResponseBytes
let settled = false
let req = null
const fail = (err) => {
if (settled) return
settled = true
clearTimeout(timer)
reject(err)
}
const done = (value) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(value)
}
const timer = setTimeout(() => {
closeRelayRequest(req)
fail(new Error(getUserFriendlyError('Timeout')))
}, timeout)
try {
req = transport.request({
method: 'POST',
...relayRequestOptions(parsed, requestHeaders)
}, (res) => {
const chunks = []
let total = 0
res.on('data', (chunk) => {
if (settled) return
total += chunk.byteLength
if (total > maxBytes) {
closeRelayRequest(req)
fail(new Error(`relay response exceeded ${maxBytes} bytes`))
return
}
chunks.push(chunk)
})
res.on('end', () => {
done({
status: res.statusCode,
body: b4a.concat(chunks)
})
})
res.on('error', fail)
})
req.on('error', fail)
req.write(body)
req.end()
} catch (err) {
fail(err)
}
})
}
/**
* Phase 5 — bootstrap the relay directory from a seed relay, replacing the
* hardcoded list. GETs `<seed>/index/relays` (the relay reverse-proxies this
* to its index sidecar), reads the relay-directory rows, and self-populates
* setRelays() with the discovered gatewayUrls (the seed kept too).
*
* Trust: relay-directory rows carry the full signed capability `doc` +
* `capabilitySig`. If a `verify(doc)` fn is supplied, only rows that verify
* are adopted (the room is an index, not an authority); without one, rows are
* adopted unverified (and flagged). Graceful: a relay with no sidecar returns
* 501/empty → we keep the current list, no error.
*
* @param {string} seedUrl the bootstrap relay's gatewayUrl
* @param {object} [opts]
* @param {(doc:object)=>boolean} [opts.verify] capability-doc verifier
* @returns {{ ok, discovered, adopted, verified, relays }}
*/
async listRelays (seedUrl, opts = {}) {
const seed = typeof seedUrl === 'string' ? seedUrl.trim().replace(/\/+$/, '') : null
if (!seed || !/^https?:\/\//i.test(seed)) {
return { ok: false, error: 'invalid seed url', discovered: 0, adopted: 0, verified: 0, relays: [...this.relays] }
}
let rows = []
try {
const res = await this._httpGet(`${seed}/index/relays?pageSize=500`, this.timeout)
if (res.status !== 200) {
// 501 (no sidecar) / 404 (older relay) — keep the current list as-is.
return { ok: false, status: res.status, discovered: 0, adopted: 0, verified: 0, relays: [...this.relays] }
}
const body = JSON.parse(res.body.toString('utf8'))
rows = Array.isArray(body.rows) ? body.rows : []
} catch (err) {
return { ok: false, error: err.message, discovered: 0, adopted: 0, verified: 0, relays: [...this.relays] }
}
// Always keep the seed in the set so a bad directory never strands us.
const current = this.relays.includes(seed) ? this.relays : [...this.relays, seed]
const { relays, discovered, verified } = mergeRelayDirectory(rows, current, opts.verify)
this.setRelays(relays)
return { ok: true, discovered, adopted: relays.length, verified, relays }
}
/**
* Bootstrap the relay directory from DHT-resolvable relay records (iroh
* adoption — completes the Phase-5 bootstrap). Resolves each seed's pubkey
* over the DHT (hyperdht verifies the signature), merges discovered gateways
* into the relay list (keeping current entries so we never strand), and
* returns the discovered index rooms so the caller can load their catalogues.
* Best-effort: DHT failures leave the current list untouched.
*
* @param {object} dht hyperdht instance (e.g. swarm.dht)
* @param {Array} seeds BOOTSTRAP_RELAYS entries ({ gatewayUrl?, indexRoom?, pubkey? })
* @returns {{ relays: string[], indexRooms: Array }}
*/
async bootstrapFromDht (dht, seeds) {
let result
try {
result = await resolveBootstrapRelays(dht, seeds)
} catch {
return { relays: [...this.relays], indexRooms: [] }
}
if (result.relays.length) {
this.setRelays([...new Set([...this.relays, ...result.relays])])
}
return result
}
addRelay (url) {
if (!this.relays.includes(url)) this.relays.push(url)
}
getStats () {
return { ...this._stats, relays: this.relays.length }
}
/**
* Get circuit breaker status for debugging
* Returns object with relayUrl -> { open, failures }
*/
getCircuitStatus () {
const status = {}
for (const [url, cb] of this._circuitBreakers) {
status[url] = { open: cb.open, failures: cb.failures }
}
return status
}
}
module.exports = { RelayClient, relayRequestOptions }