DEV Community

Cover image for @flare-ts - a TypeScript HTTP framework for Node and Cloudflare Workers
Josh Henry
Josh Henry

Posted on

@flare-ts - a TypeScript HTTP framework for Node and Cloudflare Workers

GitHub: lumex-solutions/flare-ts

A few months ago I wanted to build something on Cloudflare Workers. Hono is the obvious answer there and it's genuinely good. But I'm not a fan of the Express/Fastify style. That's not a knock on it at all, it's just not how I think about building apps. I come from an ASP.NET Core background. Controllers, DI containers, composition roots, typed pipelines. I wanted that, on Workers, with Node.js parity so the same app runs in both places. It didn't exist. So I built it.

3 months later, it's called Flare. It's at 0.1.x, "pre-release", and I figured I'd write about what I actually built.

Zero runtime dependencies

@flare-ts/lib ships with none. @flare-ts/core depends only on @flare-ts/lib. That's the whole tree. Install two packages, pull in nothing else. No transitive surprises, no version conflicts, nothing you didn't ask for.

pnpm add @flare-ts/core @flare-ts/lib
Enter fullscreen mode Exit fullscreen mode

A minimal app:

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();
Enter fullscreen mode Exit fullscreen mode

host.build() validates everything and compiles the route pipelines. app.run() binds the port. That's the whole lifecycle.

Startup validation

You register your config, services, and routes on a FlareHost, then call build(). That one call validates the entire app graph before anything binds a port or exports a handler.

Missing a service dependency? Build throws. A route needs state that no middleware provides? Build throws. Invalid CORS config? Build throws. And you get an actual useful message:

$ tsx 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().
Enter fullscreen mode Exit fullscreen mode

You don't find out about a misconfigured app from a 500 at 2am. You find out at startup. It covers the whole graph: dependency wiring, CORS config, state token provisioning, contract wiring. Different errors, same guarantee:

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.
Enter fullscreen mode Exit fullscreen mode

State tokens

Middleware in most frameworks mutates the request object and hopes the next thing downstream knows what to look for. Flare does it differently. Middleware passes typed data forward through explicit state tokens, and build() verifies that every token a route declares is actually provided by some upstream middleware in that route's chain.

const AuthUser = flareState<{ userId: string }>();

class AuthMiddleware extends MiddlewareBase {
  public static override provides = [AuthUser];

  public before() {
    const userId = verifyToken(this.ctx.req.headers.get("authorization"));
    this.ctx.set(AuthUser, { userId });
  }
}

class UsersController extends ControllerBase {
  public static override state = [AuthUser];

  @Get("/:id")
  get() {
    const { userId } = this.ctx.require(AuthUser);
    return this.ok({ userId });
  }
}
Enter fullscreen mode Exit fullscreen mode

If UsersController declares state = [AuthUser] but nothing upstream provides it, build() tells you before the server starts. No "I hope someone set this on the request object" energy.

Contracts

Routes can declare exactly what they accept. Route params, query strings, and request bodies all in one place:

import { model, str, int, optional } from "@flare-ts/lib/schema";

class CreateUser extends model({ name: str.min(1), email: str }) {}

const ApiContract = flareContract({
  getUser:    { route: { id: int }, query: { include: optional(str) } },
  createUser: { body: CreateUser },
});
Enter fullscreen mode Exit fullscreen mode

ctx.extract(descriptor) in the handler returns typed, already-coerced values. route.id is a number. query.include is string | undefined. body is a validated CreateUser instance:

@Get("/users/:id")
getUser() {
  const { route, query } = this.ctx.extract(ApiContract.getUser);
  // route.id: number
  // query.include: string | undefined
  return this.ok({ id: route.id });
}

@Post("/users")
createUser() {
  const { body } = this.ctx.extract(ApiContract.createUser);
  // body.name: string (min length 1, guaranteed)
  // body.email: string
  const user = this.userService.create(body);
  return this.created(user);
}
Enter fullscreen mode Exit fullscreen mode

Route and query validation run before any middleware. Body validation runs after middleware, immediately before the handler. If anything fails, Flare returns a 400 with field-level error details. Your code never runs with invalid input.

Logger

Most frameworks leave logging to you. Install Pino, wire it up, create child loggers per request so you get requestId on everything, pass those child loggers around through your services. It's fine, it's just friction.

Flare ships a structured logger. It's a first-class part of the framework, not an afterthought. You inject it like any other service:

class OrderService extends FlareService {
  public static override deps = [Logger];

  readonly #log = this.inject(Logger);

  place(order: { id: string }) {
    this.#log.info("placing order", { orderId: order.id });
  }
}
Enter fullscreen mode Exit fullscreen mode

All six levels are there: trace, debug, info, warn, error, fatal. The error and fatal calls accept an Error object as the first argument and handle it properly. name, message, and stack all land on the log record separately, not stringified into a message:

