Zero-dependency TypeScript web framework with file-based routing.
file-based, multi-runtime, zero-dependency — and a true-by-construction OpenAPI contract: validated at the input, typed at the output, generatable as a client.
Documentation: lacis.lycia.dev
- File-based routing — routes generated automatically from your
routes/folder - Standard Schema validation — validate params, query, and body with Zod, Valibot, or ArkType via
defineHandler - Typed responses — declare
responsesonce: it types the handler and feeds the OpenAPI (single source of truth) - OpenAPI generation — spec built automatically from your
defineHandlerroutes - Middleware — global, path-scoped (
+middleware.ts), and per-route/per-method (use:) - CORS & rate limiting — built in, zero dependencies
- SSE — server-sent events with a matching client helper
- Multi-platform — Node.js, Bun, Vercel, Netlify, Cloudflare Workers via adapters
- Typed request context —
req.locals(app data) andreq.platform(runtime bindings), both augmentable - Cookies — first-class
req.cookies/res.cookiesAPI
npm install lacislacis dev # start dev server (auto-detects platform)
lacis build # generate routes/_manifest.ts
lacis watch # watch routes and regenerate manifest on changesAll commands accept --routes <dir> to override the default ./routes directory.
my-app/
routes/
+middleware.global.ts # cascades to all routes
index.ts # GET /
users/
index.ts # GET /users, POST /users
[id]/
index.ts # GET /users/:id
api/
+middleware.ts # exact to /api — does NOT cascade
+middleware.global.ts # cascades to /api/* and below
items/
index.ts
server.ts
Each file in routes/ exports named HTTP method handlers or a default export.
Named exports
// routes/users/index.ts
import type { Request, Response } from 'lacis'
export async function GET(req: Request, res: Response) {
res.status(200).json({ users: [] })
}
export async function POST(req: Request, res: Response) {
const body = await req.json()
res.status(201).json({ created: body })
}Default export
export default async function handler(req: Request, res: Response) {
res.json({ method: req.method })
}Dynamic routes
Use bracket syntax for URL parameters: routes/users/[id]/index.ts → /users/:id
([id?] makes the segment optional).
export async function GET(req: Request, res: Response) {
const { id } = req.params!
res.json({ id })
}Typed params from the folder (./$types)
lacis dev / lacis build generates a per-route $types so req.params is typed
from the folder name — no schema, no defineHandler needed:
// routes/users/[id]/index.ts
import type { RouteHandler } from './$types'
export const GET: RouteHandler = async (req, res) => {
req.params.id // string — inferred from [id]
res.json({ id: req.params.id })
}This needs rootDirs in your tsconfig.json (the create-lacis scaffold sets it up):
{ "compilerOptions": { "rootDirs": [".", "./.lacis/types"] } }The generated .lacis/ folder is a build artifact — add it to .gitignore. For full
input and output validation, combine with defineHandler's params schema.
Request
| Property / Method | Description |
|---|---|
req.params |
URL path parameters |
req.query |
Parsed query string |
req.cookies.get(name) |
Read a cookie |
req.cookies.all() |
All cookies as an object |
req.json<T>() |
Parse JSON body |
req.form<T>() |
Parse form body (multipart/form-data and application/x-www-form-urlencoded) |
req.body() |
Raw body as Buffer |
req.getHeader(name) |
Read a request header |
req.locals |
Per-request app context, set by middleware (typed — see below) |
req.platform |
Runtime bindings (empty except on Cloudflare — see below) |
Response
| Method | Description |
|---|---|
res.status(code) |
Set status code (chainable) |
res.json(data) |
Send JSON response |
res.html(data) |
Send HTML response |
res.send(data) |
Send string or JSON |
res.redirect(url, status?) |
Redirect to a URL (default 302) |
res.setHeader(name, value) |
Set a response header |
res.cookies.set(name, value, opts?) |
Set a cookie |
res.cookies.delete(name, opts?) |
Delete a cookie |
defineHandler wraps a route handler to add validation and OpenAPI metadata. It supports any library that implements the Standard Schema spec: Zod 3.24+, Valibot, ArkType.
// routes/users/[id]/index.ts
import { defineHandler } from 'lacis'
import { z } from 'zod'
export const GET = defineHandler({
params: z.object({ id: z.string() }),
query: z.object({ verbose: z.boolean().optional() }),
meta: { summary: 'Get user by ID', tags: ['users'] },
handler: async (req, res) => {
req.params.id // string — typed and validated
req.query.verbose // boolean | undefined — typed and validated
res.json({ id: req.params.id })
},
})
export const POST = defineHandler({
body: z.object({ name: z.string(), email: z.string().email() }),
meta: { summary: 'Create user', tags: ['users'] },
handler: async (req, res) => {
const { name, email } = req.body // typed
res.status(201).json({ name, email })
},
})Validation failures return a 400 automatically:
{
"error": "Validation failed",
"issues": [{ "message": "Required", "path": ["email"] }]
}With Valibot
import * as v from 'valibot'
export const GET = defineHandler({
params: v.object({ id: v.string() }),
handler: async (req, res) => { ... },
})With ArkType
import { type } from 'arktype'
export const GET = defineHandler({
query: type({ 'page?': 'number' }),
handler: async (req, res) => { ... },
})Declare responses and res becomes type-safe: res.status(code).json(data) only
accepts the schema declared for that status code. This is a single source of truth —
the same responses types the handler and feeds the OpenAPI spec.
export const GET = defineHandler({
responses: {
200: z.object({ id: z.string(), name: z.string() }),
404: z.object({ error: z.string() }),
},
handler: async (req, res) => {
res.status(200).json({ id: '1', name: 'Ada' }) // ✓ matches the 200 schema
res.status(404).json({ id: 1 }) // ✗ type error — 404 wants { error }
},
})- Opt-in & non-breaking: without
responses,resstays the regularResponse. - Escape hatch:
res.rawis the untypedResponsefor streaming / edge cases. - Dev-only runtime check (default): in non-production (
NODE_ENV !== 'production'), a returned body that violates its declared schema fails loudly; zero validation in production (perf). Override per route withvalidateResponses: true(enforce in prod too) orvalidateResponses: false(disable entirely).
use: runs middleware for a single route handler — and because each HTTP method is its
own defineHandler, it scopes by method (impossible with file-based +middleware).
It runs after the path +middleware, before the handler. Returning false (or sending
the response) stops the chain.
import { defineHandler } from 'lacis'
import { auth, rateLimit } from '../middleware'
// GET is public, POST requires auth — same path, different methods
export const GET = defineHandler({ handler: async (req, res) => res.json({ list: [] }) })
export const POST = defineHandler({
use: [rateLimit, auth],
handler: async (req, res) => res.status(201).json({ ok: true }),
})Typed context, zero boilerplate. A use: middleware that returns an object has it
merged into req.locals — and the type is inferred for the handler. No annotations, no
declare module:
// middleware: just return what you add to the context
const auth = (req) => {
const user = verify(req.getHeader('authorization'))
if (!user) return false // stops the chain
return { user } // merged into req.locals, type inferred
}
export const GET = defineHandler({
use: [auth],
handler: async (req, res) => {
res.json({ id: req.locals.user.id }) // req.locals.user is fully typed here
},
})Both use declaration merging — augment them once and they are typed everywhere.
req.locals — pass application data from middleware to handlers (instead of abusing
headers). Two ways to type it:
- Per-route (preferred): return an object from a
use:middleware — it's merged intoreq.localsand inferred for that handler, no annotations. - Global: for cross-cutting context set by a
+middleware.global.ts, augmentLocalsonce (every route then sees the shape — a deliberate trade-off for file-based routing):
declare module 'lacis' {
interface Locals {
user: { id: string; role: string }
}
}
// in a +middleware file
req.locals.user = await authenticate(req)
// in a handler
res.json({ id: req.locals.user.id })req.platform — runtime-specific bindings. Empty by default (a Node project exposes
nothing), populated with { env, ctx, cf } on Cloudflare Workers. The Cloudflare scaffold
generates an env.d.ts that augments it, so you access req.platform.env without as any.
// env.d.ts (Cloudflare)
declare module 'lacis' {
interface PlatformContext {
env: Env
ctx: ExecutionContext
cf: IncomingRequestCfProperties
}
}Add openapi to your server config to expose a generated spec at runtime:
createServer(routesDir, {
openapi: {
path: '/openapi.json', // default
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com', description: 'production' }],
},
})The spec is built from all defineHandler routes. Routes without defineHandler appear with a generic 200 response. Converters required per library:
| Library | Package to install |
|---|---|
| Zod 4.4+ | none (native) |
| Zod < 4.4 | zod-to-json-schema |
| Valibot | @valibot/to-json-schema |
| ArkType | none (native .toJsonSchema()) |
Lacis ships no client codegen — mature tools do it better, in many languages, and
writing one would betray the zero-dependency ethos. Point openapi-fetch
at your generated spec for an end-to-end typed API client with zero maintained code:
npx openapi-typescript http://localhost:3000/openapi.json -o ./src/api.d.tsimport createClient from 'openapi-fetch'
import type { paths } from './api'
const api = createClient<paths>({ baseUrl: 'http://localhost:3000' })
// fully typed path, params, body and response
const { data, error } = await api.GET('/users/{id}', { params: { path: { id: '1' } } })There are two file-based middleware conventions (below) for cross-cutting concerns scoped
by path, plus per-route/per-method use: in defineHandler (see Per-route middleware) for fine-grained scoping. They coexist.
+middleware.global.ts — cascading
Applies to the current directory and all subdirectories.
// routes/api/+middleware.global.ts — runs for /api, /api/users, /api/users/:id, etc.
import type { Request, Response } from 'lacis'
export const beforeRequest = async (req: Request, res: Response) => {
if (!req.getHeader('authorization')) {
res.status(401).json({ error: 'Unauthorized' })
return false // stops the request
}
}
export const afterRequest = async (req: Request, res: Response) => {
// runs after the handler
}
export const onError = async (req: Request, res: Response, context: any) => {
console.error(context.error)
}+middleware.ts — exact path only
Applies only to routes at that directory level. Does not cascade into subdirectories.
// routes/api/+middleware.ts — runs for /api only, NOT /api/users
import type { Request, Response } from 'lacis'
export const beforeRequest = async (req: Request, res: Response) => {
// ...
}Returning false from beforeRequest stops the request pipeline.
Global middleware via server config
createServer(routesDir, {
middleware: {
beforeRequest: async (req, res) => { /* ... */ },
afterRequest: async (req, res) => { /* ... */ },
onError: async (req, res, ctx) => { /* ... */ },
},
})createServer(routesDir, {
hooks: {
onNotFound: async (req, res) => {
res.status(404).json({ error: 'Not found', path: req.url })
},
onShutdown: async () => {
// close DB connections, flush logs, etc.
},
},
})onNotFound is called when no route matches. If it sends a response, the default 404 is skipped. If it returns without sending, the default { error: "Not Found", code: 404 } is used.
onShutdown is called during graceful shutdown (SIGINT / SIGTERM / SIGHUP), before the server closes.
createServer(routesDir, {
cors: {
origin: 'https://myapp.com', // string, string[], RegExp, or (origin) => boolean
credentials: true,
methods: ['GET', 'POST'], // default: all methods
allowedHeaders: ['Authorization', 'Content-Type'],
exposedHeaders: ['X-Total-Count'],
maxAge: 86400,
},
})origin: '*' is incompatible with credentials: true — Lacis reflects the actual origin automatically in that case.
You can also create a standalone middleware:
import { createCorsMiddleware } from 'lacis'
const cors = createCorsMiddleware({ origin: '*' })import { createRateLimit } from 'lacis'
createServer(routesDir, {
middleware: {
beforeRequest: createRateLimit({
windowMs: 60_000, // 1 minute
max: 100,
message: 'Too Many Requests',
keyGenerator: (req) => req.getHeader('x-forwarded-for') ?? 'unknown',
}),
},
})Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers on every response. Returns 429 with Retry-After when the limit is exceeded.
Server
// routes/stream/index.ts
import type { Request, Response } from 'lacis'
export async function GET(req: Request, res: Response) {
const sse = res.initSSE()
sse.json({ status: 'connected' })
sse.event('update', { id: 1, value: 42 })
sse.close()
}res.initSSE(options?) returns an SSEContext object:
| Method | Description |
|---|---|
sse.send(data) |
Send raw string data |
sse.json(data) |
Send JSON data |
sse.event(event, data) |
Send named event with JSON data |
sse.comment(text) |
Send a comment (keepalive) |
sse.id(id) |
Set event ID |
sse.retry(ms) |
Set client retry interval |
sse.close(comment?) |
Close the connection |
sse.error(event, message, code?, details?) |
Send error event and close |
Bun & Cloudflare: call initSSE() before any await
On the runtime-Web adapters (Bun and Cloudflare Workers), the response type
(streaming vs buffered) must be decided synchronously before the first await. Calling
initSSE() after an await throws at runtime.
// ✗ throws on Bun / Cloudflare
export async function GET(req: Request, res: Response) {
const data = await fetchData()
const sse = res.initSSE() // too late — streaming window already closed
}
// ✓ init before any await, then fetch
export async function GET(req: Request, res: Response) {
const sse = res.initSSE()
const data = await fetchData()
sse.json(data)
sse.close()
}Once the handler's first await resolves, the streaming decision is final. Node.js, Vercel,
and Netlify do not have this constraint.
Client
import { createSSEClient } from 'lacis'
const client = await createSSEClient('http://localhost:3000/stream')
client
.onMessage(data => console.log('message:', data))
.onEvent('update', data => console.log('update:', data))
.onClose(() => console.log('closed'))createSSEClient options:
createSSEClient(url, {
method: 'GET', // default GET, POST if body is provided
body: { token: 'abc' }, // sent as JSON if provided
reconnectInterval: 3000,
maxRetries: 3,
disableReconnect: false,
params: { key: 'value' }, // appended to URL query string
})import { createServer } from 'lacis'
createServer(routesDir, {
port: 3000,
isDev: process.env.NODE_ENV === 'development',
platform: 'node', // 'node' | 'bun' | 'vercel' | 'netlify' | 'cloudflare'
timeout: 30000,
maxBodySize: 10_485_760, // max request body in bytes (default 10 MB; 413 when exceeded)
defaultHeaders: { // applied on node, bun and cloudflare
'X-Powered-By': 'Lacis',
},
httpsOptions: {
cert: fs.readFileSync('cert.pem'),
key: fs.readFileSync('key.pem'),
},
cluster: {
enabled: true,
workers: 4, // defaults to CPU count
// Node: fork-based cluster, OS round-robin scheduling
// Bun: Bun.spawn() workers with reusePort
},
// Dev only — exposes /health endpoint with request metrics
monitoring: {
enabled: true,
sampleInterval: 5000,
reportInterval: 60000,
thresholds: {
cpu: 80,
memory: 80,
responseTime: 1000,
errorRate: 5,
},
},
})import { createServer, getRoutesDir } from 'lacis'
// Node.js
createServer(getRoutesDir(), { platform: 'node' })
// Bun
createServer(getRoutesDir(), { platform: 'bun' })
// Vercel
export default createServer(getRoutesDir(), { platform: 'vercel' })
// Netlify
export const handler = createServer(getRoutesDir(), { platform: 'netlify' })Cloudflare Workers use the serverless manifest directly:
// worker.ts
import { cloudflareAdapter } from 'lacis/adapters'
import { routes } from './routes/_manifest.js'
export default cloudflareAdapter.createHandler({ routes })Lacis runs on five runtimes; a few behaviors differ by necessity. These are stable and intentional.
| Behavior | node | bun | cloudflare | vercel | netlify |
|---|---|---|---|---|---|
Streaming (res.stream / SSE) |
live | live | live | buffered | buffered |
initSSE() before first await |
not required | required | required | not required | not required |
defaultHeaders |
✓ | ✓ | ✓ | — | — |
req.platform |
{} |
{} |
{ env, ctx, cf } |
{} |
{} |
405 Allow header |
✓ | ✓ | ✓ | ✓ | ✓ |
- Streaming live vs buffered: node, bun and cloudflare stream chunks as they are produced; vercel and netlify buffer the whole response and send it in one block (their function model). Cloudflare is serverless but streams live because it uses the Web Response model.
- Request distribution (node cluster): handled by the OS via round-robin (
SCHED_RR). Lacis does not do application-level load balancing — the worker supervisor only forks, restarts, and gracefully shuts down workers. - SSE window: on bun/cloudflare,
initSSE()must run before the handler's firstawait(see the SSE section).
MIT