/**
* HTTP transport for hyper-svc.
*
* Local-only RPC. Browsers (or curl, or any HTTP client) on the same machine
* POST a JSON body to /rpc/<method> and receive a JSON response.
*
* Wire protocol:
* POST /rpc/<method>
* Content-Type: application/json
* { ...params... }
*
* → 200 application/json { result: ... }
* 400 application/json { error: ... } (bad request / handler threw)
* 404 application/json { error: 'unknown method' }
*
* Other routes are NOT handled by hyper-svc — apps wire their own SSR pages
* via the underlying http.Server (exposed as `.server` on this class). Any
* request not matching POST /rpc/<method> falls through to user-registered
* 'request' listeners or, if none, returns 404.
*/
import http from '#node:http'
import b4a from 'b4a'
export class HttpServer {
/**
* @param {object} opts
* @param {number} [opts.port=0] - 0 = random port
* @param {string} [opts.host='127.0.0.1'] - bind address (local-only by default)
* @param {string} [opts.prefix=''] - URL prefix to strip before matching /rpc/* (e.g. '/git')
* @param {(method: string, params: any, ctx: object) => Promise<any>} opts.dispatch - request handler
*/
constructor (opts = {}) {
if (typeof opts.dispatch !== 'function') throw new Error('HttpServer requires opts.dispatch')
this._port = opts.port || 0
this._host = opts.host || '127.0.0.1'
this._dispatch = opts.dispatch
// Optional URL prefix (e.g. '/git') stripped before matching /rpc/*. Set
// when the daemon is reverse-proxied OR accessed directly under a path
// prefix that the browser bakes into outgoing fetch URLs. Without this,
// direct (non-proxied) hits to `<host>/<prefix>/rpc/*` fall through both
// hyper-svc's listener (which only matches '/rpc/') and the consumer's
// listener (which strips the prefix and skips '/rpc/' assuming hyper-svc
// already handled it) — neither responds and the request hangs.
this._prefix = normalizePrefix(opts.prefix)
// Optional consumer-provided ctx builder. Called per /rpc/* request to
// populate fields like identity from request headers/cookies. Receives
// (req) and may return either an object or a Promise<object>. The
// returned fields are merged into the default ctx ({ transport: 'http',
// identity: null, req, res }).
this._ctxFactory = typeof opts.ctxFactory === 'function' ? opts.ctxFactory : null
this._listening = false
// Create the server eagerly so apps can register routes BEFORE listen().
// Our /rpc/* listener is installed first so it has first dibs on requests.
this._server = http.createServer()
this._server.on('request', (req, res) => this._onRequest(req, res))
}
/**
* Update or set the ctxFactory after construction. Useful when the
* factory needs references that don't exist at constructor time
* (e.g. session stores created by the consumer in a later step).
*/
setCtxFactory (fn) {
if (fn != null && typeof fn !== 'function') {
throw new Error('ctxFactory must be a function or null')
}
this._ctxFactory = fn
}
/** Underlying http.Server. Apps register their own routes here. */
get server () { return this._server }
get listening () { return this._listening }
get port () {
if (!this._listening || !this._server) return null
const addr = this._server.address()
return addr ? addr.port : null
}
get host () { return this._host }
async listen () {
if (this._listening) return
await new Promise((resolve, reject) => {
this._server.once('error', reject)
this._server.listen(this._port, this._host, () => {
this._server.removeListener('error', reject)
resolve()
})
})
this._listening = true
}
async _onRequest (req, res) {
// Compute the path with the configured prefix removed. Don't mutate
// req.url — consumers' own request listeners may have their own
// prefix-stripping logic and should see the original.
let path = req.url
if (this._prefix && path.startsWith(this._prefix)) {
const rest = path.slice(this._prefix.length)
// Only treat as a prefix match when the next char is '/' (or end of
// URL). Otherwise '/gitfoo' would falsely match prefix '/git'.
if (rest === '' || rest[0] === '/' || rest[0] === '?') {
path = rest || '/'
}
}
if (req.method === 'POST' && path.startsWith('/rpc/')) {
const method = path.slice('/rpc/'.length).split('?')[0]
let body
try {
body = await collectBody(req)
} catch (err) {
return jsonError(res, 400, 'failed to read body: ' + err.message)
}
let params = {}
if (body.length > 0) {
try {
params = JSON.parse(b4a.toString(body, 'utf8'))
} catch {
return jsonError(res, 400, 'request body must be JSON')
}
}
let ctx = {
transport: 'http',
identity: null, // anonymous by default — apps populate via ctxFactory
req,
res
}
if (this._ctxFactory) {
try {
const extra = await this._ctxFactory(req)
if (extra && typeof extra === 'object') {
ctx = { ...ctx, ...extra }
}
} catch (err) {
return jsonError(res, 500, 'ctxFactory failed: ' + (err.message || String(err)))
}
}
try {
const result = await this._dispatch(method, params, ctx)
if (result === undefined) {
return jsonOk(res, { result: null })
}
return jsonOk(res, { result })
} catch (err) {
if (err && err.code === 'ENOMETHOD') {
return jsonError(res, 404, err.message)
}
// An anonymous-gate denial is an authorization outcome, not a bad
// request — surface it as 403 so callers (and the audit trail) can
// tell "you may not" from "you sent nonsense".
if (err && err.code === 'EDENIED') {
return jsonError(res, err.status || 403, err.message)
}
return jsonError(res, 400, err.message || String(err))
}
}
// Not an /rpc/ request. App-owned routes live in OTHER 'request'
// listeners (registered on this._server by the consumer). If at least
// one other listener is present, leave the response to them — they're
// responsible for handling and 404-ing as needed.
//
// If we're the ONLY 'request' listener, nobody else will handle this,
// so we 404 it synchronously. Doing this synchronously avoids a race
// where setImmediate fires before an async user handler can respond.
if (this._server.listenerCount('request') <= 1) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not found')
}
// Otherwise: leave the response to the user's listener.
}
async close () {
if (!this._server || !this._listening) {
this._listening = false
return
}
await new Promise((resolve) => this._server.close(() => resolve()))
this._listening = false
}
}
function collectBody (req) {
return new Promise((resolve, reject) => {
const parts = []
req.on('data', (c) => parts.push(c))
req.on('end', () => resolve(b4a.concat(parts)))
req.on('error', reject)
})
}
function jsonOk (res, body) {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
function jsonError (res, status, message) {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: message }))
}
function normalizePrefix (p) {
if (!p || typeof p !== 'string') return ''
// Strip trailing slashes so '/git/' and '/git' behave identically.
let s = p.replace(/\/+$/, '')
// A bare '/' is not a real prefix.
if (s === '' || s === '/') return ''
// Require a leading slash so callers can't accidentally pass 'git'.
if (s[0] !== '/') s = '/' + s
return s
}