try {
  await db.save(order);
} catch (err) {
  this.#log.error(err, "order save failed", { orderId: order.id });
}
Enter fullscreen mode Exit fullscreen mode

Turn on enableContext in flare.json and every log record automatically gets requestId, method, and url attached without you doing anything. No child loggers. No threading a logger through function calls. It just flows.

{ "log": { "level": "info", "format": "pretty", "enableContext": true } }
Enter fullscreen mode Exit fullscreen mode

Every record your transports receive has the same shape: timestamp, level, message, and optional meta, error, context, and state. The context field is where requestId and friends live when enableContext is on.

Custom transports are trivial. Extend LoggerTransport, implement write(record):

class MetricsTransport extends LoggerTransport {
  public static override readonly transportName = "metrics";

  public write(record: LogRecord): void {
    if (record.level === "error" || record.level === "fatal") {
      // forward to your sink
      // record.error has name, message, stack already parsed out
      // record.context has requestId, method, url if enableContext is on
    }
  }
}

host.logging.transport(MetricsTransport);
Enter fullscreen mode Exit fullscreen mode

You can run multiple transports at different levels. The console transport at debug, a metrics transport at error only. Configure it in flare.json and the keys just need to match each transport's transportName:

{
  "log": {
    "level": "info",
    "transports": {
      "console": { "level": "debug" },
      "metrics": { "level": "error" }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

onStart and onStop hooks on the transport class if you need to open or close connections. Works on both Node and Cloudflare Workers. That's the whole API.

Schema and serializers

@flare-ts/lib is a standalone utility library. Zero dependencies of its own, usable outside of Flare entirely. Right now it ships schema validation and JSON serialization under ./schema, with more planned as the framework grows.

The primitives cover what you actually need: str, text, int, float, bool, uuid, date, enums, array. Most of them chain:

import { schema, str, int, bool, enums, optional, defaultTo } from "@flare-ts/lib/schema";

const PostSchema = schema({
  title:    str.min(1).max(200),
  body:     str,
  status:   enums(["draft", "published"] as const),
  views:    int.min(0),
  featured: bool,
  tags:     optional(str),
});
Enter fullscreen mode Exit fullscreen mode

safeParse accepts a JSON string, an ArrayBuffer, or a plain object. It never throws:

const result = PostSchema.safeParse(rawInput);
if (result.success) {
  result.data; // fully typed
} else {
  result.error.fields; // [{ path, message, received }]
}
Enter fullscreen mode Exit fullscreen mode

For named, extendable DTO classes, model() gives you the same parse and serialize surface with a real class you can extend:

class CreatePost extends model({ title: str.min(1), body: str }) {}
class CreatePostWithSlug extends CreatePost.extend({ slug: str }) {}
Enter fullscreen mode Exit fullscreen mode

compileSerializer generates a dedicated serialization function once at definition time and closes over it. It knows which fields need JSON escaping and which don't, and handles them differently. str and uuid fields skip escape scanning entirely. text fields always escape. The generated function is a straight string concatenation path with no branching per field at runtime:

const serialize = compileSerializer(PostSchema);
serialize({ title: "Hello", body: "World", status: "draft", views: 0, featured: false });
Enter fullscreen mode Exit fullscreen mode

Discriminated unions work too:

type Notification =
  | { kind: "email"; address: string }
  | { kind: "sms";   phone: string  };

const NotificationSchema = schema<Notification, "union">("kind", {
  email: { address: str },
  sms:   { phone: str  },
});

const result = NotificationSchema.safeParse({ kind: "email", address: "hi@example.com" });
// result.data.kind === "email" → result.data.address is typed, .phone doesn't exist
Enter fullscreen mode Exit fullscreen mode

Every branch in the union type must have a matching key. Missing or unknown discriminant values surface as field errors, not exceptions.

toJsonSchema is also there if you need JSON Schema Draft 7 output from the same definition.

Node.js and Cloudflare Workers

Same app, different adapter:

// Node.js 22+
import { node } from "@flare-ts/core/node";
const app = new FlareHost(node).build();
app.run();

// Cloudflare Workers
import { cf } from "@flare-ts/core/cloudflare";
const app = new FlareHost(cf).build();
export default app.export();
Enter fullscreen mode Exit fullscreen mode

Routes, services, contracts, middleware, and logger don't change. Swap the adapter, done. Bun and Deno adapters exist in the package but throw today. Node and Workers are the supported targets for now.

Where it is

0.1.x, pre-release. Expect breaking changes before 1.0. I don't have a published benchmark suite yet. During dev it looked good on my machine. Real numbers against Fastify and Hono are coming before 1.0.

Docs: flare-ts.dev

Discord: discord.gg/BpfrzKhhsV

If you come from an ASP.NET Core or NestJS background and you've been looking for something like that but lighter, with CF Workers support built in, take a look. PRs are open. Someone sent one on day one, which was a nice surprise.

Top comments (0)