UNPKG

lacis

Version:

Zero-dependency TypeScript web framework

658 lines (508 loc) 19.8 kB
# Lacis 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](https://lacis.lycia.dev) ## Features - **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 `responses` once: it types the handler *and* feeds the OpenAPI (single source of truth) - **OpenAPI generation** — spec built automatically from your `defineHandler` routes - **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) and `req.platform` (runtime bindings), both augmentable - **Cookies** — first-class `req.cookies` / `res.cookies` API ## Installation ```bash npm install lacis ``` ## CLI ```bash lacis dev # start dev server (auto-detects platform) lacis build # generate routes/_manifest.ts lacis watch # watch routes and regenerate manifest on changes ``` All commands accept `--routes <dir>` to override the default `./routes` directory. ## Project structure ``` 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 /apidoes NOT cascade +middleware.global.ts # cascades to /api/* and below items/ index.ts server.ts ``` ## Routing Each file in `routes/` exports named HTTP method handlers or a default export. **Named exports** ```ts // 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** ```ts 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). ```ts 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: ```ts // 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): ```json { "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`](#definehandler)'s `params` schema. ## Request / Response API **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 `defineHandler` wraps a route handler to add validation and OpenAPI metadata. It supports any library that implements the [Standard Schema](https://standardschema.dev/) spec: Zod 3.24+, Valibot, ArkType. ```ts // 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: ```json { "error": "Validation failed", "issues": [{ "message": "Required", "path": ["email"] }] } ``` **With Valibot** ```ts import * as v from 'valibot' export const GET = defineHandler({ params: v.object({ id: v.string() }), handler: async (req, res) => { ... }, }) ``` **With ArkType** ```ts import { type } from 'arktype' export const GET = defineHandler({ query: type({ 'page?': 'number' }), handler: async (req, res) => { ... }, }) ``` ### Typed responses (opt-in) 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. ```ts 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`, `res` stays the regular `Response`. - **Escape hatch**: `res.raw` is the untyped `Response` for 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 with `validateResponses: true` (enforce in prod too) or `validateResponses: false` (disable entirely). ### Per-route middleware (`use:`) `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. ```ts 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`: ```ts // 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 }, }) ``` ## Request context: `req.locals` and `req.platform` 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:`](#per-route-middleware-use) middleware — it's merged into `req.locals` and inferred for that handler, no annotations. - **Global:** for cross-cutting context set by a `+middleware.global.ts`, augment `Locals` once (every route then sees the shape — a deliberate trade-off for file-based routing): ```ts 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`. ```ts // env.d.ts (Cloudflare) declare module 'lacis' { interface PlatformContext { env: Env ctx: ExecutionContext cf: IncomingRequestCfProperties } } ``` ## OpenAPI Add `openapi` to your server config to expose a generated spec at runtime: ```ts 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()`) | ### Typed client from your spec 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`](https://openapi-ts.dev/openapi-fetch/) at your generated spec for an end-to-end typed API client with zero maintained code: ```bash npx openapi-typescript http://localhost:3000/openapi.json -o ./src/api.d.ts ``` ```ts import 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' } } }) ``` ## Middleware 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](#per-route-middleware-use)) for fine-grained scoping. They coexist. **`+middleware.global.ts` — cascading** Applies to the current directory and all subdirectories. ```ts // 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. ```ts // 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** ```ts createServer(routesDir, { middleware: { beforeRequest: async (req, res) => { /* ... */ }, afterRequest: async (req, res) => { /* ... */ }, onError: async (req, res, ctx) => { /* ... */ }, }, }) ``` ## Lifecycle hooks ```ts 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. ## CORS ```ts 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: ```ts import { createCorsMiddleware } from 'lacis' const cors = createCorsMiddleware({ origin: '*' }) ``` ## Rate limiting ```ts 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-Sent Events **Server** ```ts // 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. ```ts // ✗ 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** ```ts 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: ```ts 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 }) ``` ## Server configuration ```ts 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, }, }, }) ``` ## Adapters ```ts 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: ```ts // worker.ts import { cloudflareAdapter } from 'lacis/adapters' import { routes } from './routes/_manifest.js' export default cloudflareAdapter.createHandler({ routes }) ``` ## Runtime behavior & constraints 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 first `await` (see the SSE section). ## License MIT