OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-svc / lib / transports / http.js

221 lines · javascript ref:
/**
 * 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
}