Composition-first TypeScript HTTP framework. Register your application graph on FlareHost, validate it with host.build(), then run the same graph on Node.js or Cloudflare Workers.
Zero third-party runtime dependencies. @flare-ts/lib ships with none; @flare-ts/core depends only on @flare-ts/lib. No Express, no Zod, no separate router package in your dependency tree.
import { FlareHost, FlareResponse } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
const host = new FlareHost(node);
host.http.get("/ping", () => new FlareResponse(200, { ok: true }));
const app = host.build();
app.run();Pre-release (0.1.x). Node and Cloudflare Workers are the supported runtimes. Bun and Deno adapters are planned. Expect breaking changes before 1.0.
pnpm add @flare-ts/core @flare-ts/libThe Node adapter requires Node.js >= 22. The packages also install with npm install, yarn add, or bun add.
import { FlareHost, FlareResponse } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
const host = new FlareHost(node);
host.http.get("/ping", () => new FlareResponse(200, { ok: true }));
const app = host.build();
app.run(); // Node: binds the port from flare.json (default 3000)host.build() finalizes composition: it validates the application graph and compiles a route pipeline for every registered route. It is not a bundler step. It returns a runtime app, which you start with app.run() on Node or app.export() on Workers.
For inline routes, controllers, contracts, request state, and testing, see the documentation.
The same endpoint, GET /users/:id returning { id }, written in Hono, Fastify, and Flare. Route params arrive as strings on the wire. The question every framework answers differently is where you coerce them.
No schema, no extra packages. The response id is the string "42".
Hono
import { Hono } from "hono";
const app = new Hono();
app.get("/users/:id", (c) => c.json({ id: c.req.param("id") }));
export default app;Fastify
import Fastify from "fastify";
const app = Fastify();
app.get<{ Params: { id: string; }; }>(
"/users/:id",
async (req) => ({ id: req.params.id }),
);
await app.listen({ port: 3000 });Flare
import { FlareHost, FlareResponse } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
const host = new FlareHost(node);
host.http.get(
"/users/:id",
(ctx) => new FlareResponse(200, { id: ctx.req.rawRouteParams["id"] ?? null }),
);
const app = host.build();
app.run();Without a contract, route params live on ctx.req.rawRouteParams as strings.
The same JSON shape with a numeric id: { "id": 42 }. Each framework takes its idiomatic path. Hono adds @hono/zod-validator and zod; Fastify uses a JSON Schema on the route; Flare puts a primitive on the contract.
Hono
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const app = new Hono();
app.get(
"/users/:id",
zValidator("param", z.object({ id: z.coerce.number() })),
(c) => c.json({ id: c.req.valid("param").id }),
);
export default app;Fastify
import Fastify from "fastify";
const app = Fastify();
app.get<{ Params: { id: number; }; }>(
"/users/:id",
{
schema: {
params: {
type: "object",
required: ["id"],
properties: { id: { type: "integer" } },
},
},
},
async (req) => ({ id: req.params.id }),
);
await app.listen({ port: 3000 });Flare
import { FlareHost, FlareResponse } from "@flare-ts/core";
import { node } from "@flare-ts/core/node";
import { int } from "@flare-ts/lib/schema";
const getUser = { route: { id: int } };
const host = new FlareHost(node);
host.http.get(
"/users/:id",
{ contract: getUser },
(ctx) => {
const { route } = ctx.extract(getUser);
return new FlareResponse(200, { id: route.id });
},
);
const app = host.build();
app.run();At the validated tier the handler bodies look similar across all three. What differs is where validation lives: a route schema, a validator middleware, or the host.build() composition step. In Flare it sits in the same graph as services, config, and serializers, and a wiring mistake fails host.build() before any traffic arrives.
Flare registers config, services, and routes on a FlareHost. When you call host.build(), the framework checks the whole graph, so a broken HTTP boundary surfaces before traffic instead of on the first request that hits it.
Contracts that coerce. Route params, query fields, and request bodies are parsed once per request, before the handler runs. ctx.extract(descriptor) returns typed route, query, and body. Optional per-status response shapes strip extra fields on the way out. See Contracts.
Built-in schema library. @flare-ts/lib provides primitives (str, int, uuid, array, optional, and more) and model() for request and response shapes. Contracts and serializers share one set of definitions, so there is no second validation library to keep in sync. See Schema.
Whole-graph validation. Missing service registrations, undeclared inject() or config() calls, dependency cycles, and state tokens that no middleware provides throw during host.build() as FlareValidationError. See Composition.
Explicit static DI. Services and controllers declare static deps; inline routes declare { inject: [...] }. The framework resolves only what you list. See Dependency injection.
Typed request state. flareState() tokens carry defaults, derivations, and log-field mapping. Middleware writes state; handlers read frozen snapshots through ctx.state. Unsatisfiable state wiring is caught at host.build(). See State.
Per-route pipeline compilation. Each route compiles to a dedicated execution function covering middleware, handler, and serializers. The request path does not walk decorators or rebuild a pipeline per request. See Arc model.
One host, two runtimes. The same controllers, services, contracts, and middleware run on Node and Cloudflare Workers. Swapping the adapter and choosing .run() or .export() is the only difference. See Runtimes.
Integration tests on the composed app. With FLARE_MODE=test set, app.test() returns a handle whose fetch() drives requests through the same pipelines production uses. No listen port, and an optional { replace } map swaps in service doubles. See Testing.
| Runtime | Import | Start |
|---|---|---|
| Node.js >= 22 | import { node } from "@flare-ts/core/node" |
const app = host.build(); app.run() |
| Cloudflare Workers | import { cf } from "@flare-ts/core/cloudflare" |
const app = host.build(); export default app.export() |
| Bun (planned) | @flare-ts/core/bun |
The adapter import exists but is not yet functional. Use the node or cf adapter for now. |
| Deno (planned) | @flare-ts/core/deno |
The adapter import exists but is not yet functional, and is not in CI yet. Use the node or cf adapter for now. |
On Workers, use the cf adapter. It reads config from FLARE__-prefixed values in the wrangler.toml [vars] table, because a Worker cannot read flare.json from disk at runtime:
import { FlareHost } from "@flare-ts/core";
import { cf } from "@flare-ts/core/cloudflare";
const host = new FlareHost(cf);
// ...same registrations as Node...
const app = host.build();
export default app.export();To embed flare.json values in the Worker bundle instead of reading them from [vars], use buildCf:
import { FlareHost } from "@flare-ts/core";
import { buildCf } from "@flare-ts/core/cloudflare";
import flareJson from "./flare.json" with { type: "json" };
const host = new FlareHost(buildCf(flareJson));
// ...same registrations as Node...
const app = host.build();
export default app.export();pnpm add @flare-ts/core @flare-ts/libAn optional flare.json sets the port, logging, and custom config sections, and gives you editor autocomplete through the bundled schema:
Full documentation is at flare-ts.dev.
Contributions are welcome. The toolchain requires Node.js >= 22 and pnpm 10:
pnpm install
pnpm test
pnpm checkOpen issues and pull requests at lumex-solutions/flare-ts.
See the repository for license terms.
{ "$schema": "./node_modules/@flare-ts/core/flare.schema.json", "host": { "port": 4000, "env": "development" }, "log": { "level": "debug", "format": "pretty" } }