5.9 KiB
createHost — top-level host orchestration entry point
(no submodules)
Purpose
createHost(config) composes every internal driver (LifecycleDriver, ReloadDriver, PluginLedger, CapabilityRegistry, RequestPipeline, admin API) into a runnable host. Production deployments hand their HTTP engine's fetch(request) handler straight to host.fetch(...); integration tests drive the same surface programmatically.
The resulting Host instance carries an authoritative install order (topo-sorted by manifest deps), exposes start/stop hooks, and serves three kinds of traffic: admin endpoints under adminPrefix (default /admin), internal probes (/internal/livez, /internal/readyz), and per-request plugin traffic through the §6 11-step pipeline.
Features
- Zod-validated config with stable
HOST.CONFIG_INVALIDfailure code. - Topological dependency-graph install via
resolveInstallOrder. - Lifecycle + reload + plugin-ledger + capability-registry shared across the admin API and the per-request pipeline.
- Per-mode response-validation strictness —
productiondefaults toenforce,developmenttowarn. - Admin API mountable at any prefix or disable-able via
adminPrefix: null. /internal/livezreturns200 ok;/internal/readyzreturns{state, plugins}with the registered install order.- Graceful shutdown with
graceMsbudget; sequential reverse-topo stop sequence.
Usage: Basic
import { createHost } from "@suaveplan/api-host/host";
import { Database } from "bun:sqlite";
const host = await createHost({
adapters: {
router,
auth,
tenant,
capabilities,
rateLimit,
idempotency,
csrf,
audit,
span,
db: new Database(":memory:"),
},
plugins: [
{ module: billingModule, manifest: billingManifest, manifestHash: "sha256:...", specifier: "file:///billing.js" },
],
});
await host.start();
Bun.serve({ port: 3000, fetch: (req) => host.fetch(req) });
Usage: Advanced
Disable the admin surface for non-admin pods + supply a request-id generator for log correlation:
const host = await createHost({
adapters,
plugins,
adminPrefix: null,
mode: "development",
requestId: () => `req_${process.pid}_${counter++}`,
});
Install a plugin at runtime (post-start):
await host.installPlugin({
module: notificationsModule,
manifest: notificationsManifest,
manifestHash: "sha256:...",
specifier: "file:///notifications.js",
});
expect(host.plugins()).toContain("@acme/notifications");
API Reference
function createHost(config: HostConfig): Promise<Host>;
interface Host {
start(): Promise<void>;
stop(graceMs?: number): Promise<void>;
fetch(request: Request): Promise<Response>;
installPlugin(install: HostPluginInstall): Promise<void>;
uninstallPlugin(name: string): Promise<void>;
plugins(): readonly string[];
readonly instruments: HostInstruments;
}
interface HostConfig {
adapters: HostAdapters;
plugins: ReadonlyArray<HostPluginInstall>;
adminPrefix?: string | null;
mode?: "production" | "development";
instruments?: HostInstruments;
logger?: BoundaryLogger & PipelineRequestLogger;
hostId?: string;
hostVersion?: string;
/**
* Fallback tenant — see "Default tenant" below. `undefined` /
* `null` propagate `ctx.tenant === null` on resolver miss for
* auth-optional routes; a `TenantContext` value preserves the
* legacy `{id: "public", slug: "public"}` injection.
*/
defaultTenant?: TenantContext | null;
}
Default tenant (foundation gap 5)
Prior to 0.1.0 the request pipeline injected a hard-coded
{id: "public", slug: "public"} TenantContext whenever the configured
TenantResolverChain returned null on a public (auth-optional) route.
That made the "public" literal load-bearing across every handler that
introspected ctx.tenantId, blocking deployments that wanted to
distinguish "no tenant" from "the public tenant".
HostConfig.defaultTenant now controls the fallback explicitly:
| Value | Behaviour on resolver miss + auth-optional route |
|---|---|
undefined |
ctx.tenant === null (the new default) |
null |
Same as undefined — documents the intent explicitly |
TenantContext |
Inject verbatim — preserves legacy "public" semantics |
Auth-required routes (auth: <scope>) still raise
HOST.TENANT_NOT_RESOLVED (400) on a resolver miss regardless of
defaultTenant — the fallback is a public-route convenience, not a
silent bypass of tenant-required policy.
Implementation Notes
createHost is the only entry point that touches every internal
driver; everything else in @suaveplan/api-host is composable in
isolation. Tests SHOULD prefer the focused driver imports
(@suaveplan/api-host/lifecycle, @suaveplan/api-host/request-pipeline,
etc.) and reach for createHost only when the integration path matters.
The admin sub-router is mounted via Hono's app.fetch(...) directly
against a re-shaped Request whose pathname strips the configured
adminPrefix. This avoids a Hono app.route(...) import-cycle with
the admin-api package and keeps the prefix configurable without
forcing the admin router to know its own mount point.
installPlugin post-start short-circuits the topo-sort by appending
to the existing install order — adding a plugin at runtime never
re-orders previously-started plugins. A plugin with unresolved deps
will fail lifecycle install just as it would at start; the host
surfaces that via LifecycleDriver's state-machine codes.
The reload resolver bag is intentionally minimal — Stage 3 will wire
@suaveplan/api-loader's ModuleResolver here. Until then, calling
reload(...) throws HOST.INTERNAL_INVARIANT to make the missing
adapter loud rather than silent.