OverviewFilesCommitsIssuesDocsReleasesPeers

mirall-cli / node_modules / hyper-svc / lib / service.js

239 lines · javascript ref:
/**
 * Service — reusable per-app daemon library.
 *
 * One instance per app process. Owns:
 *   - Generic RPC handler registry (svc.handle)
 *   - Module mounts (svc.mount) with optional swarm replication
 *   - UDS, HTTP, and Hyperswarm transport instances (whichever the consumer enables)
 *
 * The consumer's corestore is passed in (or one is implied via swarm.corestore).
 * The Service does NOT create or own storage — that's the app's job.
 *
 * Local-only RPC. Peers exchange data via swarm replication, never commands.
 *
 * Usage:
 *
 *   const svc = new Service({
 *     name: 'pear-git',
 *     corestore: store,
 *     uds: '/tmp/pear-git.sock',
 *     http: { port: 4040 },
 *     swarm: { topic: '...32 bytes hex...' }
 *   })
 *
 *   svc.mount('orgs', new HyperOrgs(store, { namespace: 'orgs' }))
 *
 *   svc.handle('list-orgs', async (ctx, params) => {
 *     return svc.modules.orgs.list()
 *   })
 *
 *   await svc.listen()
 */

import process from '#node:process'
import ReadyResource from 'ready-resource'
import { UdsServer } from './transports/uds.js'
import { HttpServer } from './transports/http.js'
import { SwarmTransport } from './transports/swarm.js'

class NoSuchMethodError extends Error {
  constructor (method) {
    super(`unknown method: ${method}`)
    this.code = 'ENOMETHOD'
  }
}

export class Service extends ReadyResource {
  /**
   * @param {object} opts
   * @param {string} opts.name - service name (used for default UDS path, logging)
   * @param {object} [opts.corestore] - required if swarm transport is enabled
   * @param {string} [opts.uds] - UDS socket path. Pass null/undefined to disable.
   * @param {object} [opts.http] - HTTP transport options { port, host }. Disable by omitting.
   * @param {object} [opts.swarm] - Swarm options { topic, swarm? }. Disable by omitting.
   */
  constructor (opts = {}) {
    super()
    if (!opts.name) throw new Error('Service requires opts.name')

    this._name = opts.name
    this._corestore = opts.corestore || null
    this._handlers = new Map()
    this._mounts = new Map()

    this._udsOpt = opts.uds || null
    this._httpOpt = opts.http || null
    this._swarmOpt = opts.swarm || null

    this._uds = null
    this._http = null
    this._swarm = null

    if (this._swarmOpt && !this._corestore) {
      throw new Error('Service: swarm transport requires opts.corestore')
    }

    // Instantiate transports eagerly so apps can register HTTP routes,
    // grab the UDS path, etc., BEFORE calling listen().
    const dispatch = (method, params, ctx) => this.dispatch(method, params, ctx)

    if (this._udsOpt) {
      this._uds = new UdsServer({ path: this._udsOpt, dispatch })
    }
    if (this._httpOpt) {
      this._http = new HttpServer({
        port: this._httpOpt.port,
        host: this._httpOpt.host,
        prefix: this._httpOpt.prefix,
        ctxFactory: this._httpOpt.ctxFactory,
        dispatch
      })
    }
    if (this._swarmOpt) {
      this._swarm = new SwarmTransport({
        topic: this._swarmOpt.topic,
        corestore: this._corestore,
        mounts: () => this._listMounts(),
        dispatch: this._swarmOpt.rpc !== false ? dispatch : null,
        swarm: this._swarmOpt.swarm,
        server: this._swarmOpt.server,
        client: this._swarmOpt.client
      })
    }
  }

  // ── Properties ──────────────────────────────────────────────

  get name () { return this._name }
  get corestore () { return this._corestore }

  /** Read-only handle to the UDS transport (or null if disabled). */
  get uds () { return this._uds }

  /** Read-only handle to the HTTP transport (or null if disabled). */
  get http () { return this._http }

