baguette

serve

serve() is the entire wiring step. Point it at your routes directory; it imports every route in parallel, mounts each at the path its file location implies, wires up the OpenAPI docs, and starts a Bun server.

// server.ts
import { serve } from "@prehoy/baguette";

serve({ routesDir: "./api" });
// routes loaded · validation on · docs at /api/docs
bun run server.ts

What serve does

  • Loads routes in parallel at boot — route files are imported concurrently, not one by one.
  • Mounts by convention — each defineRoute lands at the path derived from its file location.
  • Serves the docs — a Scalar UI at /api/docs and the raw OpenAPI spec at /api/doc.
  • Installs the error funnel — unhandled throws become a clean 500.
  • Enables reusePort — so you can run multiple instances behind the same port.
  • Logs requests — a basic request log that never stringifies the body.

Middleware, auth & production options

serve() (and createApp()) take the options every real deployment needs — all off by default, one flag to turn on:

OptionWhat it does
authA resolver (c) => user | null. Routes marked auth: true run it — 401 on falsy, else it sets c.get("user"). See defineRoute.
securityHeaders: trueHSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and more.
corstrueorigin:"*". For cookies/Bearer from a browser use { reflect: true, credentials: true } — baguette refuses the invalid origin:"*" + credentials combo.
bodyLimitMax request body in bytes; larger → 413.
rateLimitStoreBacking store for per-route rateLimit. Default in-memory; RATELIMIT_STORE=redis (+ REDIS_URL) shares it across replicas.
spaServe a static SPA (spa: "./public") with an index.html deep-link fallback, mounted after the API.
onApp(app)Mount custom middleware/static on the Hono app before it listens.
onBoot()Run before the server accepts traffic — migrations, seed, warmup.
onShutdown()Run on SIGTERM/SIGINT after draining — close DB/cron.
serve({
  routesDir: "./api",
  auth: async (c) => verifySession(c.req.header("authorization")),
  securityHeaders: true,
  cors: { reflect: true, credentials: true }, // browser + credentials, done right
  bodyLimit: 1_000_000,
  spa: "./public",
  onBoot: () => migrate(),
  onShutdown: () => db.end(),
});

Auto-wiring the optional layers

serve() wires the optional layers by convention — a directory existing is the on switch. Nothing is imported if its directory is absent:

This keeps a plain HTTP app free of any Postgres or scheduling code it never asked for.

Environment

Validate and type the environment once at boot with defineEnv (a zod schema) instead of reading Bun.env.X in scattered places:

import { defineEnv, z } from "@prehoy/baguette";

export const env = defineEnv(z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
}));
// env.PORT is a number, env.DATABASE_URL a validated string — typed everywhere.

A missing or malformed variable fails fast at startup, listing every problem, instead of at 3am. (validateEnv(["A", "B"]) remains for a plain required-keys check.)