This is the full developer documentation for Lacis # Lacis > File-based routing. Zero dependencies. Node, Bun, Vercel, Netlify. Zero dependencies No runtime dependencies. Lacis ships as a single package with nothing extra pulling in. File-based routing Drop a file in `routes/` and it becomes an endpoint. No registration, no config. Multi-platform One codebase, four targets: Node.js, Bun, Vercel, and Netlify via first-class adapters. Standard Schema validation Bring your own validator — Zod, Valibot, or ArkType. Full type inference included. Batteries included CORS, rate limiting, response caching, SSE, and OpenAPI generation out of the box. TypeScript-first Fully typed request and response objects. No casting, no `any`. ## For AI tools [Section titled “For AI tools”](#for-ai-tools) This documentation is available in LLM-friendly formats: * [llms.txt](https://lacis.lycia.dev/llms.txt) — documentation index * [Abridged documentation](https://lacis.lycia.dev/llms-small.txt) — compact version with non-essential content removed * [Complete documentation](https://lacis.lycia.dev/llms-full.txt) — full documentation content # Middleware > Global cascade and exact-path middleware in Lacis. Lacis middleware is file-based. Place a middleware file alongside your route files and Lacis loads it automatically. There are two filename conventions with different scoping behaviors. Key distinction `+middleware.global.ts` cascades to the current directory and all subdirectories. `+middleware.ts` applies only to routes at that exact directory level and does not cascade. ## The two types [Section titled “The two types”](#the-two-types) ### `+middleware.global.ts` — cascading [Section titled “+middleware.global.ts — cascading”](#middlewareglobalts--cascading) Runs for every route at its directory level and all routes below it. Right for concerns like authentication, logging, or CORS that apply to an entire section of your API. ### `+middleware.ts` — exact path [Section titled “+middleware.ts — exact path”](#middlewarets--exact-path) Runs only when the route path matches the directory that contains it. Does not run for child routes. ## File structure [Section titled “File structure”](#file-structure) * routes/ * +middleware.global.ts (runs for every route) * index.ts (GET /) * api/ * +middleware.global.ts (runs for /api and all /api/\* routes) * +middleware.ts (runs for /api only — NOT /api/users) * index.ts (GET /api) * users/ * index.ts (GET /api/users) * \[id]/ * index.ts (GET /api/users/:id) For the route `GET /api/users`, the execution order is: 1. `routes/+middleware.global.ts` `beforeRequest` 2. `routes/api/+middleware.global.ts` `beforeRequest` 3. Route handler 4. `routes/api/+middleware.global.ts` `afterRequest` 5. `routes/+middleware.global.ts` `afterRequest` `routes/api/+middleware.ts` does **not** run for `/api/users` — only for `/api` itself. ## Hooks [Section titled “Hooks”](#hooks) Each middleware file can export any combination of `beforeRequest`, `afterRequest`, and `onError`. ### `beforeRequest` [Section titled “beforeRequest”](#beforerequest) Runs before the route handler. Returning `false` stops the pipeline — the handler and all subsequent middleware are skipped. ```ts // routes/api/+middleware.global.ts import type { Request, Response } from 'lacis' export const beforeRequest = async (req: Request, res: Response) => { const token = req.getHeader('authorization') if (!token) { res.status(401).json({ error: 'Unauthorized' }) return false } } ``` You can export an array — handlers run in order, and the first `false` stops the chain. ```ts export const beforeRequest = [authCheck, rateLimitCheck] ``` ### `afterRequest` [Section titled “afterRequest”](#afterrequest) Runs after the route handler completes. ```ts export const afterRequest = async (req: Request, res: Response) => { console.log(`${req.method} ${req.url}`) } ``` ### `onError` [Section titled “onError”](#onerror) Runs when an unhandled error is thrown by any middleware or the route handler. ```ts export const onError = async ( req: Request, res: Response, context: { error: unknown; phase: string } ) => { console.error(`[${context.phase}]`, context.error) if (!res.headersSent) { res.status(500).json({ error: 'Internal Server Error' }) } } ``` ## Registering middleware in `createServer` [Section titled “Registering middleware in createServer”](#registering-middleware-in-createserver) For middleware that should apply globally, pass it directly to `createServer`: ```ts import { createServer } from 'lacis' createServer('./routes', { middleware: { beforeRequest: async (req, res) => { console.log(`--> ${req.method} ${req.url}`) }, afterRequest: async (req, res) => { console.log(`<-- ${req.method} ${req.url}`) }, onError: async (req, res, ctx) => { console.error('Unhandled error:', ctx.error) }, }, }) ``` Each property accepts a single handler or an array. ## Lifecycle hooks [Section titled “Lifecycle hooks”](#lifecycle-hooks) Lifecycle hooks are registered under `hooks` in `createServer`. ### `onNotFound` [Section titled “onNotFound”](#onnotfound) Called when no route matches the request. If the hook sends a response, the default 404 JSON is skipped. ```ts createServer('./routes', { hooks: { onNotFound: async (req, res) => { res.status(404).json({ error: 'Not found', path: req.url }) }, }, }) ``` ### `onShutdown` [Section titled “onShutdown”](#onshutdown) Called during graceful shutdown on `SIGINT`, `SIGTERM`, or `SIGHUP`. Use it to close database connections or flush buffers. ```ts createServer('./routes', { hooks: { onShutdown: async () => { await db.end() await cache.quit() }, }, }) ``` `onShutdown` runs after the server stops accepting connections. Errors inside it are caught and logged — they do not prevent shutdown. # Request & Response > Complete API reference for the Request and Response objects in Lacis. Every route handler receives a `req` (Request) and `res` (Response) object. ```ts import type { Request, Response } from 'lacis' export async function GET(req: Request, res: Response) { res.json({ ok: true }) } ``` *** ## Request [Section titled “Request”](#request) ### `req.params` [Section titled “req.params”](#reqparams) URL path parameters extracted from dynamic route segments. ```ts // routes/users/[id]/index.ts → /users/:id export async function GET(req: Request, res: Response) { const { id } = req.params res.json({ id }) } ``` **Type:** `Record` *** ### `req.query` [Section titled “req.query”](#reqquery) The parsed query string as a flat key-value object. Values are always strings — coerce as needed. ```ts // GET /search?q=lacis&page=2 export async function GET(req: Request, res: Response) { const { q, page } = req.query res.json({ q, page: Number(page) }) } ``` **Type:** `Record` *** ### `req.json()` [Section titled “req.json\()”](#reqjsont) Reads and parses the request body as JSON. ```ts interface CreateUser { name: string email: string } export async function POST(req: Request, res: Response) { const body = await req.json() res.status(201).json({ created: body.name }) } ``` **Signature:** `json(): Promise` Caution The body stream can only be read once. Call `req.json()` a single time per request. *** ### `req.form()` [Section titled “req.form\()”](#reqformt) Parses a `multipart/form-data` body. Plain fields are strings; file fields are `UploadedFile` objects. ```ts export async function POST(req: Request, res: Response) { const form = await req.form<{ name: string; avatar: UploadedFile }>() console.log(form.name) // 'Alice' console.log(form.avatar.size) // file size in bytes res.status(201).json({ ok: true }) } ``` **Signature:** `form(): Promise` *** ### `req.body()` [Section titled “req.body()”](#reqbody) Reads the raw request body as a `Buffer`. Useful for binary payloads or custom parsing. ```ts export async function POST(req: Request, res: Response) { const raw = await req.body() const text = raw.toString('utf-8') res.send(text) } ``` **Signature:** `body(): Promise` Maximum body size is 10 MB. Larger requests are rejected with `413 Payload Too Large`. *** ### `req.getHeader(name)` [Section titled “req.getHeader(name)”](#reqgetheadername) Reads a single request header. Lookup is case-insensitive. ```ts export async function GET(req: Request, res: Response) { const auth = req.getHeader('authorization') if (!auth) return res.status(401).json({ error: 'Unauthorized' }) res.json({ ok: true }) } ``` **Signature:** `getHeader(name: string): string | undefined` *** ### `req.cookies.get(name)` [Section titled “req.cookies.get(name)”](#reqcookiesgetname) Reads a single cookie from the incoming `Cookie` header. The value is URL-decoded automatically. ```ts export async function GET(req: Request, res: Response) { const sessionId = req.cookies.get('session') if (!sessionId) return res.status(401).json({ error: 'No session' }) res.json({ session: sessionId }) } ``` **Signature:** `get(name: string): string | undefined` *** ### `req.cookies.all()` [Section titled “req.cookies.all()”](#reqcookiesall) Returns all cookies as a plain object. ```ts export async function GET(req: Request, res: Response) { const cookies = req.cookies.all() res.json(cookies) } ``` **Signature:** `all(): Record` *** ### `req.connection.remoteAddress` [Section titled “req.connection.remoteAddress”](#reqconnectionremoteaddress) The client’s IP address. For requests behind a reverse proxy, use the `X-Forwarded-For` header instead. ```ts export async function GET(req: Request, res: Response) { const ip = req.getHeader('x-forwarded-for') ?? req.connection.remoteAddress res.json({ ip }) } ``` *** ## Response [Section titled “Response”](#response) ### `res.json(data)` [Section titled “res.json(data)”](#resjsondata) Serializes `data` to JSON, sets `Content-Type: application/json`, and ends the response. ```ts res.json({ users: ['alice', 'bob'] }) ``` *** ### `res.html(content)` [Section titled “res.html(content)”](#reshtmlcontent) Sends an HTML string with `Content-Type: text/html; charset=utf-8`. ```ts res.html('

