/**
* 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)
}
}
}