/**
* Hyper Gateway — HTTP endpoint for serving Hyperdrive content
*
* Exposes seeded Hyperdrives over HTTP so mobile clients can fetch
* content without a full P2P connection (fast-path).
*
* When a Corestore is provided via `opts.store`, the gateway creates a
* namespaced session instead of spinning up a separate P2P stack — halving
* memory usage. Falls back to a dedicated Corestore + Hyperswarm when no
* store is given (standalone / backward-compatible mode).
*
* Designed to be mounted on the existing RelayAPI server.
*
* Usage:
* const gateway = new HyperGateway(relayNode, { store: relayNode.store })
* // Add routes to existing API server:
* // if (path.startsWith('/v1/hyper/')) return gateway.handle(req, res, path)
*/
import { openCorestore } from '../core/persistence/storage-root-restore.js'
import Hyperswarm from 'hyperswarm'
import Hyperdrive from 'hyperdrive'
import Hyperblobs from 'hyperblobs'
import { EventEmitter } from 'events'
import { Transform } from 'stream'
import { join } from 'path'
import { isIssuedExactAppContext } from './exact-app-context.js'
import { updateWithTimeout } from '../core/relay-node/cancellable-drive-update.js'
import { admitPublicHiveAppEntry } from './public-app-admission.js'
import { isVerifyRequest, serveVerifyBundle } from './verify-bundle.js'
import {
applyGatewayEdgeHeaders,
buildHivePathLinkHeader,
guardPathLaneStatelessHeaders,
isOnionReadPlaneHost
} from './edge-headers.js'
function stableCoreProofState (core, attempts = 4) {
if (!core) return null
for (let attempt = 0; attempt < attempts; attempt++) {
const forkBefore = Number(core.fork ?? 0)
const lengthBefore = Number(core.length)
const byteLengthBefore = Number(core.byteLength)
const lengthAfter = Number(core.length)
const byteLengthAfter = Number(core.byteLength)
const forkAfter = Number(core.fork ?? 0)
if (Number.isSafeInteger(forkBefore) && forkBefore >= 0 && forkBefore === forkAfter &&
Number.isSafeInteger(lengthBefore) && lengthBefore >= 0 && lengthBefore === lengthAfter &&
Number.isSafeInteger(byteLengthBefore) && byteLengthBefore >= 0 && byteLengthBefore === byteLengthAfter) {
return { fork: forkAfter, length: lengthAfter, byteLength: byteLengthAfter }
}
}
return null
}
function durableDriveProof (entry) {
const proof = {
driveVersion: entry?.storageProvedDriveVersion,
metaLength: entry?.storageProvedMetaLength,
blobLength: entry?.storageProvedBlobLength,
totalBytes: entry?.storageProvedTotalBytes,
metaFork: entry?.storageProvedMetaFork,
blobFork: entry?.storageProvedBlobFork
}
if (entry?.anchored !== true || entry.anchoredLength !== proof.driveVersion ||
!Number.isSafeInteger(proof.driveVersion) || proof.driveVersion <= 0 ||
!Number.isSafeInteger(proof.metaLength) || proof.metaLength < 0 ||
!Number.isSafeInteger(proof.blobLength) || proof.blobLength < 0 ||
!Number.isSafeInteger(proof.totalBytes) || proof.totalBytes < 0 ||
!Number.isSafeInteger(proof.metaFork) || proof.metaFork < 0 ||
!Number.isSafeInteger(proof.blobFork) || proof.blobFork < 0 ||
!Number.isSafeInteger(entry.maxStorage) || proof.totalBytes > entry.maxStorage) return null
return proof
}
function nonClosingCoreView (core) {
return new Proxy(core, {
get (target, property) {
if (property === 'close') return async () => {}
const value = Reflect.get(target, property, target)
return typeof value === 'function' ? value.bind(target) : value
}
})
}
// hyperdrive 11 reports version = max(1, bee length), so a `version === 0`
// emptiness probe can never fire and an unknown drive would be served (and
// LRU-cached) instead of waited out. The metadata core length is the honest
// "nothing replicated yet" signal; the version fallback only shapes stubbed
// drives without a bee core.
function driveHasContent (drive) {
const length = drive?.db?.core?.length
if (Number.isSafeInteger(length)) return length > 0
return Number.isSafeInteger(drive?.version) && drive.version > 0
}
const CONTENT_TYPES = {
html: 'text/html; charset=utf-8',
htm: 'text/html; charset=utf-8',
css: 'text/css; charset=utf-8',
js: 'application/javascript; charset=utf-8',
mjs: 'application/javascript; charset=utf-8',
json: 'application/json; charset=utf-8',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
ico: 'image/x-icon',
webp: 'image/webp',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
mp4: 'video/mp4',
webm: 'video/webm',
mp3: 'audio/mpeg',
wasm: 'application/wasm',
pdf: 'application/pdf',
txt: 'text/plain; charset=utf-8',
md: 'text/markdown; charset=utf-8'
}
const DEFAULT_MAX_CACHED_DRIVES = 20
const MAX_CACHED_DRIVES = 256
const DEFAULT_DRIVE_OPERATION_TIMEOUT = 30_000
const MAX_DRIVE_OPERATION_TIMEOUT = 120_000
const DRIVE_CLOSE_TIMEOUT = 5_000
const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
const MAX_RESPONSE_BYTES = 1024 * 1024 * 1024
const DEFAULT_MAX_TRANSFORM_BYTES = 4 * 1024 * 1024
function guessType (filePath) {
// Extract extension safely, handling edge cases
const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
const filename = filePath.slice(lastSlash + 1)
const lastDot = filename.lastIndexOf('.')
if (lastDot <= 0) return 'application/octet-stream' // No extension or hidden file
const ext = filename.slice(lastDot + 1).toLowerCase()
return CONTENT_TYPES[ext] || 'application/octet-stream'
}
/**
* Parse an HTTP Range header for a single byte range.
*
* Returns:
* { start, end } — inclusive byte offsets, both within [0, totalSize-1]
* null — header absent
* 'unsupported' — unsupported unit or multi-range
* 'invalid' — malformed/unsatisfiable byte range (caller should send 416)
*
* Only single-range requests are supported (no multipart/byteranges).
* Legacy bounded responses may ignore multi-range and unknown units with a
* full 200 as allowed by RFC 9110 §14.2. Exact-byte app responses and
* oversized legacy responses reject them with 416 instead.
* Malformed byte-range syntax is rejected instead of being parsed with
* JavaScript's permissive Number() rules (`1e3`, `+1`, decimals, etc.).
*/
export function parseRange (rangeHeader, totalSize) {
if (!rangeHeader || typeof rangeHeader !== 'string') return null
if (!rangeHeader.startsWith('bytes=')) return 'unsupported'
const spec = rangeHeader.slice(6).trim()
if (!spec) return 'invalid'
if (spec.includes(',')) return 'unsupported' // multi-range unsupported
const dash = spec.indexOf('-')
if (dash === -1) return 'invalid'
const startStr = spec.slice(0, dash).trim()
const endStr = spec.slice(dash + 1).trim()
let start, end
if (startStr === '') {
// Suffix range: bytes=-N (last N bytes)
if (endStr === '') return 'invalid'
const suffix = parseByteCount(endStr, totalSize)
if (suffix === null || suffix <= 0) return 'invalid'
start = Math.max(0, totalSize - suffix)
end = totalSize - 1
} else {
start = parseBytePosition(startStr)
if (start === null) return 'invalid'
if (endStr === '') {
end = totalSize - 1
} else {
end = parseBytePosition(endStr)
if (end === null) return 'invalid'
}
}
if (start > end) return 'invalid'
if (start >= totalSize) return 'invalid'
if (end >= totalSize) end = totalSize - 1
return { start, end }
}
export function selectExactByteRepresentation (payload, rangeHeader) {
const bytes = Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
if (!rangeHeader) {
return { ok: true, status: 200, payload: bytes, contentRange: null, totalSize: bytes.byteLength }
}
const parsed = parseRange(rangeHeader, bytes.byteLength)
if (!parsed || parsed === 'invalid' || parsed === 'unsupported') {
return { ok: false, status: 416, payload: null, contentRange: `bytes */${bytes.byteLength}`, totalSize: bytes.byteLength }
}
return {
ok: true,
status: 206,
payload: bytes.subarray(parsed.start, parsed.end + 1),
contentRange: `bytes ${parsed.start}-${parsed.end}/${bytes.byteLength}`,
totalSize: bytes.byteLength
}
}
function parseBytePosition (value) {
const text = String(value).trim()
if (!/^\d+$/.test(text)) return null
const n = Number(text)
if (!Number.isSafeInteger(n)) return null
return n
}
function parseByteCount (value, maxUsefulCount) {
const text = String(value).trim()
if (!/^\d+$/.test(text)) return null
const n = Number(text)
if (Number.isSafeInteger(n)) return n
// Suffix ranges larger than the resource are equivalent to "the whole
// resource"; avoid unsafe precision while preserving that common behavior.
return maxUsefulCount
}
function decodePathComponent (value) {
try {
return { ok: true, value: decodeURIComponent(value) }
} catch {
return { ok: false, value: null }
}
}
function buildHiveAppLinkHeader (keyHex, filePath) {
const canonical = new URL(`hive://${keyHex}/`)
canonical.pathname = filePath
return `<${canonical.href}>; rel="canonical", </.well-known/hiverelay-app.json>; rel="describedby"`
}
function boundedPositiveInteger (value, fallback, label, max) {
if (value === undefined || value === null) return fallback
if (!Number.isSafeInteger(value) || value < 1 || value > max) {
throw new Error(`${label} must be an integer from 1 to ${max}`)
}
return value
}
function createAbortError (reason) {
if (reason instanceof Error && reason.name === 'AbortError') return reason
const err = new Error(reason instanceof Error ? reason.message : 'Gateway request aborted')
err.name = 'AbortError'
if (reason !== undefined) err.cause = reason
return err
}
function isAbortError (err) {
return err?.name === 'AbortError'
}
class ExactLengthTransform extends Transform {
constructor (expectedBytes) {
super()
this.expectedBytes = expectedBytes
this.seenBytes = 0
}
_transform (chunk, encoding, callback) {
let bytes
try {
bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)
} catch (err) {
callback(err)
return
}
if (bytes.byteLength > this.expectedBytes - this.seenBytes) {
callback(new Error('Gateway stream exceeded declared response length'))
return
}
this.seenBytes += bytes.byteLength
callback(null, bytes)
}
_flush (callback) {
if (this.seenBytes !== this.expectedBytes) {
callback(new Error('Gateway stream ended before declared response length'))
return
}
callback()
}
}
function writeGatewayJson (res, body, status = 200, headers = null, opts = {}) {
const payload = Buffer.from(JSON.stringify(body) + '\n')
writeGatewayBuffer(res, payload, status, headers, opts)
}
function writeGatewayBuffer (res, payload, status = 200, headers = null, opts = {}) {
let explicitCacheControl = false
if (headers) {
for (const [name, value] of Object.entries(headers)) {
if (name.toLowerCase() === 'cache-control') explicitCacheControl = true
res.setHeader(name, value)
}
}
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('X-Content-Type-Options', 'nosniff')
if (!explicitCacheControl) res.setHeader('Cache-Control', 'no-store, max-age=0')
res.setHeader('Content-Length', payload.byteLength)
res.writeHead(status)
res.end(opts.head ? null : payload)
}
/**
* Simple LRU cache for Hyperdrive instances
* Tracks access order and evicts least recently used when limit exceeded
*
* @param {number} [maxSize=20] — maximum number of cached drives
*/
class DriveCache {
constructor (maxSize = 20, onEvict = null) {
this.maxSize = maxSize
this.onEvict = onEvict
this.cache = new Map() // key → { drive, lastAccess }
}
get (key) {
const entry = this.cache.get(key)
if (entry) {
entry.lastAccess = Date.now()
// Re-insert to maintain access order
this.cache.delete(key)
this.cache.set(key, entry)
}
return entry?.drive || null
}
set (key, drive) {
// Evict oldest if at capacity
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
const oldestKey = this.cache.keys().next().value
const oldestEntry = this.cache.get(oldestKey)
this.cache.delete(oldestKey)
if (oldestEntry?.drive && this.onEvict) this.onEvict(oldestEntry.drive)
}
this.cache.delete(key)
this.cache.set(key, { drive, lastAccess: Date.now() })
}
has (key) {
return this.cache.has(key)
}
delete (key) {
this.cache.delete(key)
}
clear () {
this.cache.clear()
}
get size () {
return this.cache.size
}
entries () {
return this.cache.entries()
}
}
export class HyperGateway extends EventEmitter {
constructor (relayNode, opts = {}) {
super()
this.node = relayNode
this._activeOwnedDrives = new Map()
this._retiredOwnedDrives = new Set()
this._driveOpenPromises = new Map()
this._driveOpenControllers = new Map()
this._driveOpenStates = new Map()
this._driveRefreshPromises = new Map()
this._seededDriveUpdateStates = new Map()
this._driveClosePromises = new Set()
this._ownedDriveTopics = new Map()
this._ownedTopicRefs = new Map()
this._pendingRequestCleanups = new Set()
this._activeRequestStates = new Set()
this._closing = false
this._closePromise = null
if (opts.requireLifecycleDriveAuthority !== undefined && typeof opts.requireLifecycleDriveAuthority !== 'boolean') {
throw new Error('requireLifecycleDriveAuthority must be boolean')
}
// RelayNode-mounted routes may borrow only AppLifecycle-owned drives whose
// persisted storage proof names the immutable version. Standalone gateway
// mode keeps its explicit dynamic-open authority separate.
this._requireLifecycleDriveAuthority = opts.requireLifecycleDriveAuthority === true
const maxCachedDrives = boundedPositiveInteger(
opts.maxCachedDrives,
DEFAULT_MAX_CACHED_DRIVES,
'maxCachedDrives',
MAX_CACHED_DRIVES
)
this._drives = new DriveCache(maxCachedDrives, drive => this._retireOwnedDrive(drive))
this._totalRequests = 0
this._totalBytesServed = 0
this._driveOperationTimeout = boundedPositiveInteger(
opts.driveOperationTimeout,
DEFAULT_DRIVE_OPERATION_TIMEOUT,
'driveOperationTimeout',
MAX_DRIVE_OPERATION_TIMEOUT
)
const configuredMaxResponseBytes = opts.maxResponseBytes !== undefined
? opts.maxResponseBytes
: relayNode.config?.gatewayMaxResponseBytes
if (configuredMaxResponseBytes === null) throw new Error('maxResponseBytes must be finite and non-null')
this._maxResponseBytes = boundedPositiveInteger(
configuredMaxResponseBytes,
DEFAULT_MAX_RESPONSE_BYTES,
'maxResponseBytes',
MAX_RESPONSE_BYTES
)
this._maxTransformBytes = boundedPositiveInteger(
opts.maxTransformBytes ?? relayNode.config?.gatewayMaxTransformBytes,
Math.min(DEFAULT_MAX_TRANSFORM_BYTES, this._maxResponseBytes),
'maxTransformBytes',
this._maxResponseBytes
)
// If a Corestore is provided (e.g. the relay node's store), reuse that
// same store so gateway reads hit the already-seeded Hyperdrive cores.
// A separate namespace would isolate the data and make anchored drives
// appear unavailable to the HTTP gateway.
this._externalStore = opts.store || null
this._store = null
this._swarm = null
this._ownsSwarm = false
this._ready = false
this._readyPromise = null
this._readyController = null
// Federated signed denylist (takedown channel). Defaults to the relay
// node's shared store; a standalone gateway can take an explicit
// opts.denylist (null disables enforcement). Every verified entry added
// anywhere — local operator issue or federation gossip — purges the
// drive from this gateway's caches and stops its in-flight streams.
this._denylist = opts.denylist === undefined
? (relayNode && relayNode.gatewayDenylist) || null
: opts.denylist
this._onDenylistEntryAdded = null
this._armDenylistListener()
}
// The purge listener is armed lazily and disarmed on close: the node-level
// denylist outlives any single gateway instance across restarts, so a
// closed gateway must not keep a stale listener (or its drive closures)
// reachable through it. handle() re-arms on the next request.
_armDenylistListener () {
if (this._onDenylistEntryAdded || !this._denylist || typeof this._denylist.on !== 'function') return
this._onDenylistEntryAdded = () => this._purgeDeniedDrives()
this._denylist.on('entry-added', this._onDenylistEntryAdded)
}
_disarmDenylistListener () {
if (!this._onDenylistEntryAdded || !this._denylist) return
this._denylist.removeListener('entry-added', this._onDenylistEntryAdded)
this._onDenylistEntryAdded = null
}
/**
* Wrap a promise with a timeout
*/
_withTimeout (promise, ms, context, signal = null) {
let timer = null
let abortHandler = null
return new Promise((resolve, reject) => {
let settled = false
const source = Promise.resolve(promise)
const done = (fn, value) => {
if (settled) return
settled = true
if (timer) {
clearTimeout(timer)
timer = null
}
if (signal && abortHandler) signal.removeEventListener('abort', abortHandler)
fn(value)
}
// Always observe the source, even when the signal was already aborted;
// otherwise a later source rejection becomes process-level unhandled
// rejection noise after the HTTP request is gone.
source.then(
value => done(resolve, value),
err => done(reject, err)
)
if (signal?.aborted) {
done(reject, createAbortError(signal.reason))
return
}
timer = setTimeout(() => {
timer = null
done(reject, new Error(`${context} timed out after ${ms}ms`))
}, ms)
if (timer.unref) timer.unref()
if (signal) {
abortHandler = () => done(reject, createAbortError(signal.reason))
signal.addEventListener('abort', abortHandler, { once: true })
if (signal.aborted) abortHandler()
}
})
}
/**
* Initialize the gateway's own P2P stack for content delivery.
* Called automatically on first request, or can be called explicitly.
*/
async _ensureReady (signal = null) {
if (this._closing) throw createAbortError('Gateway is closing')
if (this._ready) return
if (this._readyPromise) {
return this._withTimeout(
this._readyPromise,
this._driveOperationTimeout,
'gateway initialization',
signal
)
}
const controller = new AbortController()
const operationSignal = controller.signal
const pending = (async () => {
try {
if (this._externalStore) {
this._store = this._externalStore
await this._withTimeout(
this._store.ready(),
this._driveOperationTimeout,
'external Corestore.ready()',
operationSignal
)
// The relay node's swarm already calls store.replicate(conn), so no
// extra swarm is needed for gateway reads.
} else {
// Standalone / backward-compatible mode: own store + swarm
const storagePath = this.node.config
? join(this.node.config.storage || './storage', 'gateway-store')
: './gateway-store'
this._store = openCorestore(storagePath)
await this._withTimeout(
this._store.ready(),
this._driveOperationTimeout,
'gateway Corestore.ready()',
operationSignal
)
this._swarm = new Hyperswarm()
this._swarm.on('connection', (conn) => this._store.replicate(conn))
this._ownsSwarm = true
}
if (this._closing || operationSignal.aborted) throw createAbortError(operationSignal.reason || 'Gateway is closing')
this._ready = true
this.emit('ready')
} catch (err) {
if (this._ownsSwarm && this._swarm) {
try {
await this._withTimeout(
this._swarm.destroy(),
Math.min(DRIVE_CLOSE_TIMEOUT, this._driveOperationTimeout),
'failed swarm.destroy()'
)
} catch {}
}
if (this._store && !this._externalStore) {
try {
await this._withTimeout(
this._store.close(),
Math.min(DRIVE_CLOSE_TIMEOUT, this._driveOperationTimeout),
'failed Corestore.close()'
)
} catch {}
}
this._store = null
this._swarm = null
this._ownsSwarm = false
throw err
}
})()
this._readyPromise = pending
this._readyController = controller
pending.finally(() => {
if (this._readyPromise === pending) this._readyPromise = null
if (this._readyController === controller) this._readyController = null
}).catch(() => {})
return this._withTimeout(
pending,
this._driveOperationTimeout,
'gateway initialization',
signal
)
}
/**
* Handle an HTTP request for Hyperdrive content
* Path format: /v1/hyper/KEY/file/path
*/
async handle (req, res, context = null) {
if (this._closing) {
if (!res.destroyed && !res.writableEnded) {
writeGatewayJson(res, { error: 'Gateway shutting down' }, 503, { 'Retry-After': '1' }, {
head: req.method === 'HEAD'
})
}
return
}
this._armDenylistListener()
const controller = new AbortController()
let handlerSettled = false
let responseSettled = res.destroyed || res.writableEnded || res.writableFinished
let resolveSettled
const settled = new Promise(resolve => { resolveSettled = resolve })
const state = { controller, res, settled, forceResponseSettlement: null, keyHex: null }
this._activeRequestStates.add(state)
const maybeSettle = () => {
if (!handlerSettled || !responseSettled) return
this._activeRequestStates.delete(state)
resolveSettled()
}
const onFinish = () => {
responseSettled = true
maybeSettle()
}
const onClose = () => {
responseSettled = true
if (!res.writableFinished && !controller.signal.aborted) {
controller.abort(createAbortError('Client disconnected'))
}
maybeSettle()
}
state.forceResponseSettlement = () => {
responseSettled = true
maybeSettle()
}
res.once('finish', onFinish)
res.once('close', onClose)
try {
await this._handleRequest(req, res, context, controller.signal, state)
} finally {
handlerSettled = true
maybeSettle()
}
}
async _handleRequest (req, res, context = null, signal = null, requestState = null) {
const url = new URL(req.url, 'http://localhost')
const path = url.pathname
const exactBytes = isIssuedExactAppContext(context)
// R3/R5/R6 edge policy (edge-headers.js documents the lane × ingress
// matrix): the shared-origin path lane is stateless-only — Service-Worker-
// Allowed is structurally stripped at response commit; COOP/CORP/
// Referrer-Policy land on every gateway response; onion ingress adds its
// restrictive CSP default.
if (!exactBytes) guardPathLaneStatelessHeaders(res)
applyGatewayEdgeHeaders(res, {
exactBytes,
onionIngress: isOnionReadPlaneHost(req.headers && req.headers.host)
})
if (context !== null && !exactBytes) {
writeGatewayJson(res, { error: 'Gateway request context is not authorized' }, 403)
return
}
// R1 verifiable retrieval: ?verify=1 (or the hc-block Accept type) returns a
// proof bundle instead of raw bytes — same admission chain, same frozen limits.
const verifyMode = isVerifyRequest(url, req.headers)
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('Allow', 'GET, HEAD')
const headers = exactBytes
? {
'X-Hive-App-Key': context?.appKey,
'X-Hive-Byte-Mode': 'generated',
Vary: 'Host',
'Origin-Agent-Cluster': '?1'
}
: null
writeGatewayJson(res, { error: 'Method Not Allowed' }, 405, headers)
return
}
const isHead = req.method === 'HEAD'
const sendJson = (body, status = 200, headers = null) => {
const responseHeaders = exactBytes
? {
...(headers || {}),
'X-Hive-App-Key': context?.appKey,
'X-Hive-Byte-Mode': 'generated',
Vary: 'Host',
'Origin-Agent-Cluster': '?1'
}
: headers
writeGatewayJson(res, body, status, responseHeaders, { head: isHead })
}
let keyHex
let filePath
if (context) {
keyHex = context.appKey
filePath = context.path || '/'
} else {
// Parse: /v1/hyper/KEY/path
const prefix = '/v1/hyper/'
if (!path.startsWith(prefix)) {
sendJson({ error: 'Invalid path' }, 400)
return
}
const rest = path.slice(prefix.length)
const slashIdx = rest.indexOf('/')
keyHex = slashIdx === -1 ? rest : rest.slice(0, slashIdx)
filePath = slashIdx === -1 ? '/' : rest.slice(slashIdx)
}
if (typeof filePath !== 'string' || !filePath.startsWith('/')) {
sendJson({ error: 'Invalid path' }, 400)
return
}
// Reject path traversal attempts
// Block: .. (parent dir), null bytes, absolute paths, URL-encoded variants
const decoded = decodePathComponent(filePath)
if (!decoded.ok) {
sendJson({ error: 'Malformed path encoding' }, 400)
return
}
const decodedPath = decoded.value
const doubleDecoded = decodePathComponent(decodedPath)
if (!doubleDecoded.ok) {
sendJson({ error: 'Malformed path encoding' }, 400)
return
}
const doubleDecodedPath = doubleDecoded.value
if (
decodedPath.includes('..') ||
doubleDecodedPath.includes('..') ||
filePath.includes('\x00') ||
decodedPath.includes('\x00') ||
/^[a-zA-Z]:/.test(decodedPath) // Windows absolute paths
) {
sendJson({ error: 'Forbidden: path traversal rejected' }, 403)
return
}
if (!keyHex || keyHex.length !== 64 || !/^[0-9a-f]+$/i.test(keyHex)) {
sendJson({ error: 'Invalid drive key' }, 400)
return
}
keyHex = keyHex.toLowerCase()
if (requestState) requestState.keyHex = keyHex
// Federated signed denylist — fail closed BEFORE any existence-specific
// response or drive lookup, on both the /v1/hyper path lane and the
// exact app-origin lane. 451 Unavailable For Legal Reasons is honest
// signaling here: the channel is public by design (unlike the outboxlog
// DO-NOT-SERVE tombstone, whose opaque-id suppression is deliberately
// indistinguishable from absence).
const deniedEntry = this._deniedDenylistEntry(keyHex)
if (deniedEntry) {
sendJson({
error: 'Unavailable For Legal Reasons',
takedown: true,
reason: deniedEntry.reason
}, 451)
return
}
if (exactBytes) {
res.setHeader('X-Hive-App-Key', keyHex)
res.setHeader('Vary', 'Host')
res.setHeader('Origin-Agent-Cluster', '?1')
res.setHeader('Cache-Control', 'no-store, max-age=0')
res.setHeader('Link', buildHiveAppLinkHeader(keyHex, filePath))
} else {
// R7 upgrade hint on the shared-origin lane: a capable client can leave
// HTTPS for the canonical native P2P scheme — including on the blind/tier
// 403s below, whose bodies already say exactly that.
res.setHeader('Link', buildHivePathLinkHeader(keyHex, filePath))
}
// Exact app hosts fail closed before any existence-specific response. A
// public caller must not be able to distinguish an unknown key from a
// locally seeded private/custody key.
const appEntry = this.node.seededApps && this.node.seededApps.get(keyHex)
if (exactBytes && !admitPublicHiveAppEntry(appEntry, {
appKey: keyHex,
publicAppKeys: context?.publicAppKeys
}).allowed) {
sendJson({
error: 'App unavailable through public Hive gateway',
hint: 'Use the app\'s authorized native P2P transport'
}, 403)
return
}
let storageProvedDriveVersion = null
if (this._requireLifecycleDriveAuthority) {
storageProvedDriveVersion = appEntry?.storageProvedDriveVersion
if (!Number.isSafeInteger(storageProvedDriveVersion) || storageProvedDriveVersion <= 0) {
sendJson({ error: 'Drive not available yet — still replicating' }, 404)
return
}
if (exactBytes && storageProvedDriveVersion !== context?.driveVersion) {
sendJson({
error: 'App unavailable through public Hive gateway',
hint: 'Use the app\'s authorized native P2P transport'
}, 403)
return
}
}
// Preserve the legacy path gateway's explicit not-seeded response. An
// exact host reaches this only after local approval and public admission.
if (this.node.seededApps && !this.node.seededApps.has(keyHex)) {
sendJson({ error: 'Drive not seeded on this relay' }, 404)
return
}
// Blind apps: relay has encrypted ciphertext, can't serve over HTTP
if (appEntry && appEntry.blind) {
sendJson({
error: 'Private app — encrypted content, P2P access only',
blind: true,
hint: 'Use PearBrowser or Hyperswarm to access this app with the encryption key'
}, 403)
return
}
const privacyTier = String(appEntry?.privacyTier || 'public').toLowerCase()
if (this.node.config?.gatewayPublicOnlyPrivacyTier !== false && privacyTier !== 'public') {
sendJson({
error: 'Gateway access blocked by privacy tier policy',
privacyTier,
hint: 'Use direct P2P access for non-public apps'
}, 403)
return
}
if (this.node.policyGuard && appEntry) {
const policy = this.node.policyGuard.check(keyHex, privacyTier, 'serve-code')
if (!policy.allowed) {
if (exactBytes) {
sendJson({
error: 'App unavailable through public Hive gateway',
hint: 'Use the app\'s authorized native P2P transport'
}, 403)
} else {
sendJson({
error: 'PolicyGuard blocked this app',
privacyTier,
reason: policy.reason
}, 403)
}
return
}
}
this._totalRequests++
let requestDriveLease = null
try {
const driveLease = await this._acquireDrive(keyHex, {
requireSeeded: exactBytes || this._requireLifecycleDriveAuthority,
signal
})
if (!driveLease) {
sendJson({ error: 'Drive not available yet — still replicating' }, 404)
return
}
const drive = driveLease.drive
requestDriveLease = this._holdDriveLeaseForRequest(res, driveLease)
let readDrive = drive
if (driveLease.immutable === true) {
requestDriveLease.setCheckout(readDrive)
res.setHeader('X-Hive-Drive-Version', String(driveLease.pinnedVersion))
} else if (exactBytes || this._requireLifecycleDriveAuthority) {
if (typeof drive.checkout !== 'function') {
throw new Error('Immutable drive checkout is unavailable')
}
const pinnedVersion = exactBytes
? (context?.driveVersion == null ? null : context.driveVersion)
: storageProvedDriveVersion
if (pinnedVersion != null && (!Number.isSafeInteger(pinnedVersion) || pinnedVersion < 0)) {
throw new Error('Configured public drive version is invalid')
}
if (
!this._requireLifecycleDriveAuthority &&
typeof drive.update === 'function' &&
(pinnedVersion == null || !Number.isSafeInteger(drive.version) || drive.version < pinnedVersion)
) {
// A timeout must detach the underlying Hypercore upgrade refs, not
// merely release the HTTP request slot while replication keeps going.
await this._updateSeededDrive(keyHex, drive, signal)
}
const latestVersion = drive.version
if (!Number.isSafeInteger(latestVersion) || latestVersion < 0) throw new Error('Drive version unavailable for exact snapshot')
if (pinnedVersion != null && latestVersion < pinnedVersion) {
if (this._requireLifecycleDriveAuthority) {
sendJson({ error: 'Drive not available yet — still replicating' }, 404)
return
}
throw new Error('Configured public drive version is not available')
}
const version = pinnedVersion ?? latestVersion
readDrive = drive.checkout(version)
requestDriveLease.setCheckout(readDrive)
await this._withTimeout(readDrive.ready(), this._driveOperationTimeout, 'drive.checkout().ready()', signal)
res.setHeader('X-Hive-Drive-Version', String(version))
}
// Resolve directory → index.html (with timeout)
if (filePath.endsWith('/') || filePath === '') {
const entry = await this._withTimeout(
readDrive.entry((filePath || '/') + 'index.html'),
this._driveOperationTimeout,
'drive.entry()',
signal
).catch(() => null)
if (entry) {
filePath = (filePath || '/') + 'index.html'
} else {
if (verifyMode) {
// A directory listing is generated JSON — no single block to prove.
sendJson({ error: 'Verify mode proves file blocks only — request a file path', path: filePath || '/' }, 400)
return
}
// Directory listing
await this._serveDirectoryListing(res, readDrive, keyHex, filePath || '/', {
head: isHead,
byteMode: exactBytes ? 'generated' : null,
rangeHeader: req.headers && req.headers.range,
signal,
reserveResponseBytes: context?.reserveResponseBytes,
egressRetryAfterSeconds: context?.egressRetryAfterSeconds
})
return
}
}
// Check that the file exists via entry() — also gives us byte length
const entry = await this._withTimeout(
readDrive.entry(filePath),
this._driveOperationTimeout,
'drive.entry()',
signal
)
if (!entry || !entry.value.blob) {
sendJson({ error: 'File not found', path: filePath }, 404)
return
}
const contentType = guessType(filePath)
const byteLength = entry.value.blob.byteLength
if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
throw new Error('Drive entry has an invalid byte length')
}
// Verifiable retrieval mode: proof bundle instead of raw bytes. Admission
// (seeded/blind/tier/PolicyGuard) already ran; byte caps mirror the raw lane.
if (verifyMode) {
await serveVerifyBundle(this, res, readDrive, keyHex, filePath, entry, byteLength, {
head: isHead,
exactBytes,
rangeHeader: req.headers && req.headers.range,
driveVersion: readDrive.version,
signal,
sendJson,
reserveResponseBytes: context?.reserveResponseBytes,
egressRetryAfterSeconds: context?.egressRetryAfterSeconds
})
return
}
res.setHeader('Content-Type', contentType)
res.setHeader('X-Hyper-Key', keyHex)
res.setHeader('X-Served-By', 'hiverelay-gateway')
res.setHeader('Cache-Control', exactBytes ? 'no-store, max-age=0' : 'public, max-age=60')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Accept-Ranges', 'bytes')
if (exactBytes) {
res.setHeader('X-Hive-App-Key', keyHex)
res.setHeader('X-Hive-Byte-Mode', 'exact')
res.setHeader('Vary', 'Host')
res.setHeader('Origin-Agent-Cluster', '?1')
}
// HTML needs base-URL rewriting so Vite-built apps resolve assets
// through the gateway. /assets/foo.js → ./assets/foo.js
// This requires buffering the full response (typically small).
// Range requests on HTML are not supported (rewriting needs full content).
if (contentType.includes('text/html') && !exactBytes) {
if (byteLength > this._maxTransformBytes) {
sendJson({
error: 'Response exceeds gateway transform limit',
maxResponseBytes: this._maxTransformBytes
}, 413)
return
}
res.setHeader('X-Hive-Byte-Mode', 'transformed')
const content = await this._withTimeout(
readDrive.get(filePath),
this._driveOperationTimeout,
'drive.get()',
signal
)
if (!content) {
sendJson({ error: 'File not found', path: filePath }, 404)
return
}
if (!Buffer.isBuffer(content) || content.byteLength > this._maxTransformBytes) {
throw new Error('Drive content exceeded its bounded transform input')
}
let html = content.toString('utf-8')
html = html.replace(/href="\//g, 'href="./')
.replace(/src="\//g, 'src="./')
.replace(/href='\//g, "href='./")
.replace(/src='\//g, "src='./")
const buf = Buffer.from(html)
if (buf.byteLength > this._maxResponseBytes) {
sendJson({
error: 'Response exceeds gateway byte limit',
maxResponseBytes: this._maxResponseBytes
}, 413)
return
}
res.writeHead(200, { 'Content-Length': buf.length })
if (isHead) {
res.end()
return
}
this._totalBytesServed += buf.length
res.end(buf)
this.emit('served', { keyHex, filePath, bytes: buf.length })
return
}
// ─── Range request handling ─────────────────────────────────
// Stream non-HTML content directly — avoids buffering large
// binaries (images, WASM, video, etc.) in memory (Fix 2.2).
// Also supports HTTP byte ranges so browsers can seek in
// video/audio without pulling the whole file.
const rangeHeader = req.headers && req.headers.range
let streamOpts = null
let statusCode = 200
let responseLength = byteLength
if (rangeHeader && byteLength != null) {
const parsed = parseRange(rangeHeader, byteLength)
if (parsed === 'invalid' || (parsed === 'unsupported' && exactBytes)) {
res.setHeader('Content-Range', `bytes */${byteLength}`)
sendJson({ error: 'Range Not Satisfiable' }, 416)
return
}
if (parsed && parsed !== 'unsupported') {
const { start, end } = parsed
streamOpts = { start, length: end - start + 1 }
statusCode = 206
responseLength = end - start + 1
res.setHeader('Content-Range', `bytes ${start}-${end}/${byteLength}`)
} else if (parsed === 'unsupported' && byteLength > this._maxResponseBytes) {
// Unknown units and multi-ranges may normally be ignored, but doing
// so for an oversized object would turn attacker input into the full
// unbounded 200 response the byte ceiling is meant to prevent.
res.setHeader('Content-Range', `bytes */${byteLength}`)
sendJson({
error: 'A bounded single byte range is required',
maxResponseBytes: this._maxResponseBytes
}, 416)
return
}
}
if (responseLength > this._maxResponseBytes) {
if (streamOpts) res.setHeader('Content-Range', `bytes */${byteLength}`)
sendJson({
error: streamOpts ? 'Requested range exceeds gateway byte limit' : 'A bounded single byte range is required',
maxResponseBytes: this._maxResponseBytes
}, streamOpts ? 416 : 413)
return
}
if (
exactBytes &&
req.method !== 'HEAD' &&
responseLength > 0 &&
typeof context?.reserveResponseBytes === 'function' &&
context.reserveResponseBytes(responseLength) !== true
) {
sendJson({ error: 'Gateway byte-rate limit exceeded' }, 429, {
'Retry-After': String(context?.egressRetryAfterSeconds || 60)
})
return
}
if (responseLength != null) {
res.setHeader('Content-Length', responseLength)
}
res.writeHead(statusCode)
// HEAD requests: send headers only, no body
if (req.method === 'HEAD') {
res.end()
return
}
const stream = streamOpts
? readDrive.createReadStream(filePath, streamOpts)
: readDrive.createReadStream(filePath)
const limiter = new ExactLengthTransform(responseLength)
let bytes = 0
let streamSettled = false
let streamFailureReported = false
const onStreamSettled = () => {
if (streamSettled) return
streamSettled = true
if (signal) signal.removeEventListener('abort', onAbort)
}
const onAbort = () => {
if (!stream.destroyed) {
try { stream.destroy(createAbortError(signal?.reason)) } catch {}
}
if (!limiter.destroyed) {
try { limiter.destroy(createAbortError(signal?.reason)) } catch {}
}
if (!res.destroyed && !res.writableEnded) {
try { res.destroy(createAbortError(signal?.reason)) } catch {}
}
}
limiter.on('data', (chunk) => { bytes += chunk.length })
limiter.on('end', () => {
onStreamSettled()
this._totalBytesServed += bytes
this.emit('served', { keyHex, filePath, bytes })
})
limiter.on('close', onStreamSettled)
const onStreamError = (err) => {
onStreamSettled()
if (streamFailureReported) return
streamFailureReported = true
// If headers are already on the wire we cannot rewrite them;
// destroy the response so the client sees a truncated stream
// instead of a hung socket.
if (!res.headersSent) {
try {
sendJson({ error: 'Gateway stream failed' }, 502)
} catch {}
} else {
try { res.destroy(err) } catch {}
}
if (!isAbortError(err)) {
this.emit('drive-error', { context: 'stream', key: keyHex, path: filePath, error: err.message })
}
}
stream.on('error', onStreamError)
limiter.on('error', (err) => {
if (!stream.destroyed) {
try { stream.destroy(err) } catch {}
}
onStreamError(err)
})
// If the client disconnects mid-flight, tear down the drive stream
// so we don't keep reading blocks into a dead socket.
res.on('close', () => {
if (!stream.destroyed) {
try { stream.destroy() } catch {}
}
if (!limiter.destroyed) {
try { limiter.destroy() } catch {}
}
})
if (signal) {
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
}
stream.pipe(limiter).pipe(res)
} catch (err) {
this.emit('drive-error', { context: 'handle', key: keyHex, path: filePath, error: err.message })
if (isAbortError(err)) {
if (!res.destroyed && !res.writableEnded) {
try { res.destroy(err) } catch {}
}
return
}
if (res.destroyed || res.writableEnded) return
if (res.headersSent) {
try { res.destroy(err) } catch {}
return
}
sendJson({ error: 'Gateway read failed' }, 502)
} finally {
if (requestDriveLease) requestDriveLease.settleHandler()
}
}
_deniedDenylistEntry (keyHex) {
const denylist = this._denylist
if (!denylist || typeof denylist.entryFor !== 'function') return null
try {
return denylist.entryFor(keyHex)
} catch {
return null
}
}
/**
* Purge every denied drive from this gateway's caches and stop its
* in-flight work. Triggered by the denylist's 'entry-added' event, so a
* takedown — local or received over federation gossip — takes effect
* immediately instead of at the next cache miss.
*/
_purgeDeniedDrives () {
const denylist = this._denylist
if (!denylist || typeof denylist.isDenied !== 'function') return
const isDenied = (keyHex) => {
try {
return denylist.isDenied(keyHex) === true
} catch {
return false
}
}
// Stop in-flight request handling for denied drives first — aborting the
// request controller tears down its drive stream through the normal
// response-close path instead of racing it.
const takedownError = createAbortError('Drive taken down by gateway denylist')
for (const state of this._activeRequestStates) {
if (state.keyHex && isDenied(state.keyHex) && !state.controller.signal.aborted) {
state.controller.abort(takedownError)
}
}
for (const [keyHex, controller] of this._driveOpenControllers) {
if (isDenied(keyHex) && !controller.signal.aborted) controller.abort(takedownError)
}
for (const [keyHex, refresh] of this._driveRefreshPromises) {
if (isDenied(keyHex) && !refresh.controller.signal.aborted) refresh.controller.abort(takedownError)
}
for (const [keyHex, state] of this._seededDriveUpdateStates) {
if (isDenied(keyHex) && !state.controller.signal.aborted) state.controller.abort(takedownError)
}
// Evict denied drives from the LRU and retire them (closed once any
// outstanding borrow drains) so a later request cannot hit a warm cache.
for (const [keyHex, entry] of this._drives.entries()) {
if (!isDenied(keyHex)) continue
this._drives.delete(keyHex)
if (entry?.drive) this._retireOwnedDrive(entry.drive)
}
}
async _acquireDrive (keyHex, opts = {}) {
const seededEntry = this.node.seededApps && this.node.seededApps.get(keyHex)
const seededDrive = seededEntry && seededEntry.drive
if (seededDrive && !seededDrive.closed && !seededDrive.closing) {
// AppLifecycle owns seeded drives. The gateway borrows them for this
// request but never inserts or closes them through its LRU.
let lifecycleLease = null
if (this.node.appLifecycle && typeof this.node.appLifecycle.acquireDriveReadLease === 'function') {
lifecycleLease = this.node.appLifecycle.acquireDriveReadLease(keyHex)
if (!lifecycleLease || lifecycleLease.drive !== seededDrive || typeof lifecycleLease.release !== 'function') {
try {
if (lifecycleLease && typeof lifecycleLease.release === 'function') lifecycleLease.release()
} catch {}
return null
}
} else if (this._requireLifecycleDriveAuthority) {
return null
} else {
lifecycleLease = { drive: seededDrive, release: () => {} }
}
// A storage-aware relay serves only the exact immutable tuple accepted
// by AppLifecycle's durable footprint proof. The mutable live drive is
// never itself exposed to HTTP after proof, and its blob core is replaced
// by the exact lifecycle-owned blob snapshot bound into that tuple.
if (this.node.storageAdmission) {
let checkout = null
let accepted = false
try {
if (typeof this.node.storageAdmission.canAcknowledge !== 'function' ||
!this.node.storageAdmission.canAcknowledge(`drive:${keyHex}`)) return null
const proof = durableDriveProof(seededEntry)
const proofCores = Array.isArray(seededEntry.downloadSnapshotCores)
? seededEntry.downloadSnapshotCores
: null
const metaProofCore = proofCores && proofCores[0]
const blobProofCore = proofCores && proofCores[1]
const metaProofState = stableCoreProofState(metaProofCore)
const blobProofState = stableCoreProofState(blobProofCore)
if (!proof || typeof seededDrive.checkout !== 'function' ||
!metaProofState || metaProofState.length !== proof.metaLength || metaProofState.fork !== proof.metaFork ||
!blobProofState || blobProofState.length !== proof.blobLength || blobProofState.fork !== proof.blobFork ||
metaProofState.byteLength + blobProofState.byteLength !== proof.totalBytes) return null
checkout = seededDrive.checkout(proof.driveVersion)
if (checkout && typeof checkout.ready === 'function') {
await this._withTimeout(
checkout.ready(),
this._driveOperationTimeout,
'proved drive checkout',
opts.signal
)
}
const checkoutMeta = stableCoreProofState(checkout?.db?.core)
const current = this.node.seededApps && this.node.seededApps.get(keyHex)
const currentProof = durableDriveProof(current)
if (!checkout || current !== seededEntry || current.drive !== seededDrive ||
currentProof?.driveVersion !== proof.driveVersion ||
currentProof?.metaLength !== proof.metaLength || currentProof?.blobLength !== proof.blobLength ||
currentProof?.totalBytes !== proof.totalBytes || currentProof?.metaFork !== proof.metaFork ||
currentProof?.blobFork !== proof.blobFork ||
current.downloadSnapshotCores?.[0] !== metaProofCore || current.downloadSnapshotCores?.[1] !== blobProofCore ||
!checkoutMeta || checkout.version !== proof.driveVersion ||
checkoutMeta.length !== proof.metaLength || checkoutMeta.fork !== proof.metaFork ||
!this.node.storageAdmission.canAcknowledge(`drive:${keyHex}`)) return null
checkout.blobs = new Hyperblobs(nonClosingCoreView(blobProofCore))
accepted = true
return {
drive: checkout,
immutable: true,
pinnedVersion: proof.driveVersion,
release: lifecycleLease.release
}
} finally {
// Ownership of a successfully returned checkout transfers to the
// request cleanup path. Every rejected candidate closes here and
// releases the lifecycle borrow before another unseed can proceed.
if (!accepted) {
if (checkout && checkout !== seededDrive && !checkout.closed && typeof checkout.close === 'function') {
try { await checkout.close() } catch (_) {}
}
try { lifecycleLease.release() } catch {}
}
}
}
return lifecycleLease
}
// A recovered registry row with no live drive is a serve-only placeholder,
// not permission for HTTP to open a second replication ingress.
if (seededEntry && this.node.storageAdmission) return null
// Exact public hosts are authorized against the live seeded entry above.
// If unseed won that race, do not reopen the key through the gateway's LRU
// after public admission has already disappeared.
if (opts.requireSeeded || this._requireLifecycleDriveAuthority) return null
// Return cached drive if already open and has content
if (this._drives.has(keyHex)) {
const cached = this._drives.get(keyHex)
// Refresh in background for next request
if (typeof cached.update === 'function') this._refreshOwnedDrive(keyHex, cached)
return this._leaseOwnedDrive(cached)
}
let state = this._driveOpenStates.get(keyHex)
if (!state) {
const controller = new AbortController()
state = { controller, promise: null, waiters: 0, settled: false }
const opening = this._openDrive(keyHex, controller.signal)
const promise = opening.finally(() => {
state.settled = true
if (this._driveOpenStates.get(keyHex) === state) this._driveOpenStates.delete(keyHex)
if (this._driveOpenPromises.get(keyHex) === promise) this._driveOpenPromises.delete(keyHex)
if (this._driveOpenControllers.get(keyHex) === controller) this._driveOpenControllers.delete(keyHex)
})
state.promise = promise
this._driveOpenStates.set(keyHex, state)
this._driveOpenPromises.set(keyHex, promise)
this._driveOpenControllers.set(keyHex, controller)
promise.catch(() => {})
}
state.waiters++
try {
const drive = await this._withTimeout(
state.promise,
this._driveOperationTimeout,
'drive open',
opts.signal
)
return drive ? this._leaseOwnedDrive(drive) : null
} finally {
state.waiters = Math.max(0, state.waiters - 1)
if (state.waiters === 0 && !state.settled && !state.controller.signal.aborted) {
state.controller.abort(createAbortError('No gateway readers remain'))
}
}
}
async _openDrive (keyHex, signal = null) {
// Initialize our own P2P stack on first use. Per-key callers share this
// promise through _driveOpenPromises, preventing session/download storms.
await this._ensureReady(signal)
let drive = null
try {
// Per-drive corestore session so cache eviction / drive.close()
// tears down only this session's refs, not the root store. Without
// .session(), hyperdrive._close() cascades to the externalStore
// (the relay's node.store) and wedges the entire relay until
// restart — same class as the v0.8.14 fix in
// app-lifecycle.js:_seedAppInner. The captured trace from utah-us
// canary 2026-05-18 confirms this path fires (DriveCache eviction
// → hyperdrive._close → store.close on the wrapped root).
drive = new Hyperdrive(this._store.session(), Buffer.from(keyHex, 'hex'))
await this._withTimeout(drive.ready(), this._driveOperationTimeout, 'drive.ready()', signal)
if (this._closing || signal?.aborted) throw createAbortError(signal?.reason || 'Gateway is closing')
// Join the drive's discovery key on the swarm (only when we own it)
if (this._swarm) {
const done = drive.findingPeers()
try {
this._swarm.join(drive.discoveryKey, { server: true, client: true })
this._trackOwnedDriveTopic(drive, drive.discoveryKey)
await this._withTimeout(
this._swarm.flush(),
Math.min(5000, this._driveOperationTimeout),
'Hyperswarm.flush()',
signal
)
} catch (err) {
if (isAbortError(err)) throw err
this.emit('drive-wait-error', { key: keyHex, error: err.message })
} finally {
try { done() } catch {}
}
}
// Wait for drive data to arrive from peers
if (!driveHasContent(drive)) {
try {
await updateWithTimeout(drive, {
timeoutMs: Math.min(20_000, this._driveOperationTimeout),
signal
})
} catch (err) {
this.emit('drive-wait-error', { key: keyHex, error: err.message })
}
}
// Still no content
if (!driveHasContent(drive)) {
await this._closeOwnedDrive(drive, 'empty-close')
return null
}
if (this._closing || signal?.aborted) throw createAbortError(signal?.reason || 'Gateway is closing')
// Do not eagerly mirror an arbitrary drive requested through the legacy
// compatibility path. createReadStream() fetches only the requested
// extent; downloading '/' here let one public request consume unbounded
// relay bandwidth and disk in the background after its socket closed.
this._drives.set(keyHex, drive)
return drive
} catch (err) {
if (drive && !drive.closed) {
await this._closeOwnedDrive(drive, 'open-failure-close')
}
this.emit('drive-error', { context: 'getDrive', key: keyHex, error: err })
return null
}
}
_refreshOwnedDrive (keyHex, drive) {
const existing = this._driveRefreshPromises.get(keyHex)
if (existing) return existing.promise
if (this._closing || !drive || drive.closed || drive.closing) return null
const controller = new AbortController()
// Treat a refresh as a reader so LRU eviction cannot close the session
// while updateWithTimeout is still clearing its per-operation refs.
const lease = this._leaseOwnedDrive(drive)
const promise = updateWithTimeout(drive, {
timeoutMs: this._driveOperationTimeout,
wait: false,
signal: controller.signal
}).catch(err => {
if (!isAbortError(err)) {
this.emit('drive-update-error', { key: keyHex, error: err.message })
}
}).finally(() => {
lease.release()
const current = this._driveRefreshPromises.get(keyHex)
if (current?.promise === promise) this._driveRefreshPromises.delete(keyHex)
})
this._driveRefreshPromises.set(keyHex, { promise, controller, drive })
return promise
}
async _updateSeededDrive (keyHex, drive, signal = null) {
let state = this._seededDriveUpdateStates.get(keyHex)
if (!state || state.drive !== drive) {
const controller = new AbortController()
state = {
drive,
controller,
promise: null,
waiters: 0,
settled: false
}
const promise = updateWithTimeout(drive, {
timeoutMs: this._driveOperationTimeout,
signal: controller.signal
}).finally(() => {
state.settled = true
if (this._seededDriveUpdateStates.get(keyHex) === state) {
this._seededDriveUpdateStates.delete(keyHex)
}
})
state.promise = promise
this._seededDriveUpdateStates.set(keyHex, state)
}
state.waiters++
try {
return await this._withTimeout(
state.promise,
this._driveOperationTimeout,
'seeded drive update',
signal
)
} finally {
state.waiters = Math.max(0, state.waiters - 1)
if (state.waiters === 0 && !state.settled && !state.controller.signal.aborted) {
state.controller.abort(createAbortError('No gateway readers remain'))
}
}
}
_leaseOwnedDrive (drive) {
this._activeOwnedDrives.set(drive, (this._activeOwnedDrives.get(drive) || 0) + 1)
let released = false
return {
drive,
release: () => {
if (released) return
released = true
const remaining = (this._activeOwnedDrives.get(drive) || 1) - 1
if (remaining <= 0) this._activeOwnedDrives.delete(drive)
else this._activeOwnedDrives.set(drive, remaining)
this._tryCloseRetiredDrive(drive)
}
}
}
_retireOwnedDrive (drive) {
if (!drive || drive.closed) return
this._retiredOwnedDrives.add(drive)
// Defer close past promise continuations that may be acquiring the drive
// returned by a just-completed singleflight open.
setImmediate(() => this._tryCloseRetiredDrive(drive))
}
_tryCloseRetiredDrive (drive) {
if (!this._retiredOwnedDrives.has(drive) || this._activeOwnedDrives.has(drive)) return
this._retiredOwnedDrives.delete(drive)
if (drive.closed || typeof drive.close !== 'function') return
const pending = this._closeOwnedDrive(drive, 'evict-close')
this._driveClosePromises.add(pending)
pending.finally(() => this._driveClosePromises.delete(pending)).catch(() => {})
}
async _closeOwnedDrive (drive, operation = 'close') {
if (!drive) return
await this._releaseOwnedDriveTopic(drive)
if (drive.closed || typeof drive.close !== 'function') return
try {
await this._withTimeout(
Promise.resolve(drive.close()),
Math.min(DRIVE_CLOSE_TIMEOUT, this._driveOperationTimeout),
`drive.${operation}()`
)
} catch (err) {
this.emit(operation === 'evict-close' ? 'drive-cache-error' : 'drive-close-error', {
operation,
error: err.message
})
}
}
_trackOwnedDriveTopic (drive, discoveryKey) {
if (!drive || !discoveryKey || this._ownedDriveTopics.has(drive)) return
const keyHex = Buffer.from(discoveryKey).toString('hex')
const current = this._ownedTopicRefs.get(keyHex)
if (current) current.count++
else this._ownedTopicRefs.set(keyHex, { discoveryKey, count: 1 })
this._ownedDriveTopics.set(drive, keyHex)
}
async _releaseOwnedDriveTopic (drive) {
const keyHex = this._ownedDriveTopics.get(drive)
if (!keyHex) return
this._ownedDriveTopics.delete(drive)
const current = this._ownedTopicRefs.get(keyHex)
if (!current) return
current.count = Math.max(0, current.count - 1)
if (current.count > 0) return
this._ownedTopicRefs.delete(keyHex)
if (!this._swarm || typeof this._swarm.leave !== 'function') return
try {
await this._withTimeout(
Promise.resolve(this._swarm.leave(current.discoveryKey)),
Math.min(1000, this._driveOperationTimeout),
'Hyperswarm.leave()'
)
} catch (err) {
this.emit('drive-cache-error', { operation: 'topic-leave', error: err.message })
}
}
_bindResponseCleanup (res, cleanup) {
let cleaned = false
const run = () => {
if (cleaned) return
cleaned = true
res.removeListener('finish', run)
res.removeListener('close', run)
cleanup()
}
res.once('finish', run)
res.once('close', run)
if (res.destroyed || res.writableEnded || res.writableFinished) run()
}
_holdDriveLeaseForRequest (res, driveLease) {
let handlerSettled = false
let responseSettled = false
let cleanupStarted = false
let checkout = null
const maybeCleanup = () => {
if (!handlerSettled || !responseSettled || cleanupStarted) return
cleanupStarted = true
const pending = (async () => {
try {
if (checkout && !checkout.closed && typeof checkout.close === 'function') {
await this._withTimeout(
Promise.resolve(checkout.close()),
Math.min(1000, this._driveOperationTimeout),
'drive.checkout().close()'
)
}
} catch (err) {
this.emit('drive-close-error', { context: 'request-checkout', error: err.message })
} finally {
try { driveLease.release() } catch (err) {
this.emit('drive-close-error', { context: 'request-lease', error: err.message })
}
}
})()
this._pendingRequestCleanups.add(pending)
pending.then(
() => this._pendingRequestCleanups.delete(pending),
() => this._pendingRequestCleanups.delete(pending)
)
}
this._bindResponseCleanup(res, () => {
responseSettled = true
maybeCleanup()
})
return {
setCheckout: (drive) => { checkout = drive },
settleHandler: () => {
handlerSettled = true
maybeCleanup()
}
}
}
async _serveDirectoryListing (res, drive, keyHex, dirPath, opts = {}) {
const entries = []
const MAX_ENTRIES = 1000 // Prevent memory exhaustion from huge directories
const maxListingBytes = Math.min(this._maxResponseBytes, 1024 * 1024)
let estimatedPayloadBytes = Buffer.byteLength(JSON.stringify({ key: keyHex, path: dirPath, entries: [] }) + '\n')
const startTime = Date.now()
const TIMEOUT = this._driveOperationTimeout
let iterator = null
let iteratorDone = false
let failure = null
try {
const listing = drive.list(dirPath)
if (!listing || typeof listing[Symbol.asyncIterator] !== 'function') {
throw new Error('Directory listing unavailable')
}
iterator = listing[Symbol.asyncIterator]()
while (true) {
const elapsed = Date.now() - startTime
if (elapsed >= TIMEOUT) throw new Error('Directory listing timeout')
const next = await this._withTimeout(
iterator.next(),
Math.max(1, TIMEOUT - elapsed),
'drive.list().next()',
opts.signal
)
if (next.done) {
iteratorDone = true
break
}
const entryKey = next.value?.key
if (typeof entryKey !== 'string') throw new Error('Directory entry key is invalid')
const encodedEntryBytes = Buffer.byteLength(JSON.stringify(entryKey)) + (entries.length > 0 ? 1 : 0)
if (estimatedPayloadBytes + encodedEntryBytes > maxListingBytes) {
const marker = '... (truncated by byte limit)'
const markerBytes = Buffer.byteLength(JSON.stringify(marker)) + (entries.length > 0 ? 1 : 0)
if (estimatedPayloadBytes + markerBytes <= maxListingBytes) entries.push(marker)
break
}
entries.push(entryKey)
estimatedPayloadBytes += encodedEntryBytes
if (entries.length >= MAX_ENTRIES) {
entries.push('... (truncated)')
break
}
}
} catch (err) {
failure = err
} finally {
if (iterator && !iteratorDone && typeof iterator.return === 'function') {
try {
await this._withTimeout(iterator.return(), Math.min(1000, TIMEOUT), 'drive.list().return()')
} catch (err) {
if (!failure) failure = err
}
}
}
const headers = {
'X-Hyper-Key': keyHex,
'X-Served-By': 'hiverelay-gateway'
}
if (opts.byteMode) {
headers['X-Hive-App-Key'] = keyHex
headers['X-Hive-Byte-Mode'] = opts.byteMode
headers.Vary = 'Host'
headers['Origin-Agent-Cluster'] = '?1'
}
if (failure) {
this.emit('drive-error', { context: 'directoryListing', key: keyHex, path: dirPath, error: failure.message })
if (isAbortError(failure)) {
if (!res.destroyed && !res.writableEnded) {
try { res.destroy(failure) } catch {}
}
return
}
if (res.destroyed || res.writableEnded) return
writeGatewayJson(res, { error: 'Gateway directory listing failed' }, 502, headers, { head: !!opts.head })
return
}
headers['Cache-Control'] = opts.byteMode ? 'no-store, max-age=0' : 'public, max-age=60'
const body = { key: keyHex, path: dirPath, entries }
const payload = Buffer.from(JSON.stringify(body) + '\n')
const payloadBytes = payload.byteLength
if (payloadBytes > maxListingBytes) {
writeGatewayJson(res, { error: 'Gateway directory listing exceeds byte limit' }, 413, headers, { head: !!opts.head })
return
}
let representation = { ok: true, status: 200, payload, contentRange: null }
if (opts.byteMode) {
headers['Accept-Ranges'] = 'bytes'
representation = selectExactByteRepresentation(payload, opts.rangeHeader)
if (!representation.ok) {
headers['Content-Range'] = representation.contentRange
writeGatewayJson(res, { error: 'Range Not Satisfiable' }, 416, headers, { head: !!opts.head })
return
}
if (representation.contentRange) headers['Content-Range'] = representation.contentRange
}
if (
!opts.head &&
representation.payload.byteLength > 0 &&
typeof opts.reserveResponseBytes === 'function' &&
opts.reserveResponseBytes(representation.payload.byteLength) !== true
) {
writeGatewayJson(res, { error: 'Gateway byte-rate limit exceeded' }, 429, {
...headers,
'Retry-After': String(opts.egressRetryAfterSeconds || 60)
}, { head: false })
return
}
writeGatewayBuffer(res, representation.payload, representation.status, headers, { head: !!opts.head })
}
getStats () {
return {
cachedDrives: this._drives.size,
totalRequests: this._totalRequests,
totalBytesServed: this._totalBytesServed
}
}
async close () {
if (this._closePromise) return this._closePromise
this._closing = true
const pending = this._close()
this._closePromise = pending
try {
await pending
} finally {
if (this._closePromise === pending) this._closePromise = null
this._closing = false
}
}
async _close () {
const shutdownError = createAbortError('Gateway is shutting down')
this._disarmDenylistListener()
const requestStates = [...this._activeRequestStates]
for (const state of requestStates) {
if (!state.controller.signal.aborted) state.controller.abort(shutdownError)
if (!state.res.destroyed && !state.res.writableEnded) {
try { state.res.destroy(shutdownError) } catch {}
}
state.forceResponseSettlement?.()
}
if (this._readyController && !this._readyController.signal.aborted) {
this._readyController.abort(shutdownError)
}
for (const controller of this._driveOpenControllers.values()) {
if (!controller.signal.aborted) controller.abort(shutdownError)
}
for (const refresh of this._driveRefreshPromises.values()) {
if (!refresh.controller.signal.aborted) refresh.controller.abort(shutdownError)
}
for (const state of this._seededDriveUpdateStates.values()) {
if (!state.controller.signal.aborted) state.controller.abort(shutdownError)
}
if (requestStates.length > 0) {
await Promise.allSettled(requestStates.map(state => state.settled))
}
if (this._readyPromise) await Promise.allSettled([this._readyPromise])
if (this._driveOpenPromises.size > 0) {
await Promise.allSettled(this._driveOpenPromises.values())
this._driveOpenPromises.clear()
this._driveOpenControllers.clear()
this._driveOpenStates.clear()
}
if (this._driveRefreshPromises.size > 0) {
await Promise.allSettled([...this._driveRefreshPromises.values()].map(value => value.promise))
this._driveRefreshPromises.clear()
}
if (this._seededDriveUpdateStates.size > 0) {
await Promise.allSettled([...this._seededDriveUpdateStates.values()].map(value => value.promise))
this._seededDriveUpdateStates.clear()
}
if (this._pendingRequestCleanups.size > 0) {
await Promise.allSettled([...this._pendingRequestCleanups])
this._pendingRequestCleanups.clear()
}
if (this._driveClosePromises.size > 0) {
await Promise.allSettled([...this._driveClosePromises])
this._driveClosePromises.clear()
}
// DriveCache is not directly iterable — iterate its entries(), and each
// value is a { drive, lastAccess } wrapper, not the drive itself. The
// previous `for (const [, drive] of this._drives)` threw
// "this._drives is not iterable" on every close(), leaking the HTTP
// server (EADDRINUSE on self-heal restart).
const ownedDrives = new Set(this._retiredOwnedDrives)
for (const [, entry] of this._drives.entries()) {
if (entry?.drive) ownedDrives.add(entry.drive)
}
this._drives.clear()
this._retiredOwnedDrives.clear()
this._activeOwnedDrives.clear()
await Promise.allSettled([...ownedDrives].map(drive => this._closeOwnedDrive(drive)))
if (this._ownsSwarm && this._swarm) {
try {
await this._withTimeout(
this._swarm.destroy(),
Math.min(DRIVE_CLOSE_TIMEOUT, this._driveOperationTimeout),
'swarm.destroy()'
)
} catch (err) {
this.emit('swarm-destroy-error', { error: err.message })
}
}
// A RelayNode-provided Corestore is borrowed infrastructure. Closing it
// here would wedge seeding, replication, and control-plane consumers that
// outlive a gateway restart.
if (this._store && !this._externalStore) {
try {
await this._withTimeout(
this._store.close(),
Math.min(DRIVE_CLOSE_TIMEOUT, this._driveOperationTimeout),
'Corestore.close()'
)
} catch (err) {
this.emit('store-close-error', { error: err.message })
}
}
this._activeRequestStates.clear()
this._ownedDriveTopics.clear()
this._ownedTopicRefs.clear()
this._store = null
this._swarm = null
this._ownsSwarm = false
this._ready = false
this._readyPromise = null
this._readyController = null
}
}