  /** Read-only handle to the swarm transport (or null if disabled). */
  get swarm () { return this._swarm }

  /**
   * Mounted modules, accessible by name. Use `svc.modules.orgs` after
   * `svc.mount('orgs', orgsInstance)`.
   */
  get modules () {
    // Lazily build a property-style accessor object on top of the map
    if (!this._modulesProxy) {
      const map = this._mounts
      this._modulesProxy = new Proxy({}, {
        get (_target, prop) {
          const m = map.get(prop)
          return m ? m.instance : undefined
        },
        has (_target, prop) {
          return map.has(prop)
        },
        ownKeys () {
          return Array.from(map.keys())
        },
        getOwnPropertyDescriptor (_target, prop) {
          if (map.has(prop)) {
            return { enumerable: true, configurable: true, value: map.get(prop).instance }
          }
        }
      })
    }
    return this._modulesProxy
  }

  /** Map iteration: list mount entries as { name, instance }. */
  _listMounts () {
    const out = []
    for (const [name, m] of this._mounts) out.push({ name, instance: m.instance })
    return out
  }

  // ── Registration ────────────────────────────────────────────

  /**
   * Register an RPC handler. The handler is invoked from any RPC transport
   * with `(ctx, params)` and should return a JSON-serializable result.
   *
   * @param {string} name - method name (e.g. "list-orgs")
   * @param {(ctx: object, params: any) => Promise<any>|any} fn
   */
  handle (name, fn) {
    if (typeof name !== 'string' || !name) throw new Error('handle requires a name')
    if (typeof fn !== 'function') throw new Error('handle requires a function')
    if (this._handlers.has(name)) throw new Error(`handler "${name}" already registered`)
    this._handlers.set(name, fn)
  }

  /** Whether a handler is registered. */
  hasHandler (name) { return this._handlers.has(name) }

  /** List registered handler names. */
  listHandlers () { return Array.from(this._handlers.keys()).sort() }

  /**
   * Mount a module instance. Modules are accessible via `svc.modules.<name>`.
   * If the module exposes a `.base` (Autobase consumer) and the swarm
   * transport is enabled, the base is replicated on every swarm connection.
   *
   * @param {string} name
   * @param {object} instance
   */
  mount (name, instance) {
    if (typeof name !== 'string' || !name) throw new Error('mount requires a name')
    if (!instance) throw new Error('mount requires an instance')
    if (this._mounts.has(name)) throw new Error(`mount "${name}" already registered`)
    this._mounts.set(name, { instance })
  }

  /** Whether a module is mounted. */
  hasMount (name) { return this._mounts.has(name) }

  /** List mounted module names. */
  listMounts () { return Array.from(this._mounts.keys()).sort() }

  // ── Dispatch ────────────────────────────────────────────────

  /**
   * Dispatch a method call to its handler. Used by all RPC transports.
   * Throws NoSuchMethodError (code: 'ENOMETHOD') if the method isn't registered.
   */
  async dispatch (method, params, ctx) {
    const handler = this._handlers.get(method)
    if (!handler) throw new NoSuchMethodError(method)
    return handler(ctx || {}, params)
  }

  // ── Lifecycle ───────────────────────────────────────────────

  async _open () {
    if (this._uds) await this._uds.listen()
    if (this._http) await this._http.listen()
    if (this._swarm) await this._swarm.listen()
  }

  /**
   * Alias for `ready()` — feels more natural for a daemon ("start listening").
   */
  async listen () {
    await this.ready()
  }

  async _close () {
    const errors = []
    if (this._swarm) try { await this._swarm.close() } catch (e) { errors.push(e) }
    if (this._http) try { await this._http.close() } catch (e) { errors.push(e) }
    if (this._uds) try { await this._uds.close() } catch (e) { errors.push(e) }
    this._swarm = null
    this._http = null
    this._uds = null
    if (errors.length) {
      const msg = errors.map(e => e.message).join('; ')
      throw new Error('Service close errors: ' + msg)
    }
  }
}