Files
skills/docs/api-stack/api/api-host/src/host/host.md
2026-05-29 08:20:56 +01:00

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_INVALID failure 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 — production defaults to enforce, development to warn.
  • Admin API mountable at any prefix or disable-able via adminPrefix: null.
  • /internal/livez returns 200 ok; /internal/readyz returns {state, plugins} with the registered install order.
  • Graceful shutdown with graceMs budget; 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.