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.