first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
# `createHost` — top-level host orchestration entry point
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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:
```ts
const host = await createHost({
adapters,
plugins,
adminPrefix: null,
mode: "development",
requestId: () => `req_${process.pid}_${counter++}`,
});
```
Install a plugin at runtime (post-start):
```ts
await host.installPlugin({
module: notificationsModule,
manifest: notificationsManifest,
manifestHash: "sha256:...",
specifier: "file:///notifications.js",
});
expect(host.plugins()).toContain("@acme/notifications");
```
## API Reference
```ts
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.