Hello, world

') ``` *** ### `res.send(data)` [Section titled “res.send(data)”](#ressenddata) Sends a plain text response. If `data` is not a string, delegates to `res.json()`. ```ts res.send('pong') ``` *** ### `res.redirect(url, status?)` [Section titled “res.redirect(url, status?)”](#resredirecturl-status) Redirects the client. Default status is `302`. ```ts res.redirect('/login') res.redirect('/new-path', 301) ``` *** ### `res.status(code)` [Section titled “res.status(code)”](#resstatuscode) Sets the HTTP status code. Chainable. ```ts res.status(201).json({ created: true }) res.status(204).send('') ``` *** ### `res.setHeader(name, value)` [Section titled “res.setHeader(name, value)”](#ressetheadername-value) Sets a response header. Must be called before the response ends. ```ts res.setHeader('X-Custom-Header', 'lacis') res.json({ ok: true }) ``` *** ### `res.cookies.set(name, value, options?)` [Section titled “res.cookies.set(name, value, options?)”](#rescookiessetname-value-options) Queues a `Set-Cookie` header. Chainable. ```ts res.cookies .set('session', 'abc123', { httpOnly: true, secure: true, sameSite: 'Lax', maxAge: 60 * 60 * 24 * 7, }) .set('theme', 'dark') res.json({ ok: true }) ``` | Option | Type | Description | | ---------- | ----------------------------- | -------------------------- | | `path` | `string` | Cookie path (default: `/`) | | `domain` | `string` | Cookie domain | | `maxAge` | `number` | Max age in seconds | | `expires` | `Date` | Absolute expiry | | `httpOnly` | `boolean` | Prevent JS access | | `secure` | `boolean` | HTTPS only | | `sameSite` | `'Strict' \| 'Lax' \| 'None'` | SameSite policy | *** ### `res.cookies.delete(name)` [Section titled “res.cookies.delete(name)”](#rescookiesdeletename) Deletes a cookie by setting `Max-Age: 0`. ```ts res.cookies.delete('session') res.json({ loggedOut: true }) ``` # Routing > File-based routing in Lacis — how files map to URLs, HTTP methods, dynamic segments, and wildcards. Lacis generates routes automatically from your `routes/` directory. Every `index.ts` file inside that directory becomes a URL endpoint — no manual route registration needed. ## Directory structure [Section titled “Directory structure”](#directory-structure) Place route files under `routes/`. The directory hierarchy maps directly to URL paths. * routes/ * index.ts → GET / * users/ * index.ts → /users * \[id]/ * index.ts → /users/:id * posts/ * index.ts → /posts * \[slug]/ * index.ts → /posts/:slug Note Only `index.ts` (or `index.js`) files are treated as route handlers. Other files in the directory are ignored, so you can colocate utilities, types, and helpers freely. ## HTTP method exports [Section titled “HTTP method exports”](#http-method-exports) Export named functions matching uppercase HTTP method names. Each export handles that method for the file’s URL. ```ts // routes/users/index.ts → /users import type { Request, Response } from 'lacis' export async function GET(req: Request, res: Response) { res.json({ users: [] }) } export async function POST(req: Request, res: Response) { const body = await req.json() res.status(201).json({ created: body }) } export async function PUT(req: Request, res: Response) { const body = await req.json() res.json({ updated: body }) } export async function PATCH(req: Request, res: Response) { const body = await req.json() res.json({ patched: body }) } export async function DELETE(req: Request, res: Response) { res.status(204).send('') } ``` Supported method exports: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. ## Dynamic segments [Section titled “Dynamic segments”](#dynamic-segments) Wrap a directory name in square brackets to make it a URL parameter. The parameter name is the text inside the brackets. * routes/ * users/ * \[id]/ * index.ts → /users/:id * orgs/ * \[orgId]/ * teams/ * \[teamId]/ * index.ts → /orgs/:orgId/teams/:teamId Access parameters via `req.params`: ```ts // routes/users/[id]/index.ts → /users/:id import type { Request, Response } from 'lacis' export async function GET(req: Request, res: Response) { const { id } = req.params res.json({ id }) } ``` Caution Static segments take priority over dynamic ones. A request to `/users/settings` matches `routes/users/settings/index.ts` before `routes/users/[id]/index.ts`. ## Nested routes [Section titled “Nested routes”](#nested-routes) Nest directories to create nested URL paths. Each level can have its own `index.ts` with independent handlers. * routes/ * api/ * index.ts → /api * users/ * index.ts → /api/users * \[id]/ * index.ts → /api/users/:id * posts/ * index.ts → /api/users/:id/posts ```ts // routes/api/users/[id]/posts/index.ts → /api/users/:id/posts import type { Request, Response } from 'lacis' export async function GET(req: Request, res: Response) { const { id } = req.params res.json({ userId: id, posts: [] }) } ``` ## Wildcard routes [Section titled “Wildcard routes”](#wildcard-routes) Use a `[...]` directory name to match any remaining path segments. * routes/ * files/ * \[…path]/ * index.ts → /files/\* ```ts // routes/files/[...path]/index.ts import type { Request, Response } from 'lacis' export async function GET(req: Request, res: Response) { const filePath = req.params.path res.json({ path: filePath }) } ``` ## Method Not Allowed [Section titled “Method Not Allowed”](#method-not-allowed) When a route exists but the request uses an unregistered HTTP method, Lacis returns `405 Method Not Allowed` and sets an `Allow` header listing the supported methods automatically. ## Complete example [Section titled “Complete example”](#complete-example) ```ts // routes/index.ts → GET / import type { Request, Response } from 'lacis' export async function GET(req: Request, res: Response) { res.json({ name: 'My API', version: '1.0.0' }) } ``` ```ts // routes/users/index.ts → /users import type { Request, Response } from 'lacis' export async function GET(req: Request, res: Response) { res.json({ users: [] }) } export async function POST(req: Request, res: Response) { const body = await req.json<{ name: string; email: string }>() res.status(201).json({ id: crypto.randomUUID(), ...body }) } ``` ```ts // routes/users/[id]/index.ts → /users/:id import type { Request, Response } from 'lacis' export async function GET(req: Request, res: Response) { const { id } = req.params res.json({ id, name: 'Alice' }) } export async function DELETE(req: Request, res: Response) { res.status(204).send('') } ``` # Bun > Deploy Lacis on Bun with the bunAdapter. Long-running server The Bun adapter runs a persistent server using `Bun.serve()`. It scans your `routes/` directory at startup — no pre-compilation step required. ## Entry point [Section titled “Entry point”](#entry-point) ```ts // server.ts import { bunAdapter } from 'lacis/adapters' const createServer = bunAdapter.createHandler('./routes') createServer({ port: 3000, isDev: process.env.NODE_ENV !== 'production', }) ``` ## How the Bun adapter differs [Section titled “How the Bun adapter differs”](#how-the-bun-adapter-differs) * **Native fetch handler** — requests are handled as standard `Request`/`Response` objects, wrapped in thin `BunRequest`/`BunResponse` classes * **TransformStream for SSE** — Bun’s `Bun.serve()` needs the `Response` synchronously, so `initSSE()` must be called before the first `await` in your handler * **Faster JSON parsing** — delegates directly to Bun’s native JSON parser ## SSE constraint [Section titled “SSE constraint”](#sse-constraint) ```ts export async function GET(req, res) { res.initSSE() // must be before any await const data = await fetchSomething() // ... } ``` Danger If `initSSE()` is called after an `await`, Lacis throws: `initSSE() must be called synchronously before any await in your handler`. ## Config options [Section titled “Config options”](#config-options) | Option | Type | Default | Description | | ---------------- | ------------------------ | ------- | ------------------------------------------------ | | `port` | `number` | `3000` | Port to listen on | | `isDev` | `boolean` | `false` | Enables dev mode | | `defaultHeaders` | `Record` | — | Headers added to every response | | `cluster` | `object` | — | Multi-worker via `reusePort` | | `cors` | `CorsConfig` | — | CORS policy | | `middleware` | `object` | — | `beforeRequest`, `afterRequest`, `onError` hooks | | `hooks` | `object` | — | `onNotFound`, `onShutdown` hooks | ## Cluster mode [Section titled “Cluster mode”](#cluster-mode) Lacis spawns worker processes with `Bun.spawn()` and uses `reusePort: true` so the OS distributes connections across workers. ```ts createServer({ port: 3000, cluster: { enabled: true, workers: 4 }, }) ``` Workers poll the primary’s PID every 2 seconds and exit cleanly if the primary is gone. ## Running in production [Section titled “Running in production”](#running-in-production) 1. Build: ```bash bun run build ``` 2. Start: ```bash bun dist/server.js ``` # Netlify > Deploy Lacis on Netlify using the netlifyAdapter and a pre-built routes manifest. Serverless — routes must be pre-compiled Netlify functions have no persistent filesystem. Lacis cannot scan your `routes/` directory at request time. You must run `lacis build` first to generate a routes manifest. ## How it differs from Node/Bun [Section titled “How it differs from Node/Bun”](#how-it-differs-from-nodebun) | | Node / Bun | Vercel / Netlify | | --------------- | ------------------------- | ---------------------------- | | Config | `routesDir` string | `ServerlessConfig` object | | Route discovery | `loadRoutes()` at startup | Pre-built manifest | | Build step | Optional | **Required** (`lacis build`) | ## Project structure [Section titled “Project structure”](#project-structure) ```plaintext my-app/ ├── netlify/ │ └── functions/ │ └── api.ts ← Netlify function entry point ├── routes/ │ ├── users/ │ │ └── index.ts │ └── posts/ │ └── [id]/ │ └── index.ts └── netlify.toml ``` ## Function handler [Section titled “Function handler”](#function-handler) ```ts // netlify/functions/api.ts import { netlifyAdapter } from 'lacis/adapters' import { routes, middlewares } from '../../routes/_manifest.js' const handler = netlifyAdapter.createHandler({ routes, middlewares }) export { handler } ``` ## netlify.toml [Section titled “netlify.toml”](#netlifytoml) ```toml [build] command = "lacis build" publish = "public" [[redirects]] from = "/*" to = "/.netlify/functions/api" status = 200 ``` Setting `command = "lacis build"` regenerates the routes manifest on every deploy automatically. ## Deploy flow [Section titled “Deploy flow”](#deploy-flow) 1. Install dependencies: ```bash npm install lacis @netlify/functions ``` 2. Commit your function and config: ```bash git add netlify/ netlify.toml git commit -m "add lacis netlify handler" ``` 3. Deploy: ```bash netlify deploy --prod ``` Netlify picks up the build command from `netlify.toml` and regenerates the manifest automatically. ## Optional config [Section titled “Optional config”](#optional-config) ```ts const handler = netlifyAdapter.createHandler({ routes, middlewares, cors: { origin: 'https://myapp.com', credentials: true }, hooks: { onNotFound: async (req, res) => { res.status(404).json({ error: 'Not found', path: req.url }) }, }, }) ``` # Node.js > Deploy Lacis on Node.js with the nodeAdapter. Long-running server The Node.js adapter runs a persistent HTTP(S) server. It scans your `routes/` directory at startup — no pre-compilation step required. ## Entry point [Section titled “Entry point”](#entry-point) ```ts // server.ts import { nodeAdapter } from 'lacis/adapters' const createServer = nodeAdapter.createHandler('./routes') createServer({ port: 3000, isDev: process.env.NODE_ENV !== 'production', }) ``` ## Config options [Section titled “Config options”](#config-options) | Option | Type | Default | Description | | ---------------- | ------------------------ | -------------------- | ------------------------------------------------ | | `port` | `number` | `3000` | Port to listen on | | `isDev` | `boolean` | `false` | Enables dev mode | | `defaultHeaders` | `Record` | — | Headers added to every response | | `httpsOptions` | `object` | — | TLS config | | `cluster` | `object` | — | Multi-process clustering | | `monitoring` | `object` | `{ enabled: false }` | Dev performance monitoring | | `cors` | `CorsConfig` | — | CORS policy | | `middleware` | `object` | — | `beforeRequest`, `afterRequest`, `onError` hooks | | `hooks` | `object` | — | `onNotFound`, `onShutdown` hooks | ## Running in production [Section titled “Running in production”](#running-in-production) 1. Build your TypeScript: ```bash npm run build ``` 2. Start the server: ```bash node dist/server.js ``` 3. Use a process manager for resilience: ```bash pm2 start dist/server.js --name my-api ``` ## HTTPS [Section titled “HTTPS”](#https) ```ts import { readFileSync } from 'fs' import { nodeAdapter } from 'lacis/adapters' const createServer = nodeAdapter.createHandler('./routes') createServer({ port: 443, httpsOptions: { cert: readFileSync('./certs/cert.pem'), key: readFileSync('./certs/key.pem'), }, }) ``` ## Cluster mode [Section titled “Cluster mode”](#cluster-mode) Uses Node’s built-in `cluster` module. The primary process manages workers and restarts any that crash. ```ts createServer({ port: 3000, cluster: { enabled: true, workers: 4, // defaults to os.cpus().length }, }) ``` ## Dev monitoring [Section titled “Dev monitoring”](#dev-monitoring) When `isDev: true` and `monitoring.enabled: true`, a `/health` endpoint returns live performance metrics: ```ts createServer({ isDev: true, monitoring: { enabled: true, sampleInterval: 5000, reportInterval: 60000, thresholds: { cpu: 80, memory: 512, responseTime: 500, errorRate: 5 }, }, }) ``` # Vercel > Deploy Lacis on Vercel using the vercelAdapter and a pre-built routes manifest. Serverless — routes must be pre-compiled Vercel functions have no persistent filesystem. Lacis cannot scan your `routes/` directory at request time. You must run `lacis build` first to generate a routes manifest. ## How it differs from Node/Bun [Section titled “How it differs from Node/Bun”](#how-it-differs-from-nodebun) | | Node / Bun | Vercel / Netlify | | --------------- | ------------------------- | ---------------------------- | | Config | `routesDir` string | `ServerlessConfig` object | | Route discovery | `loadRoutes()` at startup | Pre-built manifest | | Build step | Optional | **Required** (`lacis build`) | ## Project structure [Section titled “Project structure”](#project-structure) ```plaintext my-app/ ├── api/ │ └── [...slug].ts ← Vercel catch-all handler ├── routes/ │ ├── users/ │ │ └── index.ts │ └── posts/ │ └── [id]/ │ └── index.ts └── vercel.json ``` ## API handler [Section titled “API handler”](#api-handler) ```ts // api/[...slug].ts import { vercelAdapter } from 'lacis/adapters' import { routes, middlewares } from '../routes/_manifest.js' const handler = vercelAdapter.createHandler({ routes, middlewares }) export default handler ``` Note `createHandler` initializes routes, CORS, middleware, and hooks once on first invocation and caches the result for subsequent requests in the same function instance. ## vercel.json [Section titled “vercel.json”](#verceljson) ```json { "rewrites": [ { "source": "/(.*)", "destination": "/api/slug" } ] } ``` ## Deploy flow [Section titled “Deploy flow”](#deploy-flow) 1. Add `lacis build` to your build command in `package.json`: ```json { "scripts": { "build": "lacis build" } } ``` 2. Commit your handler: ```bash git add api/ vercel.json git commit -m "add lacis vercel handler" ``` 3. Deploy: ```bash vercel deploy --prod ``` Vercel runs `lacis build` automatically on each deploy, regenerating the routes manifest. ## Optional config [Section titled “Optional config”](#optional-config) ```ts const handler = vercelAdapter.createHandler({ routes, middlewares, cors: { origin: 'https://myapp.com', credentials: true }, hooks: { onNotFound: async (req, res) => { res.status(404).json({ error: 'Not found', path: req.url }) }, }, }) ``` # Caching > HTTP response caching in Lacis. Lacis provides two caching APIs: middleware-based (`createResponseCache`) and handler-wrapping (`withCache`). ## Middleware approach [Section titled “Middleware approach”](#middleware-approach) `createResponseCache` registers a middleware that caches responses before they are sent. Apply it globally or per-route. ```ts import { createServer } from 'lacis' import { createResponseCache } from 'lacis' createServer('./routes', { middleware: { beforeRequest: createResponseCache({ ttl: 60 }), }, }) ``` ### Options [Section titled “Options”](#options) | Option | Type | Default | Description | | -------------- | ----------------------- | ----------------- | ----------------------------------- | | `ttl` | `number` | required | Cache duration in **seconds** | | `methods` | `string[]` | `['GET', 'HEAD']` | HTTP methods to cache | | `maxSize` | `number` | `500` | Max entries before LRU eviction | | `keyGenerator` | `(req) => string` | `METHOD:URL` | Cache key function | | `match` | `(req) => boolean` | — | Only cache when this returns `true` | | `exclude` | `string \| string[]` | — | Path prefixes to skip | | `shouldCache` | `(req, res) => boolean` | 2xx status codes | Whether to store a response | ### Excludes [Section titled “Excludes”](#excludes) ```ts createResponseCache({ ttl: 60, exclude: ['/admin', '/api/private'], }) ``` ## Handler wrapping [Section titled “Handler wrapping”](#handler-wrapping) `withCache` wraps an individual handler. Useful when you want caching on one route without a global middleware. ```ts import { withCache } from 'lacis' import type { Request, Response } from 'lacis' export const GET = withCache( { ttl: 300 }, async (req: Request, res: Response) => { const data = await fetchExpensiveData() res.json(data) } ) ``` ### Options [Section titled “Options”](#options-1) | Option | Type | Default | Description | | --------- | ----------------- | ------------ | ------------------------------- | | `ttl` | `number` | required | Cache duration in **seconds** | | `maxSize` | `number` | `500` | Max entries before LRU eviction | | `key` | `(req) => string` | `METHOD:URL` | Cache key function | ## LRU eviction [Section titled “LRU eviction”](#lru-eviction) When `maxSize` is reached, the 50 least recently used entries are evicted at once. Entries also expire individually based on `ttl`. ## Distributed store [Section titled “Distributed store”](#distributed-store) Both `createResponseCache` and `withCache` accept a `store` option to use an external backend instead of the in-memory default. **Upstash Redis example:** ```ts import { createResponseCache } from 'lacis' import type { CacheStore, CacheEntry } from 'lacis' import { Redis } from '@upstash/redis' const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! }) const store: CacheStore = { async get(key) { return await redis.get(key) ?? undefined }, async set(key, entry) { const ttl = Math.ceil((entry.expiresAt - Date.now()) / 1000) await redis.set(key, entry, { ex: ttl }) }, } createResponseCache({ ttl: 60, store }) // or: withCache({ ttl: 60, store }, handler) ``` Note SSE responses (`text/event-stream`) and responses that set `Set-Cookie` are **never cached**, regardless of configuration. # CORS > Cross-origin resource sharing configuration in Lacis. ## Setup [Section titled “Setup”](#setup) Pass a `cors` object to `createServer`: ```ts import { createServer } from 'lacis' createServer('./routes', { cors: { origin: 'https://myapp.com', credentials: true, }, }) ``` Or use `createCorsMiddleware` directly to register it as middleware: ```ts import { createCorsMiddleware } from 'lacis' export const beforeRequest = createCorsMiddleware({ origin: ['https://myapp.com', 'https://admin.myapp.com'], credentials: true, }) ``` ## Options [Section titled “Options”](#options) | Option | Type | Default | Description | | ---------------- | ---------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------- | | `origin` | `string \| string[] \| RegExp \| ((origin: string) => boolean) \| '*'` | `'*'` | Allowed origins | | `methods` | `string[]` | `['GET','POST','PUT','DELETE','PATCH','OPTIONS']` | Allowed HTTP methods | | `allowedHeaders` | `string[]` | `['Content-Type','Authorization']` | Allowed request headers | | `exposedHeaders` | `string[]` | — | Headers exposed to the browser | | `credentials` | `boolean` | — | Allow cookies / credentials | | `maxAge` | `number` | — | Preflight cache duration in seconds | ## Origin formats [Section titled “Origin formats”](#origin-formats) ```ts // Single origin origin: 'https://myapp.com' // Multiple origins origin: ['https://myapp.com', 'https://admin.myapp.com'] // RegExp origin: /\.myapp\.com$/ // Custom function origin: (origin) => origin.endsWith('.myapp.com') // Allow all (default) origin: '*' ``` Note When `credentials: true` is set alongside `origin: '*'`, Lacis automatically reflects the actual request `Origin` header instead of responding with `*`. This is required by the CORS spec — browsers reject credentialed requests with a wildcard origin. ## Preflight [Section titled “Preflight”](#preflight) Lacis handles `OPTIONS` preflight requests automatically. When a preflight comes in, it responds with `204` and the appropriate `Access-Control-Allow-*` headers, then stops further processing. # OpenAPI > Automatic OpenAPI spec generation in Lacis. Lacis generates an OpenAPI 3.1.0 spec at runtime from your registered routes. Routes wrapped with `defineHandler` contribute their schema and `meta` fields to the spec. ## Setup [Section titled “Setup”](#setup) Add an `openapi` key to your server config: ```ts // server.ts import { createServer } from 'lacis' createServer('./routes', { openapi: { path: '/openapi.json', info: { title: 'My API', version: '1.0.0', }, }, }) ``` The spec is served at `/openapi.json` on every request. No build step required. ## Schema converters [Section titled “Schema converters”](#schema-converters) The spec is built by converting your validator schemas to JSON Schema. Each library requires its own converter: | Library | Package to install | | --------- | ------------------------------- | | Zod 4.4+ | none (native `toJSONSchema`) | | Zod < 4.4 | `zod-to-json-schema` | | Valibot | `@valibot/to-json-schema` | | ArkType | none (native `.toJsonSchema()`) | Install only the converter that matches your validator. ## Annotating routes with `meta` [Section titled “Annotating routes with meta”](#annotating-routes-with-meta) ```ts // routes/users/[id]/index.ts import { defineHandler } from 'lacis' import { z } from 'zod' export const GET = defineHandler({ params: z.object({ id: z.string().uuid() }), meta: { summary: 'Get user by ID', description: 'Returns a single user record.', tags: ['users'], }, handler: async (req, res) => { res.json({ id: req.params.id }) }, }) export const DELETE = defineHandler({ params: z.object({ id: z.string().uuid() }), meta: { summary: 'Delete user', tags: ['users'], deprecated: true }, handler: async (req, res) => { res.status(204).send('') }, }) ``` ## Using the spec [Section titled “Using the spec”](#using-the-spec) The generated JSON is standard OpenAPI 3.1.0 — drop it into any compatible tool. **Scalar** (recommended): ```html API Reference ``` **Postman / Insomnia**: use “Import from URL” → `http://localhost:3000/openapi.json`. **OpenAPI Generator**: generate clients, SDKs, or server stubs in 50+ languages from the spec. ```bash # Install npm install @openapitools/openapi-generator-cli -g # Generate a TypeScript fetch client openapi-generator-cli generate \ -i http://localhost:3000/openapi.json \ -g typescript-fetch \ -o ./generated/client ``` See the [OpenAPI Generator docs](https://openapi-generator.tech/docs/installation/) for all supported generators and options. ## Routes without `defineHandler` [Section titled “Routes without defineHandler”](#routes-without-definehandler) Plain handlers appear in the spec with a minimal operation. Add `defineHandler` + `meta` to get richer output. # Rate Limiting > Built-in rate limiting in Lacis. ## Setup [Section titled “Setup”](#setup) Pass a rate limit middleware to `createServer` to apply it globally: ```ts import { createServer } from 'lacis' import { createRateLimit } from 'lacis' createServer('./routes', { middleware: { beforeRequest: createRateLimit({ windowMs: 60_000, max: 100 }), }, }) ``` Or scope it to a specific route via a middleware file: ```ts // routes/api/+middleware.ts import { createRateLimit } from 'lacis' export const beforeRequest = createRateLimit({ windowMs: 60_000, max: 20 }) ``` ## Options [Section titled “Options”](#options) | Option | Type | Default | Description | | -------------- | ----------------- | ---------------------------------------------------------- | ---------------------------------- | | `windowMs` | `number` | `60000` | Time window in milliseconds | | `max` | `number` | `100` | Max requests per window per key | | `message` | `string` | `'Too Many Requests'` | Error message in the 429 response | | `keyGenerator` | `(req) => string` | First IP from `X-Forwarded-For`, or `socket.remoteAddress` | Function to identify the requester | ## Response headers [Section titled “Response headers”](#response-headers) Every response includes: | Header | Description | | ----------------------- | ----------------------------------------------- | | `X-RateLimit-Limit` | The configured `max` | | `X-RateLimit-Remaining` | Requests left in the current window | | `X-RateLimit-Reset` | Unix timestamp (seconds) when the window resets | When the limit is exceeded, a `429 Too Many Requests` response is sent with an additional `Retry-After` header indicating seconds until the window resets. ## Custom key [Section titled “Custom key”](#custom-key) By default the rate limiter uses the client’s IP. Use `keyGenerator` to key by user, API token, or any other identifier: ```ts createRateLimit({ windowMs: 60_000, max: 1000, keyGenerator: (req) => req.getHeader('x-api-key') ?? req.connection.remoteAddress ?? 'unknown', }) ``` Note The rate limiter uses an in-memory store. It is not shared across cluster workers or multiple server instances. For distributed rate limiting, pass a custom `store` backed by Redis or a similar service. ## Distributed store [Section titled “Distributed store”](#distributed-store) Pass a `store` option to use any external backend. The store must implement `get`, `set`, and `delete`. **Upstash Redis example:** ```ts import { createRateLimit } from 'lacis' import type { RateLimitStore, RateLimitEntry } from 'lacis' import { Redis } from '@upstash/redis' const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! }) const store: RateLimitStore = { async get(key) { return await redis.get(key) ?? undefined }, async set(key, entry) { const ttl = Math.ceil((entry.resetAt - Date.now()) / 1000) await redis.set(key, entry, { ex: ttl }) }, async delete(key) { await redis.del(key) }, } createRateLimit({ windowMs: 60_000, max: 100, store }) ``` # SSE > Server-sent events in Lacis. Server-sent events (SSE) let you push data from server to client over a persistent HTTP connection. Lacis has first-class SSE support via `res.initSSE()`. ## Basic usage [Section titled “Basic usage”](#basic-usage) ```ts // routes/events/index.ts import type { Request, Response } from 'lacis' export async function GET(req: Request, res: Response) { const sse = res.initSSE() // Send events sse.json({ type: 'connected' }) await new Promise(resolve => setTimeout(resolve, 1000)) sse.json({ type: 'update', data: { value: 42 } }) sse.close() } ``` `initSSE()` sets the response headers (`Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`) and returns an `SSEContext`. ## SSEContext API [Section titled “SSEContext API”](#ssecontext-api) | Method | Description | | ---------------------------------------- | ---------------------------------------------------- | | `send(data: string)` | Send a raw `data:` line | | `json(data: any)` | Send JSON-serialized data | | `event(event: string, data: any)` | Send a named event with JSON data | | `comment(text: string)` | Send a comment (useful as keepalive) | | `id(id: string)` | Set the event `id` field | | `retry(ms: number)` | Tell the client how long to wait before reconnecting | | `close(comment?: string)` | Close the connection gracefully | | `error(event, message, code?, details?)` | Send an error event and close | All methods return `false` if the connection is already closed. ## Options [Section titled “Options”](#options) ```ts const sse = res.initSSE({ timeout: 300_000, // ms before auto-close (default: 300000 = 5 min) headers: { 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', // disable Nginx buffering }, }) ``` ## Bun constraint [Section titled “Bun constraint”](#bun-constraint) Danger On Bun, `initSSE()` **must be called synchronously before any `await`** in your handler. Bun’s `Bun.serve()` needs the streaming `Response` object returned synchronously — if you `await` first, the window to initialize the SSE stream has already closed and Lacis will throw. ```ts // ✅ Correct — initSSE before any await export async function GET(req: Request, res: Response) { const sse = res.initSSE() const data = await fetchSomething() sse.json(data) sse.close() } // ❌ Wrong on Bun — await before initSSE export async function GET(req: Request, res: Response) { const data = await fetchSomething() const sse = res.initSSE() // throws on Bun } ``` This constraint does not apply to Node.js. ## AI streaming example [Section titled “AI streaming example”](#ai-streaming-example) * Server ```ts // routes/chat/index.ts import type { Request, Response } from 'lacis' export async function POST(req: Request, res: Response) { const sse = res.initSSE() // must be before any await on Bun const { prompt } = await req.json<{ prompt: string }>() try { const stream = await llm.stream(prompt) for await (const chunk of stream) { sse.json({ delta: chunk.text }) } sse.event('done', { finish_reason: 'stop' }) sse.close() } catch (err) { sse.error('error', 'Stream failed', 500) } } ``` * Client ```ts import { createSSEClient } from 'lacis' const client = await createSSEClient('/chat', { method: 'POST', body: JSON.stringify({ prompt: 'Hello' }), contentType: 'application/json', }, { onMessage: (data) => console.log('chunk:', data), onEvent: { done: (data) => console.log('done:', data), error: (data) => console.error('error:', data), }, onClose: () => console.log('stream closed'), }) ``` ## Client API (`createSSEClient`) [Section titled “Client API (createSSEClient)”](#client-api-createsseclient) ```ts import { createSSEClient } from 'lacis' const client = await createSSEClient(url, options, handlers) ``` **Options:** | Option | Type | Default | Description | | ------------------- | --------- | ----------------------------------- | ----------------------------- | | `method` | `string` | `'GET'` (or `'POST'` if `body` set) | HTTP method | | `body` | `string` | — | Request body | | `contentType` | `string` | `'application/json'` if body | Content-Type header | | `reconnectInterval` | `number` | `3000` | Ms between reconnect attempts | | `maxRetries` | `number` | `3` | Max reconnect attempts | | `disableReconnect` | `boolean` | `false` | Disable auto-reconnect | **Handlers:** | Handler | Description | | ----------------- | ------------------------------------------------- | | `onMessage(data)` | Receives generic `data:` events | | `onEvent` | `Record void>` for named events | | `onClose()` | Called when the connection closes | | `onError(error)` | Called on connection error | # HTTP Streaming > Stream raw bytes and NDJSON responses in Lacis. Lacis provides two streaming methods on the response object: * `res.stream(body)` — pipe a raw `ReadableStream` or `AsyncIterable` to the client * `res.ndjson(iter)` — serialize an `AsyncIterable` of objects as newline-delimited JSON A companion utility, `parseNDJSON(stream)`, converts an incoming `ReadableStream` into an `AsyncIterable` of parsed objects — useful for consuming LLM API responses. ## Proxying an LLM stream [Section titled “Proxying an LLM stream”](#proxying-an-llm-stream) The most common use case: forward a streaming response from an external LLM API directly to your client. * Ollama ```ts import type { Request, Response } from 'lacis' export async function POST(req: Request, res: Response) { const body = await req.json() const upstream = await fetch('http://localhost:11434/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) res.stream(upstream.body!) } ``` * OpenAI ```ts import type { Request, Response } from 'lacis' import OpenAI from 'openai' const openai = new OpenAI() export async function POST(req: Request, res: Response) { const { messages } = await req.json<{ messages: OpenAI.ChatCompletionMessageParam[] }>() const stream = await openai.chat.completions.create({ model: 'gpt-4o', stream: true, messages, }) res.stream(stream.toReadableStream()) } ``` * Anthropic ```ts import type { Request, Response } from 'lacis' import Anthropic from '@anthropic-ai/sdk' const anthropic = new Anthropic() export async function POST(req: Request, res: Response) { const { prompt } = await req.json<{ prompt: string }>() const stream = anthropic.messages.stream({ model: 'claude-opus-4-7', max_tokens: 1024, messages: [{ role: 'user', content: prompt }], }) res.stream(stream.toReadableStream()) } ``` ## Generating NDJSON [Section titled “Generating NDJSON”](#generating-ndjson) `res.ndjson(iter)` serializes each value from an async iterable as a JSON line. Sets `Content-Type: application/x-ndjson` automatically. ```ts import type { Request, Response } from 'lacis' export async function GET(req: Request, res: Response) { async function* generate() { for (let i = 0; i < 5; i++) { yield { index: i, value: Math.random() } } } res.ndjson(generate()) } ``` ## Transforming a stream [Section titled “Transforming a stream”](#transforming-a-stream) Use `parseNDJSON` to parse an incoming NDJSON stream into objects, transform them, then re-stream to the client. ```ts import { parseNDJSON } from 'lacis' import type { Request, Response } from 'lacis' export async function POST(req: Request, res: Response) { const upstream = await fetch('http://localhost:11434/api/chat', { method: 'POST', body: await req.body(), }) async function* tokens() { for await (const chunk of parseNDJSON(upstream.body!)) { const c = chunk as any if (!c.done) yield { content: c.message?.content ?? '' } } } res.ndjson(tokens()) } ``` ## Platform support [Section titled “Platform support”](#platform-support) | Platform | `res.stream()` | `res.ndjson()` | | -------- | -------------- | -------------- | | Node.js | streaming | streaming | | Bun | streaming | streaming | | Vercel | buffered | buffered | | Netlify | buffered | buffered | Note On Vercel and Netlify, responses are always buffered — the full body is sent at once when the stream ends. For real-time streaming on serverless, use Vercel Edge Functions or Netlify Edge Functions instead. ## Difference from SSE [Section titled “Difference from SSE”](#difference-from-sse) `res.stream()` and `res.ndjson()` are raw HTTP streaming — no protocol, no reconnection logic, no event naming. Use them when the client (or an upstream service) already handles the format. Use [`res.initSSE()`](/features/sse) when you need the full SSE protocol: named events, retry, and browser `EventSource` compatibility. # Validation > Request validation with defineHandler and Standard Schema. `defineHandler` wraps a route handler to add schema validation and type inference. It accepts any library that implements the [Standard Schema](https://standardschema.dev/) spec — Zod, Valibot, and ArkType all work out of the box. ## Basic usage [Section titled “Basic usage”](#basic-usage) ```ts import { defineHandler } from 'lacis' import { z } from 'zod' export const GET = defineHandler({ params: z.object({ id: z.string().uuid() }), query: z.object({ verbose: z.coerce.boolean().optional() }), meta: { summary: 'Get user by ID', tags: ['users'] }, handler: async (req, res) => { req.params.id // string — validated and typed req.query.verbose // boolean | undefined — validated and typed res.json({ id: req.params.id }) }, }) ``` The return value is a plain async function — export it like any other route handler. ## Configuration fields [Section titled “Configuration fields”](#configuration-fields) ### `params`, `query`, `body` [Section titled “params, query, body”](#params-query-body) Each field accepts a Standard Schema. Schemas are validated in order: `params` first, then `query`, then `body`. If any fails, Lacis returns `400` immediately. * `params` — validated against `req.params` (URL path parameters) * `query` — validated against `req.query` (parsed query string) * `body` — Lacis calls `req.json()` internally and validates the result ### `meta` [Section titled “meta”](#meta) Attached to the OpenAPI operation when the spec is generated. No effect at request time. ```ts meta: { summary: 'Create user', description: 'Creates a new user account.', tags: ['users'], deprecated: false, } ``` ### `handler` [Section titled “handler”](#handler) The actual request handler with fully typed `req` and `res`. ## Validation errors [Section titled “Validation errors”](#validation-errors) When validation fails, Lacis sends a `400` automatically: ```json { "error": "Validation failed", "issues": [ { "message": "Invalid uuid", "path": ["id"] } ] } ``` The handler is never called when validation fails. ## Query coercion [Section titled “Query coercion”](#query-coercion) Query strings are always strings. Use your validator’s coercion utilities: * Zod ```ts query: z.object({ page: z.coerce.number().int().positive(), active: z.coerce.boolean().optional(), }) ``` * Valibot ```ts import * as v from 'valibot' query: v.object({ page: v.pipe(v.string(), v.transform(Number), v.integer()), active: v.optional(v.pipe(v.string(), v.transform((s) => s === 'true'))), }) ``` * ArkType ```ts import { type } from 'arktype' query: type({ page: 'number.integer > 0', 'active?': 'boolean' }) ``` ## Body validation [Section titled “Body validation”](#body-validation) * Zod ```ts import { defineHandler } from 'lacis' import { z } from 'zod' export const POST = defineHandler({ body: z.object({ name: z.string().min(1), email: z.string().email(), }), meta: { summary: 'Create user', tags: ['users'] }, handler: async (req, res) => { const { name, email } = req.body // fully typed res.status(201).json({ name, email }) }, }) ``` * Valibot ```ts import { defineHandler } from 'lacis' import * as v from 'valibot' export const POST = defineHandler({ body: v.object({ name: v.pipe(v.string(), v.minLength(1)), email: v.pipe(v.string(), v.email()), }), meta: { summary: 'Create user', tags: ['users'] }, handler: async (req, res) => { const { name, email } = req.body res.status(201).json({ name, email }) }, }) ``` * ArkType ```ts import { defineHandler } from 'lacis' import { type } from 'arktype' export const POST = defineHandler({ body: type({ name: 'string > 0', email: 'string.email' }), meta: { summary: 'Create user', tags: ['users'] }, handler: async (req, res) => { const { name, email } = req.body res.status(201).json({ name, email }) }, }) ``` ## Combined schemas [Section titled “Combined schemas”](#combined-schemas) ```ts export const PUT = defineHandler({ params: z.object({ id: z.string().uuid() }), query: z.object({ dryRun: z.coerce.boolean().default(false) }), body: z.object({ name: z.string(), email: z.string().email() }), handler: async (req, res) => { if (req.query.dryRun) return res.json({ ok: true, dry: true }) res.json({ id: req.params.id, ...req.body }) }, }) ``` ## Type inference [Section titled “Type inference”](#type-inference) TypeScript infers the output types of your schemas. Change a schema, and the types in `handler` update automatically — no manual annotations needed. # Getting Started > Create your first Lacis project in minutes. 1. **Create a project** Run the interactive scaffolding CLI: ```bash npm create lacis@latest ``` You will be prompted for: * **Project name** — directory name for the new project * **Platform** — Node.js, Bun, Vercel, or Netlify * **Validation library** — Zod, Valibot, ArkType, or none 2. **Enter your project** ```bash cd my-app ``` Your project looks like this (Node.js example): * my-app/ * routes/ * index.ts * server.ts * package.json * tsconfig.json 3. **Start the dev server** ```bash npm run dev ``` Your server is running at `http://localhost:3000`. ## Your first route [Section titled “Your first route”](#your-first-route) The scaffolded `routes/index.ts` exports HTTP method handlers: ```ts import type { Request, Response } from "lacis"; export const GET = async (_req: Request, res: Response) => { res.status(200).json({ message: "Hello from lacis!" }); }; ``` Each file in `routes/` maps to a URL path. Add a `POST` export to the same file, or create `routes/users/index.ts` for `/users`. ## Next steps [Section titled “Next steps”](#next-steps) * [Routing](/core/routing/) — learn how file paths map to routes * [Validation](/features/validation/) — add type-safe request validation with `defineHandler` # Error Handling > Built-in HTTP error helpers and global error handling in Lacis. Lacis exports typed HTTP error constructors and a `sendError` helper. Use them to send consistent, properly-formatted error responses without writing `res.status(...).json(...)` by hand. ## Built-in error constructors [Section titled “Built-in error constructors”](#built-in-error-constructors) ```ts import { createBadRequestError, // 400 createUnauthorizedError, // 401 createForbiddenError, // 403 createNotFoundError, // 404 createConflictError, // 409 createValidationError, // 422 createInternalServerError, // 500 createServiceUnavailableError, // 503 createGatewayTimeoutError, // 504 } from 'lacis' ``` Each accepts an optional `message` and `details` object: ```ts createNotFoundError('User not found', { id: req.params.id }) ``` ## Throwing errors [Section titled “Throwing errors”](#throwing-errors) The simplest pattern: `throw` any error constructor and Lacis will send the correct status code automatically. ```ts import type { Request, Response } from 'lacis' import { createUnauthorizedError, createNotFoundError } from 'lacis' export async function GET(req: Request, res: Response) { const token = req.getHeader('authorization') if (!token) throw createUnauthorizedError('Missing token') const user = await db.findUser(req.params.id) if (!user) throw createNotFoundError('User not found', { id: req.params.id }) res.json(user) } ``` ## `sendError` [Section titled “sendError”](#senderror) An alternative to throwing — sends the error as JSON and sets the correct status code inline. Useful when you need to send an error and then do cleanup work before returning. ```ts import type { Request, Response } from 'lacis' import { sendError, createUnauthorizedError, createNotFoundError } from 'lacis' export async function GET(req: Request, res: Response) { const token = req.getHeader('authorization') if (!token) { sendError(createUnauthorizedError('Missing token'), res) return } const user = await db.findUser(req.params.id) if (!user) { sendError(createNotFoundError('User not found', { id: req.params.id }), res) return } res.json(user) } ``` The client receives: ```json { "error": "User not found", "code": 404, "details": { "id": "42" } } ``` Note `sendError` checks `res.headersSent` before writing — safe to call even if you’re not sure whether the response has already started. ## Details exposure [Section titled “Details exposure”](#details-exposure) * **4xx errors** — `details` are included in the response body (safe to expose to the client) * **5xx errors** — `details` are never sent to the client; the error is logged server-side ```ts // ✅ details sent to client — 422 sendError(createValidationError('Invalid input', { field: 'email' }), res) // → { "error": "Invalid input", "code": 422, "details": { "field": "email" } } // ✅ details NOT sent to client — 500 sendError(createInternalServerError('DB query failed', { query: sql }), res) // → { "error": "Internal Server Error", "code": 500 } ``` ## Global error handler [Section titled “Global error handler”](#global-error-handler) Use the `onError` middleware hook to centralize logging or forward errors to an external service: ```ts import { createServer, normalizeError, sendError } from 'lacis' createServer('./routes', { middleware: { onError: async (req, res, ctx) => { const error = normalizeError(ctx.error) // Forward to Sentry, Datadog, etc. captureException(error) if (!res.headersSent) { sendError(error, res) } }, }, }) ``` ## `normalizeError` [Section titled “normalizeError”](#normalizeerror) Converts any unknown error into a typed `HttpError`. Useful in `onError` when you don’t control the error source: ```ts import { normalizeError } from 'lacis' const error = normalizeError(unknownErr) // ECONNREFUSED / ENOTFOUND → 503 // ETIMEDOUT → 504 // { statusCode: 422 } → 422 // anything else → 500 ``` # Testing > How to test Lacis route handlers with supertest. Lacis routes are plain async functions — they’re easy to test. The recommended approach is to spin up a real server on a random port with your handlers passed inline, then hit it with `supertest`. ## Setup [Section titled “Setup”](#setup) Install `supertest`: ```bash npm install -D supertest @types/supertest ``` ## Test helper [Section titled “Test helper”](#test-helper) Create a small helper that wraps `createServer` with a random port: ```ts // tests/helpers/server.ts import supertest from 'supertest' import { createServer } from 'lacis' import type { ServerlessRoute, ServerConfig } from 'lacis' import type { Server } from 'http' export async function createTestApp(options: { routes: ServerlessRoute[] middleware?: ServerConfig['middleware'] hooks?: ServerConfig['hooks'] }) { const server = await createServer('', { platform: 'node', port: 0, // random available port cluster: { enabled: false }, routes: options.routes, middleware: options.middleware, hooks: options.hooks, }) as Server const { port } = server.address() as { port: number } return { request: supertest.agent(`http://localhost:${port}`), close: () => new Promise((resolve, reject) => server.close(err => err ? reject(err) : resolve()) ), } } ``` ## Writing tests [Section titled “Writing tests”](#writing-tests) Import your handlers directly and pass them as `routes`: ```ts // tests/users.test.ts import { createTestApp } from './helpers/server' import { GET, POST } from '../routes/users/index' describe('GET /users', () => { it('returns a list of users', async () => { const { request, close } = await createTestApp({ routes: [{ path: '/users', handlers: { GET, POST } }], }) await request.get('/users') .expect(200) .expect(res => { expect(Array.isArray(res.body.users)).toBe(true) }) await close() }) }) ``` ## Testing with params [Section titled “Testing with params”](#testing-with-params) ```ts import { GET } from '../routes/users/[id]/index' it('returns a user by id', async () => { const { request, close } = await createTestApp({ routes: [{ path: '/users/:id', handlers: { GET } }], }) await request.get('/users/123').expect(200).expect({ id: '123' }) await close() }) ``` ## Testing with body [Section titled “Testing with body”](#testing-with-body) ```ts import { POST } from '../routes/users/index' it('creates a user', async () => { const { request, close } = await createTestApp({ routes: [{ path: '/users', handlers: { POST } }], }) await request .post('/users') .send({ name: 'Alice', email: 'alice@example.com' }) .expect(201) await close() }) ``` ## Testing middleware [Section titled “Testing middleware”](#testing-middleware) Pass middleware directly — no file loading needed: ```ts import { createUnauthorizedError } from 'lacis' it('rejects requests without a token', async () => { const { request, close } = await createTestApp({ routes: [{ path: '/protected', handlers: { GET: async (_req, res) => res.json({ ok: true }) } }], middleware: { beforeRequest: async (req, res) => { if (!req.getHeader('authorization')) { throw createUnauthorizedError() } }, }, }) await request.get('/protected').expect(401) await request.get('/protected').set('Authorization', 'Bearer token').expect(200) await close() }) ``` ## Testing cookies [Section titled “Testing cookies”](#testing-cookies) ```ts it('sets a session cookie on login', async () => { const { request, close } = await createTestApp({ routes: [{ path: '/login', handlers: { POST: async (_req, res) => { res.cookies.set('session', 'abc123', { httpOnly: true }) res.json({ ok: true }) }, }, }], }) const res = await request.post('/login').expect(200) expect(res.headers['set-cookie'][0]).toContain('session=abc123') expect(res.headers['set-cookie'][0]).toContain('HttpOnly') await close() }) ``` Note Use `afterEach` or `afterAll` to call `close()` and avoid open handle warnings in Jest/Vitest. ```ts afterEach(async () => { await close() }) ``` # API Reference > Full API reference for Lacis exports. ## `createServer(routesDir, config?)` [Section titled “createServer(routesDir, config?)”](#createserverroutesdir-config) Creates and starts a Lacis server. Used with the Node.js and Bun adapters. ```ts import { createServer } from 'lacis' createServer('./routes', { port: 3000 }) ``` See [Configuration](/reference/configuration/) for all config options. *** ## `defineHandler(config)` [Section titled “defineHandler(config)”](#definehandlerconfig) Wraps a route handler with schema validation, type inference, and OpenAPI metadata. ```ts import { defineHandler } from 'lacis' defineHandler({ params?: StandardSchema, query?: StandardSchema, body?: StandardSchema, meta?: { summary?: string description?: string tags?: string[] deprecated?: boolean }, cache?: WithCacheOptions, handler: (req, res) => void | Promise, }) ``` Returns a typed async function `(req, res) => Promise`. *** ## `createCorsMiddleware(config)` [Section titled “createCorsMiddleware(config)”](#createcorsmiddlewareconfig) Creates a CORS middleware function. ```ts import { createCorsMiddleware } from 'lacis' createCorsMiddleware({ origin?: string | string[] | RegExp | ((origin: string) => boolean) | '*', methods?: string[], allowedHeaders?: string[], exposedHeaders?: string[], credentials?: boolean, maxAge?: number, }) ``` Returns a `MiddlewareCallback`. See [CORS](/features/cors/). *** ## `createRateLimit(options?)` [Section titled “createRateLimit(options?)”](#createratelimitoptions) Creates a rate limiting middleware. ```ts import { createRateLimit } from 'lacis' createRateLimit({ windowMs?: number, // default: 60000 max?: number, // default: 100 message?: string, keyGenerator?: (req: Request) => string, }) ``` Returns a `MiddlewareCallback`. See [Rate Limiting](/features/rate-limiting/). *** ## `createResponseCache(options)` [Section titled “createResponseCache(options)”](#createresponsecacheoptions) Creates a response caching middleware. ```ts import { createResponseCache } from 'lacis' createResponseCache({ ttl: number, // seconds, required methods?: string[], // default: ['GET', 'HEAD'] maxSize?: number, // default: 500 keyGenerator?: (req: Request) => string, match?: (req: Request) => boolean, exclude?: string | string[], shouldCache?: (req: Request, res: Response) => boolean, }) ``` Returns a `MiddlewareCallback`. See [Caching](/features/caching/). *** ## `withCache(options, handler)` [Section titled “withCache(options, handler)”](#withcacheoptions-handler) Wraps a single handler with response caching. ```ts import { withCache } from 'lacis' withCache( { ttl: number, maxSize?: number, key?: (req: Request) => string }, async (req, res) => { /* ... */ } ) ``` Returns `(req: Request, res: Response) => Promise`. *** ## `initSSE(res, options?)` [Section titled “initSSE(res, options?)”](#initsseres-options) Initializes a server-sent events connection. Returns an `SSEContext`. ```ts import { initSSE } from 'lacis' // Typically called via res.initSSE() in route handlers const sse = res.initSSE({ timeout?: number, // default: 300000 (5 min) headers?: Record, }) ``` *** ## `SSEContext` [Section titled “SSEContext”](#ssecontext) Returned by `res.initSSE()`. | Method | Signature | Description | | --------- | ------------------------------------------- | -------------------- | | `send` | `(data: string) => boolean` | Send a raw data line | | `json` | `(data: any) => boolean` | Send JSON data | | `event` | `(event: string, data: any) => boolean` | Send a named event | | `comment` | `(text: string) => boolean` | Send a comment | | `id` | `(id: string) => boolean` | Set the event ID | | `retry` | `(ms: number) => boolean` | Set reconnect delay | | `close` | `(comment?: string) => void` | Close gracefully | | `error` | `(event, message, code?, details?) => void` | Send error and close | *** ## `createSSEClient(url, options?, handlers?)` [Section titled “createSSEClient(url, options?, handlers?)”](#createsseclienturl-options-handlers) Creates a client-side SSE connection. ```ts import { createSSEClient } from 'lacis' const client = await createSSEClient(url, { method?: string, body?: string, contentType?: string, reconnectInterval?: number, // default: 3000 maxRetries?: number, // default: 3 disableReconnect?: boolean, }, { onMessage?: (data: any) => void, onEvent?: Record void>, onClose?: () => void, onError?: (error: Error) => void, }) ``` *** ## TypeScript types [Section titled “TypeScript types”](#typescript-types) ```ts import type { // Request / Response Request, Response, // Configuration ServerConfig, ServerlessConfig, CorsConfig, // Middleware MiddlewareCallback, MiddlewareHooks, // SSE SSEOptions, SSEClientOptions, SSEEventHandlers, SSEClient, // Adapters Adapter, // Monitoring MonitorOptions, HealthMetrics, } from 'lacis' ``` # CLI > Lacis CLI reference — build, watch, dev. The `lacis` CLI is included with the `lacis` package. It provides commands for building and developing your project. ## `lacis build` [Section titled “lacis build”](#lacis-build) Generates the routes manifest required for serverless deployments (Vercel, Netlify). ```bash lacis build ``` This scans your `routes/` directory and outputs `routes/_manifest.ts` — a file with static imports for every route handler. Serverless adapters import this manifest at runtime instead of scanning the filesystem. **When to use:** Always run before deploying to Vercel or Netlify. Note Node.js and Bun deployments do **not** require `lacis build`. They scan the routes directory at startup using `loadRoutes()`. ## `lacis watch` [Section titled “lacis watch”](#lacis-watch) Watches your `routes/` directory and regenerates the manifest whenever a file is added, removed, or renamed. ```bash lacis watch ``` Useful during development on Vercel or Netlify to keep the manifest in sync as you add routes. ## `lacis dev` [Section titled “lacis dev”](#lacis-dev) Starts a local development server for Vercel and Netlify projects. ```bash lacis dev ``` Caution `lacis dev` is for **Vercel and Netlify only**. For Node.js and Bun, use the native dev command from your `package.json`: * Node.js: `npm run dev` → runs `tsx watch server.ts` * Bun: `bun run dev` → runs `bun --watch server.ts` # Configuration > Server configuration reference for Lacis. The `ServerConfig` object is passed as the second argument to `createServer` (for Node/Bun) or as part of `ServerlessConfig` (for Vercel/Netlify). ## Top-level options [Section titled “Top-level options”](#top-level-options) | Option | Type | Default | Description | | ---------------- | ------------------------------------------ | ---------------------------- | --------------------------------------------- | | `port` | `number` | `3000` | Port to listen on | | `platform` | `'node' \| 'bun' \| 'vercel' \| 'netlify'` | `'node'` | Target platform | | `isDev` | `boolean` | `NODE_ENV === 'development'` | Enables dev mode (verbose errors, monitoring) | | `timeout` | `number` | `30000` | Request timeout in milliseconds | | `defaultHeaders` | `Record` | — | Headers added to every response | ## `httpsOptions` [Section titled “httpsOptions”](#httpsoptions) TLS configuration for HTTPS (Node.js only). ```ts httpsOptions: { cert: readFileSync('./certs/cert.pem'), key: readFileSync('./certs/key.pem'), } ``` ## `cors` [Section titled “cors”](#cors) See the [CORS](/features/cors/) page for all options. ```ts cors: { origin: 'https://myapp.com', credentials: true, maxAge: 86400, } ``` ## `middleware` [Section titled “middleware”](#middleware) Global middleware applied to every request. ```ts middleware: { beforeRequest: async (req, res) => { /* ... */ }, afterRequest: async (req, res) => { /* ... */ }, onError: async (req, res, ctx) => { /* ... */ }, } ``` Each property accepts a single handler or an array of handlers. ## `hooks` [Section titled “hooks”](#hooks) Server lifecycle hooks. ```ts hooks: { onNotFound: async (req, res) => { res.status(404).json({ error: 'Not found' }) }, onShutdown: async () => { await db.end() }, } ``` | Hook | When it runs | | ------------ | ----------------------------------------- | | `onNotFound` | No route matched the request | | `onShutdown` | `SIGINT`, `SIGTERM`, or `SIGHUP` received | ## `cluster` [Section titled “cluster”](#cluster) Multi-process clustering (Node.js and Bun). ```ts cluster: { enabled: false, // default workers: undefined, // defaults to os.cpus().length } ``` ## `monitoring` [Section titled “monitoring”](#monitoring) Development performance monitoring (Node.js only, requires `isDev: true`). ```ts monitoring: { enabled: false, // default sampleInterval: 5000, // ms between samples reportInterval: 60000, // ms between console reports thresholds: { cpu: 80, // % — triggers alert memory: 512, // MB responseTime: 500, // ms errorRate: 5, // % }, } ``` When enabled, a `/health` endpoint returns live metrics as JSON. ## `routes` [Section titled “routes”](#routes) Pre-compiled routes manifest (serverless platforms only). Generated by `lacis build`. ```ts import { routes, middlewares } from './routes/_manifest.js' // Passed to vercelAdapter or netlifyAdapter vercelAdapter.createHandler({ routes, middlewares }) ``` ## `openapi` [Section titled “openapi”](#openapi) OpenAPI spec generation. ```ts openapi: { path: '/openapi.json', info: { title: 'My API', version: '1.0.0', description: 'Optional description.', }, } ``` # create-lacis > Scaffold a new Lacis project with the interactive CLI. `create-lacis` is the official scaffolding CLI. It sets up a new Lacis project with the right structure, entry point, and dependencies for your target platform. ## Usage [Section titled “Usage”](#usage) ```bash npm create lacis@latest ``` ## Prompts [Section titled “Prompts”](#prompts) The CLI asks three questions: 1. **Project name** — becomes the directory name (lowercase letters, numbers, `-`, `_`, `.`) 2. **Platform** — Node.js, Bun, Vercel, or Netlify 3. **Validation library** — Zod, Valibot, ArkType, or none ## Generated structure [Section titled “Generated structure”](#generated-structure) * Node.js * my-app/ * routes/ * index.ts * server.ts * package.json * tsconfig.json * .gitignore ```json // package.json scripts { "dev": "tsx watch server.ts", "build": "lacis build" } ``` * Bun * my-app/ * routes/ * index.ts * server.ts * package.json * tsconfig.json * .gitignore ```json // package.json scripts { "dev": "bun --watch server.ts", "build": "lacis build" } ``` * Vercel * my-app/ * routes/ * index.ts * api/ * slug.ts * vercel.json * package.json * tsconfig.json * .gitignore ```json // package.json scripts { "dev": "lacis dev", "build": "lacis build" } ``` * Netlify * my-app/ * routes/ * index.ts * netlify/ * functions/ * api.ts * netlify.toml * package.json * tsconfig.json * .gitignore ```json // package.json scripts { "dev": "lacis dev", "build": "lacis build" } ``` ## Validator dependencies [Section titled “Validator dependencies”](#validator-dependencies) Each validator choice adds the appropriate packages to `dependencies`: | Choice | Packages added | | ------- | ------------------------------------ | | Zod | `zod`, `zod-to-json-schema` | | Valibot | `valibot`, `@valibot/to-json-schema` | | ArkType | `arktype` | | None | — | The converter packages (`zod-to-json-schema`, `@valibot/to-json-schema`) are needed for OpenAPI spec generation. If you don’t plan to use OpenAPI, you can remove them.