Skip to content

Build-time validation

How a Flare app is assembled and what host.build() validates before the server accepts traffic.

AI generated, pending review Updated 10 days ago · Flare 0.3

A Flare app is assembled by registering a graph of tokens, classes, and routes on FlareHost, then calling host.build() to validate the entire graph and compile per-route pipelines before the server accepts traffic. Registration is explicit: call host.cfg(), host.scoped() or host.singleton(), and host.http.* (plus host.ws.*, and any adapter-stamped surfaces) before build(). Registrations after the first successful build() are ignored because later calls return the cached app.

// @ground:preamble
import { FlareService, ControllerBase, flareConfig } from "@flare-ts/core";
import { str } from "@flare-ts/lib/schema";
const DbConfig = flareConfig("db", { url: str });
class DbService extends FlareService {
public static override deps = [];
}
class ApiController extends ControllerBase {
public static override deps = [];
}
import { FlareHost } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
const host = new FlareHost(node);
host.cfg(DbConfig); // 1. config tokens
host.singleton(DbService); // 2. service registrations
host.http.controller("/api", ApiController); // 3. routes / arcs
  1. Config tokens: sections of flare.json or FLARE__ env vars you want typed (flareConfig("db", { ... })). Register each with host.cfg(token) so the host parses and validates that section during build().
  2. Service registrations: classes the DI container resolves. Use host.scoped() for per-request instances. Use host.singleton() for per-process instances on Node (singletons are created at build() in production). On Cloudflare Workers, host.singleton() throws at registration time; register the same class with host.scoped() instead. Both methods require a static deps array (may be []). Omitting static deps throws when you call host.scoped() or host.singleton(), not at build().
  3. Routes / arcs: inline handlers, controllers, middleware, and groups on host.http and host.ws. State tokens enter the graph when routes declare state: [...] and middleware declares provides: [...].

Logger transports register on host.logging. Error handlers register on host.http.error(). CORS registers on host.http.cors(). See Host for the full registration table.

host.build() is synchronous and runs in order: config compilation, logger bootstrap, three composite validator suites, then arc compilation (pipelines, router, state wiring).

Validator **errors** throw `FlareValidationError`; **warnings** log after

compilation succeeds (such as DEAD_MIDDLEWARE and ORPHANED_CONTRACT_ENTRY).

After validators pass, the HTTP arc compiles pipelines. State provisioning is checked here: if a controller’s static state or a middleware class’s static state includes a token that no earlier middleware provides in a before() hook, compilation throws a plain Error, not a FlareValidationError. See The arc model for provisioning rules.

The three suites map one-to-one onto the three things you registered, and they run in the order the graph is layered: services first, then HTTP, then config.

  1. The service graph is checked first because every other layer resolves out of it. This family answers “can the DI container build what every class asked for”: dependencies that point at a token nobody registered, cycles in the graph, a singleton holding a scoped instance hostage, a lifecycle hook on a class that has no such phase. The representative is UNDECLARED_DEPENDENCY: a class lists Foo in static deps but Foo was never registered. If a mistake is about which service is wired to which, it fails here.
  2. The HTTP wiring is checked next, on top of a service graph already known to resolve. This family answers “does the transport surface fit together”: malformed paths, two routes claiming the same method, a route or query param naming collision, a state-provider cycle across global middleware, an invalid CORS shape. The representative is MIDDLEWARE_STATE_CYCLE: two pieces of global middleware each need a state token the other provides. If a mistake is about a path, a route, or how middleware hands state along, it fails here.
  3. The config tokens are checked last because they sit at the edge of the graph, declared against by the classes the first two families already validated. This family answers “is the config the classes asked for actually present”: a class declaring static config for a token host.cfg() never registered, or a registered token whose flare.json section or required field is missing. The representative is UNREGISTERED_CONFIG_TOKEN: a class declares static config = [DbConfig] but host.cfg(DbConfig) was never called. If a mistake is about a config section or field, it fails here.

The suites do not stop at the first error. Every validator in every family runs and the errors are collected, so one build() reports every problem across all three families at once, not just the first one found. The order above is the order they are reported in, and it lets you place an unfamiliar mistake before you read the code: a wiring problem about services lands in the first family, about routes or middleware in the second, about config in the third.

Two related checks sit just outside this set. Contract body and query schemas validate on each request, not at build (invalid bodies return 400); build time checks contract wiring only: it warns ORPHANED_CONTRACT_ENTRY for a handler-less contract key and errors with CONTRACT_KIND_MISMATCH when a controller carries a non-http contract (for example a socketContract). And app.test({ replace }) re-runs the service family against the post-replacement graph, throwing FlareTestError rather than FlareValidationError when a replacement does not extend the token it replaces.

See Failure modes for the full code catalog, example messages, and registration-time errors.

Validator and compile failures both happen before the server binds a port:

$ node src/main.ts
Error: NoDepsService is missing static 'deps'.
at FlareHost.scoped (...)
$ node src/main.ts
Error: [flare] Build failed with 1 validation error:
1. [UNDECLARED_DEPENDENCY] Service GreetService has an undeclared dependency: TagService.
Hint: Register TagService with host.scoped() or host.singleton() before calling host.build().
$ node src/main.ts
Error: MeController requires state token AuthUser that is not provided by any preceding middleware. Please ensure that a preceding middleware in the chain provides this state token.

A deploy that fails this way never accepts traffic with a half-built pipeline.

host.build() is safe to call multiple times. The second call returns the cached app:

import { FlareHost } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
const host = new FlareHost(node);
const app = host.build(); // compiles
const same = host.build(); // returns the same app instance

This matters in tests. A typical entry file calls build() and run() at module scope. When your test imports that module, build() has already run. Calling host.build() again returns the same compiled app; validators and pipeline compilation do not run twice. To drive requests, call app.test() on that cached app to get a TestAppHandle. See Testing.

In FLARE_MODE=test, scoped and singleton compilation are deferred until app.test({ replace }) so test doubles can substitute classes before any constructor runs. HTTP arc compilation still runs on the first host.build(). When app.test() runs, scoped and singleton registrations compile so substituted classes can instantiate (pipelines and router are not recompiled).