# Zelt Documentation > A fast, type-safe application framework for TypeScript This file contains all documentation content in a single document following the llmstxt.org standard. ## The Big Picture With Zelt, what you write is your application's functionality — and only that. The GraphQL schema, the OpenAPI document, typed clients, and the startup wiring are all derived from your code. And the same app runs on Node, Bun, Cloudflare Workers, Lambda — and inside your tests — by swapping a single adapter call. The map below shows why that is possible. ```mermaid flowchart TD APP["app definitionyour code"] APP -- "obtained by importing andevaluating it (never boots)" --> BP["blueprintthe design, as data"] BP -- "a plugin derives, at zelt build" --> PRE[".zelt/prebuiltauxiliary artifacts used at runtime(graphql runtime, …)"] BP -- "a plugin derives, at zelt build" --> EXT["auxiliary artifacts used outside the app(openapi.json, …)"] subgraph ENVS["runtime environments"] NLIVE["running app on Node(onNode)"] WLIVE["running app on Workers(onCloudflareWorkers)"] TLIVE["running app in your tests(onTest · in-process)"] end APP -- "an adapter realizes" --> ENVS BP -- "used as the design" --> ENVS PRE -. "joined by the entry, when present" .-> ENVS ``` ## What you write - **app definition** — `createApp([...])` with your features: HTTP controllers, GraphQL resolvers, commands, schedulers, and the services behind them. The description of your application's functionality; it imports nothing from `.zelt/` and contains no startup logic. - **entry** — a few lines per platform (`node.ts`, `worker.ts`, …) that import the app and the prebuilt and hand both to an adapter. The only place your code touches `.zelt/`. - **tests** — they play the same role as an entry: join app + prebuilt and hand them to `onTest`, which is one adapter among the others. Tests are not a parallel world; they go through the same path as production, in-process. - **zelt.config.ts** — your instructions to the CLI: which plugins to run, build and dev settings. ## zelt CLI (build / dev) `zelt build` (and every `zelt dev` restart) imports your app definition and evaluates it. Evaluation collects decorators and type metadata but boots nothing — no server, no connections. The result is the **blueprint**: your app's routes, resolvers, and types as plain data. Plugins consume the blueprint and derive artifacts: the GraphQL schema and executable runtime, the OpenAPI document, typed clients. One source, many derivatives, checked against it at build or startup — though today only structural drift is caught (an endpoint's path or resolver set changing), not deeper drift such as a resolver's method signature. Removing something from the app removes its artifacts on the next build. And plugins are optional unless your app uses a feature that depends on one — a `graphql()` endpoint, for example, requires `graphqlPlugin()` and `zelt build` to run at all. Finally the bundle step (tsdown on Node, wrangler on Workers) packs the entry and everything it imports into `dist/`. ## generated - **`.zelt/`** — artifacts derived from *the app itself*. They flow *out of* your app: only entries and tests import them (the app cannot depend on its own derivatives). Disposable and reproducible — delete the directory and `zelt build` recreates it. The runtime pieces are bundled into one value module, `.zelt/prebuilt.ts`. - **`openapi.json` / typed clients** — derivatives for the world outside your app: frontends and API consumers. Because they are derived from the code, the spec cannot disagree with the implementation. - **`dist/`** — the deployable: entry + app + prebuilt packed together, the unit you ship to a runtime environment. ## Runtime environments Adapters are interchangeable implementations of the same job: take your app’s code, its blueprint, and — when present — `.zelt/prebuilt`, and **realize** them — run DI, read configuration from the environment, open connections to databases and external services, start servers. `onNode`, `onBun`, `onCloudflareWorkers`, `onLambda`, `onElectron` — and `onTest`. Switching platforms means swapping this one call; the rest of the map is untouched. Seen from deployment and operations, the map connects like this: ```mermaid flowchart LR SRCSET["entry + app + .zelt/prebuilt"] -- "tsdown / wrangler bundles" --> DIST["dist/deployable"] DIST -- "CI/CD deploys" --> LIVE["running app in aruntime environment"] ENV["env vars · secrets"] -- "realize reads as configuration" --> LIVE LIVE -- "connects at runtime" --> INFRA["DB · KV · external services"] USER["frontends · API consumers"] -- "call via HTTP / GraphQL" --> LIVE API["openapi.json · typed clients"] -- "used for type-safe calls" --> USER ``` At startup each feature checks its prebuilt entry against the code and fails loudly with the fix (`zelt build`) if they have drifted — stale artifacts never run silently. --- ## Getting Started Zelt runs on Node.js, Bun, Cloudflare Workers, and more. Switching between environments is as simple as changing `onNode()` to `onBun()`. ## Packages Zelt is split into focused packages. Here's where each function lives: | Function | Package | Purpose | |----------|---------|---------| | `createApp`, `Controller`, `Get`, `Post`, `inject`, ... | `@zeltjs/core` | Framework core | | `request()`, `request(schema)` | `@zeltjs/core` | Request access, body parsing, and Standard Schema validation | | `onNode()` | `@zeltjs/adapter-node` | Node.js runtime adapter | | `onBun()` | `@zeltjs/adapter-bun` | Bun runtime adapter | | `onCloudflareWorkers()` | `@zeltjs/adapter-cloudflare-workers` | Workers adapter | | `onLambda()` | `@zeltjs/adapter-lambda` | AWS Lambda adapter | | `onElectron()` | `@zeltjs/adapter-electron` | Electron adapter | Only `@zeltjs/core` and one adapter are required to get started. ## Choose Your Environment - **[Node.js](./getting-started/node)** — The most common choice - **[Bun](./getting-started/bun)** — Fast JavaScript runtime - **[Cloudflare Workers](./getting-started/cloudflare-workers)** — Edge computing - **[AWS Lambda](./getting-started/lambda)** — Serverless - **[Electron](./getting-started/electron)** — Desktop apps --- ## Controllers Controllers are responsible for handling incoming **requests** and returning **responses** to the client. ## Defining Controllers A controller is a class decorated with `@Controller()`. The decorator accepts a path prefix that will be prepended to all routes defined in the controller. ```typescript import { Controller, Get, Post, response } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import * as v from 'valibot'; const CreateUserBody = v.object({ name: v.string(), email: v.pipe(v.string(), v.email()), }); @Controller('/users') export class UserController { @Get('/') findAll() { return { users: [] }; } @Get('/:id') findOne(req = request()) { const id = req.pathParam('id'); return { id, name: 'John Doe' }; } @Post('/') async create(req = request(CreateUserBody), res = response()) { const body = await req.body(); return res.json({ id: '1', ...body }, 201); } } // ---cut-after--- import { expect, test } from 'vitest'; test('UserController.findAll returns users array', () => { const controller = new UserController(); expect(controller.findAll()).toEqual({ users: [] }); }); ``` ## Route Path Rules The `@Controller` prefix and method decorator path are joined to form the final route. Trailing slashes are stripped; leading slashes on the method path are optional. | Controller Prefix | Method Path | Final Route | |-------------------|-------------|-------------| | `'/users'` | `'/'` | `/users` | | `'/users'` | `'/:id'` | `/users/:id` | | `'/api'` | `'/users'` | `/api/users` | | `'/'` | `'/hello'` | `/hello` | | `'/api/v1'` | `'/users/:id'` | `/api/v1/users/:id` | :::tip Both `@Get('/items')` and `@Get('items')` produce the same result — a leading slash is added automatically if missing. ::: ## HTTP Method Decorators Zelt provides decorators for all standard HTTP methods: | Decorator | HTTP Method | |-----------|-------------| | `@Get()` | GET | | `@Post()` | POST | | `@Put()` | PUT | | `@Patch()` | PATCH | | `@Delete()` | DELETE | ```typescript import { Controller, Get, Post, Put, Patch, Delete } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import * as v from 'valibot'; const schema = v.object({ name: v.string() }); // ---cut--- @Controller('/items') export class ItemController { @Get('/') findAll() { /* ... */ } @Get('/:id') findOne(req = request()) { const id = req.pathParam('id'); /* ... */ } @Post('/') async create(req = request(schema)) { const body = await req.body(); /* ... */ } @Put('/:id') async update(req = request(schema)) { const id = req.pathParam('id'); const body = await req.body(); /* ... */ } @Patch('/:id') async patch(req = request(schema)) { const id = req.pathParam('id'); const body = await req.body(); /* ... */ } @Delete('/:id') remove(req = request()) { const id = req.pathParam('id'); /* ... */ } } ``` ## Route Parameters Inject `request()` as a handler parameter and use `req.pathParam()` to extract route parameters: ```typescript import { Controller, Get, request } from '@zeltjs/core'; // ---cut--- @Controller('/items') class ItemController { @Get('/:category/:id') findOne(req = request()) { const category = req.pathParam('category'); const id = req.pathParam('id'); return { category, id }; } } ``` ## Request Body ### With Validation (Recommended) Use `request()` with a Valibot schema to validate and type the request body: ```typescript import { Controller, Post } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import * as v from 'valibot'; // ---cut--- const CreatePostBody = v.object({ title: v.pipe(v.string(), v.minLength(1), v.maxLength(100)), content: v.string(), tags: v.optional(v.array(v.string())), }); @Controller('/posts') class PostController { @Post('/') async create(req = request(CreatePostBody)) { const body = await req.body(); // body is fully typed as { title: string; content: string; tags?: string[] } return { id: '1', ...body }; } } ``` If validation fails, Zelt automatically returns a 400 response with detailed error information. ### Without Validation For cases where you don't need validation (e.g., accepting arbitrary JSON), inject `request()` and use `req.body()`: ```typescript import { Controller, Post, request } from '@zeltjs/core'; // ---cut--- @Controller('/webhooks') class WebhookController { @Post('/github') async handleGithubWebhook(req = request()) { const payload = await req.body(); // payload is typed as unknown return { received: true }; } } ``` See [Request & Response Primitives](./primitives.md) for more details on `request()` and other request helpers. ## Returning Responses Controller methods support two return styles: ### Plain Return (Recommended for 200 OK) Simply return a value — Zelt automatically serializes it as JSON with status 200: ```typescript import { Controller, Get } from '@zeltjs/core'; // ---cut--- @Controller('/users') class UserController { @Get('/') findAll() { return { users: [] }; // → 200 OK, Content-Type: application/json } @Get('/health') health() { return 'OK'; // → 200 OK, Content-Type: text/plain } } ``` ### response() (For Custom Status Codes or Headers) Use `response()` when you need a status code other than 200, custom headers, or redirects: ```typescript import { Controller, Post, Delete, response } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import * as v from 'valibot'; const schema = v.object({ name: v.string() }); // ---cut--- @Controller('/users') class UserController { @Post('/') async create(req = request(schema), res = response()) { const body = await req.body(); return res.json({ id: '1', ...body }, 201); // 201 Created } @Delete('/:id') remove(req = request()) { const id = req.pathParam('id'); return new Response(null, { status: 204 }); // 204 No Content } } ``` ### When to Use Which | Scenario | Approach | |----------|----------| | Return JSON with 200 | `return { data }` | | Return with custom status (201, 204, etc.) | `response().json(data, status)` | | Set custom headers | `response().header(name, value).json(data)` | | Redirect | `response().redirect(url)` | | Set cookies | `response().setCookie(name, value).json(data)` | | Stream response | `response().stream(cb)` / `response().sse(cb)` | See [Request & Response Primitives](./primitives.md) for the full `response()` API. ## Custom Response Status Use `response()` to control the HTTP status code: ```typescript import { Controller, Post, Delete, response } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import * as v from 'valibot'; const schema = v.object({ name: v.string() }); // ---cut--- @Controller('/items') class ItemController { @Post('/') async create(req = request(schema), res = response()) { const body = await req.body(); const created = { id: '1', ...body }; return res.json(created, 201); // Returns 201 Created } @Delete('/:id') remove(req = request()) { const id = req.pathParam('id'); // Perform delete operation return new Response(null, { status: 204 }); // Returns 204 No Content } } ``` ## Registering Controllers Controllers must be registered in `createApp()`: ```typescript import { createApp, Controller, Get, Post, response, http } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import * as v from 'valibot'; const CreateUserBody = v.object({ name: v.string(), email: v.pipe(v.string(), v.email()) }); @Controller('/users') class UserController { @Get('/') findAll() { return { users: [] }; } @Get('/:id') findOne(req = request()) { const id = req.pathParam('id'); return { id }; } @Post('/') async create(req = request(CreateUserBody), res = response()) { const body = await req.body(); return res.json({ id: '1', ...body }, 201); } } @Controller('/posts') class PostController { @Get('/') findAll() { return { posts: [] }; } } // ---cut--- export const app = createApp([http({ controllers: [UserController, PostController], })]); ``` ## Next Steps - Learn about [Middleware](./middleware.md) for request/response processing --- ## Middleware Middleware classes execute before the route handler and can modify requests, responses, or context. ## Class Middleware The simplest form of middleware is a class with a `use()` method. Use `request()` and `response()` to access HTTP primitives: ```typescript import { Middleware, request, type Next } from '@zeltjs/core'; @Middleware export class LoggingMiddleware { async use(next: Next, req = request()): Promise { const start = Date.now(); await next(); const duration = Date.now() - start; console.log(`[${req.method()}] ${req.path()} ${duration}ms`); return undefined; } } ``` ## Middleware Levels Zelt supports middleware at three levels, executed in order: **global → controller → method**. ### Global Middleware Apply to all routes via `createApp()`: ```typescript import { createApp, Controller, Get, Middleware, request, type Next, http } from '@zeltjs/core'; @Middleware class LoggingMiddleware { async use(next: Next, req = request()) { const start = Date.now(); await next(); console.log(`[${req.method()}] ${req.path()} ${Date.now() - start}ms`); return undefined; } } @Controller('/users') class UserController { @Get('/') findAll() { return []; } } // ---cut--- export const app = createApp([http({ controllers: [UserController], middlewares: [LoggingMiddleware], })]); ``` ### Controller Middleware Apply to all methods in a controller with `@UseMiddleware`: ```typescript import { Controller, Get, Middleware, UseMiddleware, type Next } from '@zeltjs/core'; @Middleware class AuthMiddleware { async use(next: Next) { await next(); return undefined; } } // ---cut--- @UseMiddleware(AuthMiddleware) @Controller('/admin') export class AdminController { @Get('/dashboard') dashboard() { return { stats: [] }; } } ``` ### Method Middleware Apply to specific methods: ```typescript import { Controller, Delete, Get, Middleware, UseMiddleware, request, type Next } from '@zeltjs/core'; @Middleware class AdminOnlyMiddleware { async use(next: Next) { await next(); return undefined; } } // ---cut--- @Controller('/posts') export class PostController { @Get('/') findAll() { return { posts: [] }; } @UseMiddleware(AdminOnlyMiddleware) @Delete('/:id') remove(req = request()) { const id = req.pathParam('id'); return { deleted: id }; } } ``` ## Skipping Middleware Use `@SkipMiddleware` to exclude specific middleware from a method: ```typescript import { Controller, Get, Middleware, SkipMiddleware, type Next } from '@zeltjs/core'; @Middleware class AuthMiddleware { async use(next: Next) { await next(); return undefined; } } // ---cut--- @Controller('/api') export class ApiController { @Get('/protected') protected() { return { secret: 'data' }; } @SkipMiddleware(AuthMiddleware) @Get('/health') health() { return { status: 'ok' }; } } ``` Apply `@SkipMiddleware` to a controller class to exclude middleware from every route in that controller: ```typescript import { Controller, Get, Middleware, SkipMiddleware, type Next } from '@zeltjs/core'; @Middleware class AuthMiddleware { async use(next: Next) { await next(); return undefined; } } // ---cut--- @SkipMiddleware(AuthMiddleware) @Controller('/public') export class PublicController { @Get('/health') health() { return { status: 'ok' }; } @Get('/version') version() { return { version: '1.0.0' }; } } ``` Class-level and method-level skip declarations are combined. If a controller skips `AuthMiddleware` and a method skips `LoggingMiddleware`, that method skips both. More specific middleware attachment wins over a class-level skip. If a controller has `@SkipMiddleware(AuthMiddleware)` but one method also has `@UseMiddleware(AuthMiddleware)`, `AuthMiddleware` runs for that method. If the same method has both `@UseMiddleware(AuthMiddleware)` and `@SkipMiddleware(AuthMiddleware)`, the method-level skip wins. `CorsMiddleware` and `SecureHeadersMiddleware` are auto-registered on every HTTP app. See [HTTP Security](./http-security.md) for their defaults, configuration options, skip examples, and CORS preflight behavior. ## Middleware Values Middleware can provide a typed value to the code that runs after it. Providing a value uses the `Next` type together with `next(value)`; reading it uses `middlewareValue(M)`. There are no string keys or module augmentation to maintain. ### Providing a Value A middleware that provides a value declares it in `Next` and passes it to `next(value)`: ```typescript import { Middleware, request, type Next } from '@zeltjs/core'; declare function verifyToken(token: string): Promise<{ id: number; name: string } | null>; // ---cut--- @Middleware export class AuthMiddleware { async use(next: Next<{ id: number; name: string }>, req = request()): Promise { const token = req.header('Authorization'); const user = token ? await verifyToken(token) : null; if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 }); await next(user); return undefined; } } ``` Because `Next` requires an argument, calling `next()` without a value is a type error. Middleware that provides nothing keeps using the plain `Next` type, as in the examples above. ### Reading a Value Handlers — and other middleware — read a provided value with `middlewareValue(M)`, passed as a parameter default: ```typescript import { Controller, Get, Middleware, UseMiddleware, request, middlewareValue, type Next } from '@zeltjs/core'; @Middleware class AuthMiddleware { async use(next: Next<{ id: number; name: string }>, req = request()): Promise { await next({ id: 1, name: 'placeholder' }); return undefined; } } // ---cut--- @UseMiddleware(AuthMiddleware) @Controller('/profile') export class ProfileController { @Get('/') getProfile(user = middlewareValue(AuthMiddleware)) { return { id: user.id, name: user.name }; } } ``` The type reflects `M`'s own `Next` declaration as-is — a middleware declared as `Next` yields a nullable value. In this example, `AuthMiddleware` short-circuits with a 401 response before it ever calls `next(user)`, so by the time a handler runs, the value is guaranteed to exist. Middleware reads another middleware's value the same way, as a parameter default on its own `use()` method. ### Reading Requires the Middleware Calling `middlewareValue(M)` throws immediately, with an error naming the middleware, in two cases: the middleware isn't applied to the route, or it is applied but never called `next(value)` (so no value was ever recorded). There is no silent `undefined`. Applying middleware to a route still works exactly as before, with `@UseMiddleware` on a controller or method, or with `middlewares` on a module. ## Dependency Injection For middleware that requires dependency injection, use `@Middleware`: ```typescript import { Config, Env, Middleware, inject, request } from '@zeltjs/core'; import type { Next } from '@zeltjs/core'; @Config class AuthConfig { constructor(private env = inject(Env)) {} get secret() { return this.env.getString('AUTH_SECRET'); } } @Middleware export class AuthMiddleware { constructor(private config = inject(AuthConfig)) {} async use(next: Next, req = request()): Promise { const secret = this.config.secret; // ... authentication logic await next(); return undefined; } } ``` Use class middleware the same way as function middleware: ```typescript import { Controller, UseMiddleware, Middleware, Get, type Next } from '@zeltjs/core'; @Middleware class AuthMiddleware { async use(next: Next) { await next(); return undefined; } } // ---cut--- @UseMiddleware(AuthMiddleware) @Controller('/admin') export class AdminController { @Get('/') index() { return { ok: true }; } } ``` ## Middleware with Options Middleware that requires configuration extends `MiddlewareWithOptions` and reads its options with `middlewareOptions()`, as a parameter default — the same pattern as `request()` and `middlewareValue()`: ```typescript import { Middleware, MiddlewareWithOptions, middlewareOptions, type Next } from '@zeltjs/core'; // ---cut--- interface RateLimitOptions { limit: number; windowSec: number; } @Middleware export class RateLimitMiddleware extends MiddlewareWithOptions { async use(next: Next, opts = middlewareOptions(RateLimitMiddleware)) { const { limit, windowSec } = opts; // ... rate limiting logic await next(); return undefined; } } ``` Pass the options where the middleware is applied, with `.with()`: ```typescript import { Controller, Middleware, MiddlewareWithOptions, Post, UseMiddleware, middlewareOptions, type Next } from '@zeltjs/core'; interface RateLimitOptions { limit: number; windowSec: number; } @Middleware class RateLimitMiddleware extends MiddlewareWithOptions { async use(next: Next, opts = middlewareOptions(RateLimitMiddleware)) { await next(); return undefined; } } // ---cut--- @Controller('/api') export class ApiController { @UseMiddleware(RateLimitMiddleware.with({ limit: 10, windowSec: 60 })) @Post('/submit') submit() { return { submitted: true }; } } ``` Middleware that takes options is always registered through `.with()`. Registering the bare class is a type error — there would be no options for it to run with. Middleware without options doesn't extend anything and is registered as the plain class, exactly as in the earlier examples. ### Reading Values from Middleware with Options To read the value provided by middleware that takes options, store the return value of `.with()` in a `const` and use that same `const` in both places — where the middleware is applied, and in `middlewareValue()`: ```typescript import { Controller, Get, Middleware, MiddlewareWithOptions, UseMiddleware, middlewareOptions, middlewareValue, type Next } from '@zeltjs/core'; interface AuthOptions { role: 'admin' | 'member'; } @Middleware class UserAuthMiddleware extends MiddlewareWithOptions { async use(next: Next<{ id: number; role: string }>, opts = middlewareOptions(UserAuthMiddleware)) { await next({ id: 1, role: opts.role }); return undefined; } } // ---cut--- // user-auth.middleware.ts export const adminAuth = UserAuthMiddleware.with({ role: 'admin' }); // admin.controller.ts @UseMiddleware(adminAuth) @Controller('/admin') export class AdminController { @Get('/me') me(admin = middlewareValue(adminAuth)) { return { id: admin.id, role: admin.role }; } } ``` Each `.with()` call counts as its own middleware. If a route is registered with one `.with()` call and `middlewareValue()` receives another — even with identical options — the two don't match: `middlewareValue()` sees a middleware that isn't applied to the route and throws. Always share a single `const`. The same middleware class can be applied multiple times with different options. Each application runs independently, and each `const` reads its own value. ## Request Flow ```mermaid flowchart LR REQ["Request"] --> G subgraph G["Global Middleware"] subgraph C["Controller Middleware"] subgraph M["Method Middleware"] RH["Route Handler"] end end end G --> RES["Response"] ``` Middleware can process both before and after the route handler by placing logic before or after `await next()`. ## Execution Order Middleware executes in this order: 1. **Global middleware** (in array order) 2. **Controller middleware** (in decorator order) 3. **Method middleware** (in decorator order) 4. **Route handler** 5. **Post-handler middleware** (reverse order after `next()`) ```typescript import { Middleware, type Next } from '@zeltjs/core'; // ---cut--- @Middleware class GlobalMiddleware { async use(next: Next) { console.log('1. global before'); await next(); console.log('6. global after'); } } @Middleware class ControllerMiddleware { async use(next: Next) { console.log('2. controller before'); await next(); console.log('5. controller after'); } } @Middleware class MethodMiddleware { async use(next: Next) { console.log('3. method before'); await next(); console.log('4. method after'); } } ``` ## Common Patterns Middleware is written as classes. Use `request()`, `response()`, and `middlewareValue()` for framework primitives. ### Restrict Access Use class middleware when you need to inject services: ```typescript import { Middleware, Injectable, inject, currentUser, type Next } from '@zeltjs/core'; @Injectable() class AuthService { isAdmin(user: unknown) { return false; } } // ---cut--- @Middleware export class RequireAdmin { constructor(private authService = inject(AuthService)) {} async use(next: Next): Promise { const user = currentUser(); if (!this.authService.isAdmin(user)) { return Response.json({ error: 'Forbidden' }, { status: 403 }); } await next(); return undefined; } } ``` ### Add Response Headers Use `response()` for response headers: ```typescript import { Middleware, response, type Next } from '@zeltjs/core'; // ---cut--- @Middleware class PoweredByMiddleware { async use(next: Next, res = response()) { res.header('X-Powered-By', 'zelt'); await next(); } } ``` Use `{ type: 'append' }` when multiple values for the same header should be preserved: ```typescript import { Middleware, response, type Next } from '@zeltjs/core'; // ---cut--- @Middleware class CacheTagMiddleware { async use(next: Next, res = response()) { res.header('Cache-Tag', 'api'); res.header('Cache-Tag', 'users', { type: 'append' }); await next(); } } ``` ### Measure Response Time ```typescript import { Middleware, response, type Next } from '@zeltjs/core'; // ---cut--- @Middleware class TimingMiddleware { async use(next: Next, res = response()) { const start = Date.now(); await next(); res.header('X-Response-Time', `${Date.now() - start}ms`); } } ``` --- ## Request & Response Primitives Zelt provides a `request()` primitive for accessing request data and a `response()` primitive for building responses. `request()` can be used as a default parameter in controller methods and returns a request accessor. ## Request Primitives ### Query Parameters ```typescript import { Controller, Get, request, response } from '@zeltjs/core'; @Controller('/search') export class SearchController { @Get('/') search(req = request(), res = response()) { const q = req.queryParam('q'); const tags = req.queryParams('tag'); // q: string | undefined // tags: string[] (empty array if not provided) return res.json({ query: q, tags }); } } ``` | Method | Return Type | Description | |----------|-------------|-------------| | `req.queryParam(name)` | `string \| undefined` | Get a single query parameter | | `req.queryParams(name)` | `string[]` | Get all values for a query parameter | ### Headers ```typescript import { Controller, Get, request, response } from '@zeltjs/core'; @Controller('/api') export class ApiController { @Get('/info') info(req = request(), res = response()) { const userAgent = req.header('User-Agent'); const acceptLanguage = req.header('Accept-Language'); return res.json({ userAgent, acceptLanguage }); } } ``` | Method | Return Type | Description | |----------|-------------|-------------| | `req.header(name)` | `string \| undefined` | Get a request header value | ### Cookies ```typescript import { Controller, Get, request, response } from '@zeltjs/core'; @Controller('/session') export class SessionController { @Get('/') getSession(req = request(), res = response()) { const sessionId = req.cookie('session_id'); return res.json({ sessionId }); } } ``` | Method | Return Type | Description | |----------|-------------|-------------| | `req.cookie(name)` | `string \| undefined` | Get a cookie value | ### URL & Path ```typescript import { Controller, Get, request, response } from '@zeltjs/core'; @Controller('/debug') export class DebugController { @Get('/request') requestInfo(req = request(), res = response()) { const fullUrl = req.url(); const requestPath = req.path(); const httpMethod = req.method(); return res.json({ url: fullUrl, // "http://localhost:3000/debug/request?foo=bar" path: requestPath, // "/debug/request" method: httpMethod // "GET" }); } } ``` | Method | Return Type | Description | |----------|-------------|-------------| | `req.url()` | `string` | Full request URL including query string | | `req.path()` | `string` | Request path without query string | | `req.method()` | `string` | HTTP method (GET, POST, etc.) | ### Client IP ```typescript import { Controller, Get, request, response } from '@zeltjs/core'; @Controller('/debug') export class DebugController { @Get('/ip') clientIp(req = request(), res = response()) { const ip = req.ip(); return res.json({ ip }); } } ``` | Method | Return Type | Description | |----------|-------------|-------------| | `req.ip()` | `string \| undefined` | Client IP address | ### Request Body The request body target is configured when calling `request()`. Use `await req.body()` to read the parsed body. When no schema is passed, `request()` uses an internal any schema and the default `json` target. ```typescript // @noErrors import { Controller, Post, request, response } from '@zeltjs/core'; import * as v from 'valibot'; const FormSchema = v.record(v.string(), v.unknown()); @Controller('/upload') export class UploadController { @Post('/json') async uploadJson(req = request(), res = response()) { const data = await req.body(); return res.json({ received: data }); } @Post('/form') async uploadForm(req = request(FormSchema, { target: 'form' }), res = response()) { const formData = await req.body(); return res.json({ fields: formData }); } } ``` | `request()` call | `await req.body()` type | Description | |------|-------------|-------------| | `request()` | `unknown` | Parsed JSON body with the default any schema | | `request(schema)` | schema output | Validated JSON body | | `request(schema, { target: 'form' })` | schema output | Validated form data | :::tip For validated request bodies with automatic type inference, use [`request()` with a schema](./validation.md) instead. ::: ### Path Parameters ```typescript import { Controller, Get, request, response } from '@zeltjs/core'; @Controller('/users') export class UserController { @Get('/:id') getUser(req = request(), res = response()) { const id = req.pathParam('id'); // id: string (throws if not defined) return res.json({ userId: id }); } } ``` | Method | Return Type | Description | |----------|-------------|-------------| | `req.pathParam(name)` | `string` | Get a path parameter (throws if undefined) | ## Response Primitives ### response() The `response()` primitive returns a builder for constructing HTTP responses: ```typescript import { Controller, Get, Post, response } from '@zeltjs/core'; @Controller('/api') export class ApiController { @Get('/data') getData(res = response()) { return res.json({ message: 'Hello' }); } @Get('/redirect') redirect(res = response()) { return res.redirect('/new-location', 302); } @Get('/text') getText(res = response()) { return res.text('Plain text response'); } @Post('/created') create(res = response()) { return res.json({ id: '123' }, 201); } } ``` ### Response Methods | Method | Description | |--------|-------------| | `json(data, status?, headers?)` | JSON response with optional status code and headers | | `text(data, status?)` | Plain text response | | `redirect(url, status?)` | HTTP redirect (default: 302) | | `body(data, status?)` | Raw body response | | `header(name, value)` | Set a response header (chainable) | | `stream(cb, onError?)` | Stream binary data | | `streamText(cb, onError?)` | Stream text data | | `sse(cb, onError?)` | Server-Sent Events stream | ### Setting Cookies ```typescript import { Controller, Post, response } from '@zeltjs/core'; @Controller('/auth') export class AuthController { @Post('/login') login(res = response()) { return res .setCookie('session_id', 'abc123', { httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 60 * 60 * 24, // 1 day }) .json({ success: true }); } @Post('/logout') logout(res = response()) { return res .deleteCookie('session_id') .json({ success: true }); } } ``` ### Cookie Options | Option | Type | Description | |--------|------|-------------| | `domain` | `string` | Cookie domain | | `expires` | `Date` | Expiration date | | `httpOnly` | `boolean` | HTTP-only flag | | `maxAge` | `number` | Max age in seconds | | `path` | `string` | Cookie path | | `secure` | `boolean` | Secure flag | | `sameSite` | `'Strict' \| 'Lax' \| 'None'` | SameSite attribute | ## Streaming Responses Zelt provides streaming capabilities for real-time data delivery. ### Basic Streaming Use `stream()` for binary data or `streamText()` for text data: ```typescript import { Controller, Get, response } from '@zeltjs/core'; @Controller('/stream') export class StreamController { @Get('/data') streamData(res = response()) { return res.stream(async (stream) => { await stream.write('chunk 1'); await stream.sleep(100); await stream.write('chunk 2'); await stream.close(); }); } @Get('/lines') streamLines(res = response()) { return res.streamText(async (stream) => { await stream.writeln('line 1'); await stream.writeln('line 2'); await stream.close(); }); } } ``` ### Server-Sent Events (SSE) Use `sse()` for Server-Sent Events: ```typescript import { Controller, Get, response } from '@zeltjs/core'; @Controller('/events') export class EventController { @Get('/updates') streamUpdates(res = response()) { return res.sse(async (stream) => { await stream.writeSSE({ data: 'connected', event: 'open' }); for (let i = 0; i < 5; i++) { await stream.sleep(1000); await stream.writeSSE({ data: JSON.stringify({ count: i }), event: 'update', id: String(i), }); } await stream.close(); }); } } ``` ### Stream Writer Methods | Method | Description | |--------|-------------| | `write(input)` | Write `Uint8Array` or `string` to stream | | `writeln(input)` | Write string with newline | | `writeSSE(message)` | Write SSE message (SSE streams only) | | `sleep(ms)` | Pause for specified milliseconds | | `pipe(body)` | Pipe a `ReadableStream` | | `close()` | Close the stream | | `abort()` | Abort the stream | | `onAbort(listener)` | Register abort handler | ### SSE Message Format ```typescript type SSEMessage = { data: string | Promise; event?: string; id?: string; retry?: number; }; ``` ### Error Handling Both streaming methods accept an optional error handler: ```typescript import { Controller, Get, response } from '@zeltjs/core'; // ---cut--- @Controller('/stream') class StreamController { @Get('/data') streamData(res = response()) { return res.stream( async (stream) => { // ... stream logic }, async (error, stream) => { await stream.write(`Error: ${error.message}`); await stream.close(); } ); } } ``` ## Chaining Response Methods Response methods that modify state (`header`, `setCookie`, `deleteCookie`) return the builder, allowing method chaining: ```typescript import { Controller, Get, response } from '@zeltjs/core'; // ---cut--- @Controller('/files') class FileController { @Get('/download') download(res = response()) { return res .header('Content-Disposition', 'attachment; filename="report.csv"') .header('Cache-Control', 'no-cache') .setCookie('download_started', 'true') .text('id,name\n1,Alice\n2,Bob'); } } ``` --- ## Validation Zelt validates request bodies with synchronous Standard Schema compatible schemas. You can use any validator that exposes `schema["~standard"].validate(value)`, including [Valibot](https://valibot.dev/), Zod, and ArkType. ## Installation `request()` is included in `@zeltjs/core`. Install the schema library you want to use: ```bash pnpm add @zeltjs/core valibot ``` ### With OpenAPI Generation Runtime validation only requires Standard Schema. OpenAPI generation still needs a schema adapter when the schema does not expose Standard JSON Schema. For Valibot, use `@zeltjs/validator-valibot/openapi` and install `@valibot/to-json-schema`: ```bash pnpm add @zeltjs/validator-valibot valibot @valibot/to-json-schema ``` :::tip[Version Compatibility] `@valibot/to-json-schema` must match your `valibot` version. For example: - `valibot@1.4.x` → `@valibot/to-json-schema@1.7.x` - `valibot@1.3.x` → `@valibot/to-json-schema@1.6.x` Check the [Valibot releases](https://github.com/fabian-hiller/valibot/releases) for compatibility. ::: :::info[Important] Import `request()` from `@zeltjs/core`. The Valibot package only provides the OpenAPI schema adapter. The `valibot` peer dependency must be `^1.0.0`. We test against `1.3.x`; using an older version may cause type inference issues. ::: ## Basic Usage Use `request()` with a Valibot schema to validate request bodies: ```typescript import { Controller, Post, request, response } from '@zeltjs/core'; import * as v from 'valibot'; const CreateUserSchema = v.object({ name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)), email: v.pipe(v.string(), v.email()), age: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(150))), }); @Controller('/users') export class UserController { @Post('/') async create(req = request(CreateUserSchema), res = response()) { const body = await req.body(); // body is fully typed: { name: string; email: string; age?: number } return res.json({ id: '1', ...body }, 201); } } ``` ## Form Data and File Uploads Use `request(schema, { target: 'form' })` to validate `multipart/form-data` requests, including file uploads: ```typescript import { Controller, Post, request, response } from '@zeltjs/core'; import * as v from 'valibot'; const UploadSchema = v.object({ file: v.instance(File), description: v.optional(v.string()), }); @Controller('/upload') export class UploadController { @Post('/') async upload(req = request(UploadSchema, { target: 'form' }), res = response()) { const body = await req.body(); // body.file is a File object console.log(body.file.name, body.file.size, body.file.type); return res.json({ filename: body.file.name, size: body.file.size }, 201); } } ``` ### Target Options The `target` option of `request()` specifies the request body format: | Target | Content-Type | Use Case | |--------|-------------|----------| | `'json'` (default) | `application/json` | JSON API requests | | `'form'` | `multipart/form-data`, `application/x-www-form-urlencoded` | File uploads, HTML forms | ### Multiple Files ```typescript import { Controller, Post, request } from '@zeltjs/core'; import * as v from 'valibot'; // ---cut--- const MultiUploadSchema = v.object({ files: v.array(v.instance(File)), category: v.string(), }); @Controller('/upload') class BulkUploadController { @Post('/bulk') async bulkUpload(req = request(MultiUploadSchema, { target: 'form' })) { const body = await req.body(); for (const file of body.files) { console.log(file.name); } return { count: body.files.length }; } } ``` ### OpenAPI Generation When using `'form'` target, OpenAPI output automatically uses `multipart/form-data` as the content type: ```yaml requestBody: required: true content: multipart/form-data: schema: $ref: '#/components/schemas/UploadSchema' ``` ## Validation Error Response When validation fails, Zelt automatically returns a 400 response: ```json { "code": "VALIDATION_FAILED", "issues": [ { "kind": "validation", "type": "email", "message": "Invalid email", "path": ["email"] } ] } ``` See [Error Handling](./error-handling.md) for more details on error responses. ## Common Validations ### String Validations ```typescript import * as v from 'valibot'; // ---cut--- const schema = v.object({ username: v.pipe( v.string(), v.minLength(3), v.maxLength(20), v.regex(/^[a-z0-9_]+$/i) ), email: v.pipe(v.string(), v.email()), url: v.pipe(v.string(), v.url()), uuid: v.pipe(v.string(), v.uuid()), }); ``` ### Number Validations ```typescript import * as v from 'valibot'; // ---cut--- const schema = v.object({ age: v.pipe(v.number(), v.minValue(0), v.maxValue(150)), price: v.pipe(v.number(), v.minValue(0)), quantity: v.pipe(v.number(), v.integer(), v.minValue(1)), }); ``` ### Array Validations ```typescript import * as v from 'valibot'; // ---cut--- const schema = v.object({ tags: v.pipe( v.array(v.string()), v.minLength(1), v.maxLength(10) ), scores: v.array(v.pipe(v.number(), v.minValue(0), v.maxValue(100))), }); ``` ### Optional and Nullable ```typescript import * as v from 'valibot'; // ---cut--- const schema = v.object({ required: v.string(), optional: v.optional(v.string()), nullable: v.nullable(v.string()), optionalNullable: v.optional(v.nullable(v.string())), withDefault: v.optional(v.string(), 'default value'), }); ``` ### Nested Objects ```typescript import * as v from 'valibot'; // ---cut--- const AddressSchema = v.object({ street: v.string(), city: v.string(), country: v.string(), zipCode: v.optional(v.string()), }); const UserSchema = v.object({ name: v.string(), address: AddressSchema, alternateAddresses: v.optional(v.array(AddressSchema)), }); ``` ## Type Inference Valibot schemas provide automatic TypeScript type inference: ```typescript import * as v from 'valibot'; // ---cut--- const UserSchema = v.object({ name: v.string(), age: v.number(), }); // Infer the type from schema type User = v.InferOutput; // Equivalent to: { name: string; age: number } ``` ## Why Valibot? - **Type-safe** — Full TypeScript support with automatic type inference - **Lightweight** — Tree-shakeable, only includes what you use - **Fast** — Optimized for runtime performance - **Composable** — Build complex schemas from simple building blocks --- ## HTTP Security Zelt provides built-in HTTP security through two auto-registered middleware classes: `SecureHeadersMiddleware` and `CorsMiddleware`. Both are registered globally on every HTTP app and run on all routes before your configured global middleware. Configuration is controlled through `SecureHeadersConfig` and `CorsConfig`. Both use the `@Config` decorator pattern for type-safe, DI-based configuration. - **SecureHeadersConfig** is enabled by default with secure defaults - **CorsConfig** is disabled by default (empty origin) and must be explicitly configured ## SecureHeadersConfig Security headers are automatically applied to all responses. The default configuration enables recommended security headers. ### Default Headers | Header | Default | |--------|---------| | `Cross-Origin-Resource-Policy` | `same-origin` | | `Cross-Origin-Opener-Policy` | `same-origin` | | `Origin-Agent-Cluster` | `?1` | | `Referrer-Policy` | `no-referrer` | | `Strict-Transport-Security` | `max-age=15552000; includeSubDomains` | | `X-Content-Type-Options` | `nosniff` | | `X-DNS-Prefetch-Control` | `off` | | `X-Download-Options` | `noopen` | | `X-Frame-Options` | `SAMEORIGIN` | | `X-Permitted-Cross-Domain-Policies` | `none` | | `X-XSS-Protection` | `0` | | `X-Powered-By` | removed | | `Cross-Origin-Embedder-Policy` | disabled | ### Customizing Headers Extend `SecureHeadersConfig` and override properties to customize header values: ```typescript import { Config, SecureHeadersConfig } from '@zeltjs/core'; @Config class MySecureHeadersConfig extends SecureHeadersConfig { override readonly xFrameOptions = 'DENY'; override readonly referrerPolicy = 'strict-origin-when-cross-origin'; } ``` ### Disabling Headers Set a header property to `false` to disable it: ```typescript import { Config, SecureHeadersConfig } from '@zeltjs/core'; @Config class MySecureHeadersConfig extends SecureHeadersConfig { override readonly xXssProtection = false; override readonly xDownloadOptions = false; } ``` ## CorsConfig CORS is disabled by default. To enable it, extend `CorsConfig` and set the `origin` property. ### Enabling CORS ```typescript import { Config, CorsConfig } from '@zeltjs/core'; @Config class MyCorsConfig extends CorsConfig { override readonly origin = 'https://example.com'; } ``` ### Multiple Origins ```typescript import { Config, CorsConfig } from '@zeltjs/core'; @Config class MyCorsConfig extends CorsConfig { override readonly origin = ['https://app.example.com', 'https://admin.example.com']; } ``` ### Available Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `origin` | `string \| string[]` | `[]` | Allowed origins (empty disables CORS) | | `credentials` | `boolean` | `false` | Allow credentials | | `allowMethods` | `string[]` | `['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH']` | Allowed HTTP methods | | `allowHeaders` | `string[]` | `[]` | Allowed request headers | | `exposeHeaders` | `string[]` | `[]` | Headers exposed to the client | | `maxAge` | `number \| undefined` | `undefined` | Preflight cache duration in seconds | ### Full Configuration Example ```typescript import { Config, CorsConfig } from '@zeltjs/core'; @Config class MyCorsConfig extends CorsConfig { override readonly origin = 'https://example.com'; override readonly credentials = true; override readonly allowHeaders = ['Content-Type', 'Authorization']; override readonly exposeHeaders = ['X-Request-Id']; override readonly maxAge = 86400; } ``` ## Registration The middleware classes are registered automatically. Register custom configs when creating the app: ```typescript import { createApp, Config, CorsConfig, SecureHeadersConfig, Controller, Get, http } from '@zeltjs/core'; @Config class MyCorsConfig extends CorsConfig { override readonly origin = 'https://example.com'; override readonly credentials = true; } @Config class MySecureHeadersConfig extends SecureHeadersConfig { override readonly xFrameOptions = 'DENY'; } @Controller('/') class AppController { @Get('/') index() { return { ok: true }; } } const app = createApp([http({ controllers: [AppController], })], { configs: [MyCorsConfig, MySecureHeadersConfig] }); ``` The framework automatically detects and uses your custom configuration classes when registered in the `configs` array. ## Skipping Security Middleware Use `@SkipMiddleware` to skip either built-in middleware for one endpoint or every endpoint in a controller. Method-level and controller-level skips are combined. ```typescript import { Controller, CorsMiddleware, Get, SecureHeadersMiddleware, SkipMiddleware, } from '@zeltjs/core'; @SkipMiddleware(CorsMiddleware) @Controller('/webhook') class WebhookController { @Get('/health') health() { return { ok: true }; } @SkipMiddleware(SecureHeadersMiddleware) @Get('/raw') raw() { return { ok: true }; } } ``` In this example, non-preflight requests to `WebhookController` endpoints skip CORS response headers. The `/webhook/raw` endpoint also skips secure headers. `@SkipMiddleware(CorsMiddleware)` does not disable CORS preflight handling. `OPTIONS` preflight requests are handled by `CorsMiddleware` before an endpoint handler is selected, so the preflight response can still include CORS allow headers. The actual endpoint response is the part that skips `CorsMiddleware`. --- ## Rate Limiting Zelt provides rate limiting via the `@zeltjs/rate-limit` package, using a KV store backend for distributed rate limiting. ## Basic Usage Use the `@RateLimit` decorator to apply rate limiting to routes: ```typescript import { Controller, Get, Post } from '@zeltjs/core'; import { RateLimit } from '@zeltjs/rate-limit'; @Controller('/api') export class ApiController { @RateLimit({ limit: 100, windowSec: 60, key: 'ip' }) @Get('/data') getData() { return { items: [] }; } } ``` ## Dynamic Keys Rate limiting keys determine how requests are grouped. Use static strings or functions: ```typescript import { Controller, Get, currentUser, request } from '@zeltjs/core'; import { RateLimit } from '@zeltjs/rate-limit'; // ---cut--- @Controller('/api') class ApiController { // By IP address @RateLimit({ limit: 100, windowSec: 60, key: 'ip' }) @Get('/public') publicData() { return { data: [] }; } // By user ID @RateLimit({ limit: 1000, windowSec: 60, key: () => `user:${currentUser()?.id ?? 'anonymous'}`, }) @Get('/user-data') userData() { return { data: [] }; } // By API key @RateLimit({ limit: 500, windowSec: 60, key: () => `apikey:${request().header('X-API-Key')}`, }) @Get('/api-data') apiData() { return { data: [] }; } } ``` ## Programmatic Usage Use `RateLimitService` for custom rate limiting logic: ```typescript import { Controller, Post, inject, response } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import { RateLimitService } from '@zeltjs/rate-limit'; import * as v from 'valibot'; const LoginSchema = v.object({ email: v.pipe(v.string(), v.email()), password: v.string() }); // ---cut--- @Controller('/auth') export class AuthController { constructor(private rateLimiter = inject(RateLimitService)) {} @Post('/login') async login(req = request(LoginSchema), res = response()) { const body = await req.body(); const result = await this.rateLimiter.hit(`login:${body.email}`, { limit: 5, windowSec: 300, }); if (!result.ok) { return res.json({ error: 'Service unavailable' }, 503); } if (!result.value.allowed) { return res.json({ error: 'Too many attempts' }, 429); } return { token: 'jwt-token' }; } @Post('/reset') async resetLimit(email: string) { await this.rateLimiter.reset(`login:${email}`); return { success: true }; } } ``` ## Custom Configuration Extend `RateLimitConfig` to customize behavior. To back the limiter with Redis instead of the default in-memory store, pass a `RedisKVAdaptor` to `super()`: ```typescript import { Config, inject } from '@zeltjs/core'; import { RateLimitConfig } from '@zeltjs/rate-limit'; import { RedisKVAdaptor } from '@zeltjs/kv/adaptor-redis'; // ---cut--- @Config class CustomRateLimitConfig extends RateLimitConfig { constructor(kv = inject(RedisKVAdaptor)) { super(kv); } override readonly kvStoreNamespace = 'ratelimit:'; override readonly defaultLimit = 200; override readonly defaultWindowSec = 120; override readonly failureMode = 'closed' as const; } ``` Using Redis requires registering `RedisConfig` (from `@zeltjs/redis`) so the adaptor can resolve its connection. ## Response Headers and Errors Rate limit information is included in response headers: `X-RateLimit-Limit` and `X-RateLimit-Remaining`. | Status | Code | When | |--------|------|------| | 429 | `RATE_LIMIT_EXCEEDED` | Rate limit exceeded | | 503 | `SERVICE_UNAVAILABLE` | KV store fails in `closed` mode | ## Failure Modes The `failureMode` option controls behavior when the KV store is unavailable: | Mode | Behavior | |------|----------| | `'open'` (default) | Allow requests to proceed when KV store fails | | `'closed'` | Reject requests with 503 when KV store fails | Use `'open'` for non-critical rate limiting where availability is prioritized. Use `'closed'` for strict rate limiting where security is critical. ## RateLimitResult Type The `hit()` method returns `Promise`: ```typescript import type { RateLimitResult, RateLimitError, RateLimiterHitResult } from '@zeltjs/rate-limit'; // ---cut--- type HitResult = | { ok: true; value: RateLimitResult } | { ok: false; error: RateLimitError }; type Result = { allowed: boolean; // Whether the request is permitted remaining: number; // Requests remaining in current window limit: number; // Maximum requests allowed retryAfterSec: number; // Seconds until window resets (0 if allowed) }; ``` --- ## Overview Zelt provides a flexible authentication system that separates **authentication** (who is the user?) from **authorization** (what can they do?). ## Authentication vs Authorization | Concept | Question | Zelt API | |---------|----------|----------| | **Authentication** | Who is the user? | `setUser()`, `currentUser()` | | **Authorization** | What can they do? | `@Authorized()`, `currentRoles()` | Authentication happens first (typically in middleware), then authorization checks run on protected routes. ## Choose Your Strategy Zelt supports multiple authentication strategies. Pick the one that fits your architecture: | Strategy | Best For | Package | |----------|----------|---------| | **JWT** | SPAs, Mobile apps, APIs | `@zeltjs/auth-jwt` | | **Sessions** | Server-rendered apps, Traditional web apps | `@zeltjs/auth-session` | | **Custom** | API keys, OAuth, or any other method | Built-in primitives | ### Decision Guide ```mermaid flowchart TD Q1{"Browser client withserver-side rendering?"} Q1 -- "Yes" --> SESSIONS["Sessions(cookie-based, automatic CSRF handling)"] Q1 -- "No" --> Q2{"SPA or mobile app?"} Q2 -- "Yes" --> JWT["JWT(stateless, scalable)"] Q2 -- "No — machine-to-machine API" --> CUSTOM["Custom(API keys, mTLS)"] ``` ## Authentication Flow ```mermaid flowchart TD REQ["Request"] --> AM["Authentication Middleware(verify credentials, setUser)"] AM --> AC{"@Authorized() check"} AC -- "no user" --> E401["401"] AC -- "missing role" --> E403["403"] AC -- "OK" --> RH["Route Handler"] RH --> RES["Response"] ``` ## Quick Start ### 1. Install a package (or use built-in primitives) ```bash # For JWT authentication pnpm add @zeltjs/auth-jwt # For session authentication pnpm add @zeltjs/auth-session @zeltjs/kv ``` ### 2. Register middleware ```typescript import { createApp, Controller, Get, Authorized, currentUser, http } from '@zeltjs/core'; import { JwtMiddleware, JwtConfig } from '@zeltjs/auth-jwt'; @Controller('/users') class UserController { @Authorized() @Get('/me') me() { return currentUser(); } } // ---cut--- const app = createApp([http({ controllers: [UserController], middlewares: [JwtMiddleware], })], { configs: [JwtConfig] }); ``` ### 3. Protect routes ```typescript import { Controller, Get, Authorized, currentUser } from '@zeltjs/core'; // ---cut--- @Controller('/dashboard') class DashboardController { @Authorized() @Get('/') index() { const user = currentUser(); return { message: `Hello, ${user?.name}` }; } } ``` ## Next Steps - [User Context](./user-context) — How to type and access the authenticated user - [JWT Authentication](./jwt) — Stateless token-based authentication - [Session Authentication](./sessions) — Cookie-based session management - [Custom Authentication](./custom) — Build your own authentication middleware --- ## User Context Zelt provides request-scoped functions to access and manage the authenticated user. ## Core Functions | Function | Description | |----------|-------------| | `setUser(user, roles)` | Set the authenticated user (call in middleware) | | `currentUser()` | Get the current user (returns `undefined` if not authenticated) | | `currentRoles()` | Get the current user's roles (returns `[]` if not authenticated) | ## Setting the User Call `setUser()` in your authentication middleware after validating credentials: ```typescript import { Middleware, request, setUser, type Next } from '@zeltjs/core'; declare function verifyToken(token: string): Promise<{ sub: string; name: string; email: string; roles: string[] }>; // ---cut--- @Middleware export class AuthMiddleware { async use(next: Next, req = request()): Promise { const token = req.header('Authorization')?.replace('Bearer ', ''); if (token) { const payload = await verifyToken(token); setUser( { id: payload.sub, name: payload.name, email: payload.email }, payload.roles ); } await next(); return undefined; } } ``` ### Parameters - **user** — Any object representing the authenticated user - **roles** — Array of role strings (e.g., `['admin', 'user']`) ## Accessing the User ### In Route Handlers Use `currentUser()` to access the authenticated user: ```typescript import { Controller, Get, currentUser, currentRoles } from '@zeltjs/core'; import { HTTPException } from 'hono/http-exception'; // ---cut--- @Controller('/profile') class ProfileController { @Get('/me') me() { const user = currentUser(); const roles = currentRoles(); if (!user) { throw new HTTPException(401, { message: 'Not authenticated' }); } return { user, roles, isAdmin: roles.includes('admin') }; } } ``` ### With Default Parameters For cleaner handler signatures, use default parameters: ```typescript import { Controller, Get, currentUser } from '@zeltjs/core'; // ---cut--- @Controller('/profile') class ProfileController { @Get('/me') me(user = currentUser()) { return user; } } ``` ## Typed Access `currentUser()` always returns `Record | undefined` — the shape passed to `setUser()` isn't tracked at the type level, so reading a specific field requires a manual assertion or narrowing. For a value with a concrete, narrowed type available to handlers, provide it through a middleware instead: declare it with `Next` and pass it to `next(value)`, then read it with `middlewareValue(M)`. See [Middleware Values](../middleware.md#middleware-values) for details. ## User Design Best Practices ### Keep It Minimal Only include fields you need in handlers. Don't copy the entire database record: ```typescript import { setUser } from '@zeltjs/core'; // ---cut--- // ✅ Good — minimal user setUser({ id: '123', name: 'Alice' }, ['user']); // ❌ Avoid — copying the entire record setUser({ id: '123', name: 'Alice', email: 'alice@example.com', passwordHash: '...', // Never include sensitive data createdAt: new Date(), updatedAt: new Date(), preferences: {}, // ... 20 more fields }, ['user']); ``` ### Fetch Additional Data When Needed Use the user ID to fetch more data in specific handlers: ```typescript import { Controller, Get, Authorized, Injectable, inject, currentUser } from '@zeltjs/core'; type FullUser = { preferences: object }; type SessionUser = { id: string }; const isSessionUser = (u: Record | undefined): u is SessionUser => typeof u?.id === 'string'; @Injectable() class UserRepository { async findById(id: string): Promise { return { preferences: {} }; } } // ---cut--- @Controller('/settings') class SettingsController { constructor(private userRepo = inject(UserRepository)) {} @Authorized() @Get('/') async getSettings() { const user = currentUser(); if (!isSessionUser(user)) return; const fullUser = await this.userRepo.findById(user.id); return { preferences: fullUser.preferences }; } } ``` ### Consider Role Granularity Roles should be simple strings. Complex permission logic belongs in services: ```typescript // ---cut--- // ✅ Good — simple roles type GoodRoles = ('admin' | 'editor' | 'viewer')[]; // ❌ Avoid — overly specific roles type BadRoles = ('can_edit_posts' | 'can_delete_posts' | 'can_view_analytics')[]; ``` For fine-grained permissions, check roles in your service layer: ```typescript import { currentUser, currentRoles } from '@zeltjs/core'; interface Post { authorId: string; } type SessionUser = { id: string }; const isSessionUser = (u: Record | undefined): u is SessionUser => typeof u?.id === 'string'; // ---cut--- function canEdit(post: Post): boolean { const user = currentUser(); const roles = currentRoles(); if (roles.includes('admin')) return true; if (roles.includes('editor') && isSessionUser(user) && post.authorId === user.id) return true; return false; } ``` --- ## JWT Authentication `@zeltjs/auth-jwt` provides stateless JWT-based authentication for SPAs, mobile apps, and APIs. ## Installation ```bash pnpm add @zeltjs/auth-jwt ``` ## Quick Start ### 1. Set the Secret Set the `JWT_SECRET` environment variable: ```bash # .env JWT_SECRET=your-secret-key-at-least-32-characters ``` ### 2. Register Middleware ```typescript import { createApp, Controller, Post, Get, Authorized, currentUser, inject, http } from '@zeltjs/core'; import { JwtMiddleware, JwtConfig, JwtService } from '@zeltjs/auth-jwt'; @Controller('/auth') class AuthController { constructor(private jwtService = inject(JwtService)) {} @Post('/login') async login() { return { token: await this.jwtService.sign({ sub: '1' }) }; } } @Controller('/users') class UserController { @Authorized() @Get('/me') me() { return currentUser(); } } // ---cut--- const app = createApp([http({ controllers: [AuthController, UserController], middlewares: [JwtMiddleware], })], { configs: [JwtConfig] }); ``` ### 3. Generate Tokens Use `JwtService` to sign tokens at login: ```typescript import { Controller, Post, inject } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import { JwtService } from '@zeltjs/auth-jwt'; import { HTTPException } from 'hono/http-exception'; import * as v from 'valibot'; declare function validateCredentials(email: string, password: string): Promise<{ id: string; roles: string[] } | null>; // ---cut--- const LoginSchema = v.object({ email: v.pipe(v.string(), v.email()), password: v.string(), }); @Controller('/auth') class AuthController { constructor(private jwtService = inject(JwtService)) {} @Post('/login') async login(req = request(LoginSchema)) { const body = await req.body(); const user = await validateCredentials(body.email, body.password); if (!user) { throw new HTTPException(401, { message: 'Invalid credentials' }); } const token = await this.jwtService.sign({ sub: user.id, roles: user.roles, }); return { token }; } } ``` ### 4. Protect Routes Use `@Authorized()` to require authentication: ```typescript import { Controller, Get, Authorized, currentUser } from '@zeltjs/core'; // ---cut--- @Controller('/users') class UserController { @Authorized() @Get('/me') me(user = currentUser()) { return user; } } ``` ## JwtService API | Method | Description | |--------|-------------| | `sign(payload)` | Create a signed JWT token | | `verify(token)` | Verify and decode a token (throws on invalid) | | `decode(token)` | Decode without verification (returns `null` on error) | ### Sign Create a signed token with custom payload: ```typescript import { Injectable, inject } from '@zeltjs/core'; import { JwtService } from '@zeltjs/auth-jwt'; @Injectable() class TokenService { constructor(private jwtService = inject(JwtService)) {} // ---cut--- async createToken(userId: string) { return this.jwtService.sign({ sub: userId, roles: ['admin', 'user'], customClaim: 'value', }); } } ``` ### Verify Verify a token and get its payload (throws if invalid or expired): ```typescript import { Injectable, inject } from '@zeltjs/core'; import { JwtService } from '@zeltjs/auth-jwt'; @Injectable() class TokenService { constructor(private jwtService = inject(JwtService)) {} // ---cut--- async validateToken(token: string) { try { const payload = await this.jwtService.verify(token); console.log(payload.sub); return payload; } catch { return null; } } } ``` ### Decode Decode without verification (useful for reading expired tokens): ```typescript import { Injectable, inject } from '@zeltjs/core'; import { JwtService } from '@zeltjs/auth-jwt'; @Injectable() class TokenService { constructor(private jwtService = inject(JwtService)) {} // ---cut--- readToken(token: string) { const payload = this.jwtService.decode(token); if (payload) { console.log(payload.sub); } return payload; } } ``` ## Configuration Extend `JwtConfig` to customize behavior: ```typescript import { JwtConfig, type JwtPayload, type ResolveUserResult } from '@zeltjs/auth-jwt'; import { Config, Env, Injectable, inject } from '@zeltjs/core'; type User = { id: string; name: string; email: string; roles: string[] }; @Injectable() class UserRepository { async findById(id: string): Promise { return { id, name: '', email: '', roles: [] }; } } // ---cut--- @Config class CustomJwtConfig extends JwtConfig { constructor(private userRepo = inject(UserRepository)) { super(); } override get secret(): string { return this.env.getRequired('JWT_SECRET'); } override get expiresIn(): string { return '7d'; } override get resolveUser(): (payload: JwtPayload) => Promise { return async (payload) => { const user = await this.userRepo.findById(payload.sub!); return { user: { id: user.id, name: user.name, email: user.email }, roles: user.roles, }; }; } } ``` Register your custom config: ```typescript import { createApp, Controller, Post, Get, Authorized, currentUser, inject, http } from '@zeltjs/core'; import { JwtMiddleware, JwtService } from '@zeltjs/auth-jwt'; import { JwtConfig, type JwtPayload, type ResolveUserResult } from '@zeltjs/auth-jwt'; import { Config, Env, Injectable } from '@zeltjs/core'; type User = { id: string; name: string; email: string; roles: string[] }; @Injectable() class UserRepository { async findById(id: string): Promise { return { id, name: '', email: '', roles: [] }; } } @Config class CustomJwtConfig extends JwtConfig { constructor(private userRepo = inject(UserRepository)) { super(); } override get secret(): string { return this.env.getRequired('JWT_SECRET'); } override get expiresIn(): string { return '7d'; } override get resolveUser(): (payload: JwtPayload) => Promise { return async (payload) => { const user = await this.userRepo.findById(payload.sub!); return { user: { id: user.id, name: user.name, email: user.email }, roles: user.roles }; }; } } @Controller('/auth') class AuthController { constructor(private jwtService = inject(JwtService)) {} @Post('/login') async login() { return { token: await this.jwtService.sign({ sub: '1' }) }; } } @Controller('/users') class UserController { @Authorized() @Get('/me') me() { return currentUser(); } } // ---cut--- const app = createApp([http({ controllers: [AuthController, UserController], middlewares: [JwtMiddleware], })], { configs: [CustomJwtConfig] }); ``` ### Configuration Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `secret` | `string` | `env.getRequired('JWT_SECRET')` | Secret key for signing | | `expiresIn` | `string` | `'1h'` | Token expiration (e.g., `'15m'`, `'7d'`) | | `resolveUser` | `function` | Returns `{ user: sub, roles: [] }` | Resolves user from JWT payload | ## Client Integration ### Sending the Token Clients should include the token in the `Authorization` header: ```typescript declare const token: string; // ---cut--- fetch('/api/users/me', { headers: { 'Authorization': `Bearer ${token}`, }, }); ``` ### Token Storage Store tokens securely on the client: | Platform | Recommended Storage | |----------|---------------------| | Browser SPA | `httpOnly` cookie or memory (avoid `localStorage`) | | Mobile App | Secure storage (Keychain / Keystore) | | Server-to-Server | Environment variable | ## Token Refresh Pattern For long-lived sessions, implement a refresh token flow: ```typescript import { Controller, Post, Injectable, inject } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import { JwtService } from '@zeltjs/auth-jwt'; import * as v from 'valibot'; const RefreshSchema = v.object({ refreshToken: v.string() }); type User = { id: string; roles: string[] }; @Injectable() class UserRepository { async findById(id: string): Promise { return { id, roles: [] }; } } // ---cut--- @Controller('/auth') class AuthController { constructor( private jwtService = inject(JwtService), private userRepo = inject(UserRepository) ) {} @Post('/refresh') async refresh(req = request(RefreshSchema)) { const body = await req.body(); const payload = await this.jwtService.verify(body.refreshToken); const user = await this.userRepo.findById(payload.sub!); const accessToken = await this.jwtService.sign({ sub: user.id, roles: user.roles, }); return { accessToken }; } } ``` ## Error Responses | Status | Code | When | |--------|------|------| | 401 | `UNAUTHORIZED` | No token, invalid token, or expired token | | 403 | `FORBIDDEN` | Valid token but missing required role | ```json { "code": "UNAUTHORIZED", "message": "Authentication required" } ``` ## Edge Runtime Support `@zeltjs/auth-jwt` uses the `jose` library which supports Web Crypto API, making it compatible with: - Cloudflare Workers - Vercel Edge Functions - Deno Deploy - Node.js --- ## Session Authentication `@zeltjs/auth-session` provides cookie-based session management for server-rendered applications. ## Installation ```bash pnpm add @zeltjs/auth-session @zeltjs/kv ``` ## Quick Start ### 1. Set the Secret Set the `SESSION_SECRET` environment variable: ```bash # .env SESSION_SECRET=your-secret-key-at-least-32-characters ``` ### 2. Configure Session Store Sessions are stored in a KV store. By default `SessionConfig` uses the in-memory adaptor under the `session:` namespace. To customize the namespace (or other options), extend `SessionConfig`: ```typescript import { Config } from '@zeltjs/core'; import { SessionConfig } from '@zeltjs/auth-session'; // ---cut--- @Config class MySessionConfig extends SessionConfig { override readonly kvStoreNamespace = 'sessions:'; } ``` ### 3. Register Middleware ```typescript import { createApp, Config, Controller, Post, Get, inject, http } from '@zeltjs/core'; import { MemoryKVService } from '@zeltjs/kv'; import { SessionMiddleware, SessionConfig, getSession, setSession, destroySession } from '@zeltjs/auth-session'; import { HTTPException } from 'hono/http-exception'; @Config class MySessionConfig extends SessionConfig { override readonly kvStoreNamespace = 'sessions:'; } @Controller('/auth') class AuthController { @Post('/login') login() { setSession({ userId: '1' }); return { success: true }; } @Get('/me') me() { const session = getSession(); if (!session) throw new HTTPException(401, { message: 'Not logged in' }); return session; } @Post('/logout') logout() { destroySession(); return { success: true }; } } @Controller('/users') class UserController { @Get('/') findAll() { return { users: [] }; } } // ---cut--- const app = createApp([http({ controllers: [AuthController, UserController], middlewares: [SessionMiddleware], })], { configs: [MySessionConfig] }); ``` ### 4. Manage Sessions Use session functions in your handlers: ```typescript import { Controller, Post, Get } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import { getSession, setSession, destroySession } from '@zeltjs/auth-session'; import { HTTPException } from 'hono/http-exception'; import * as v from 'valibot'; const LoginSchema = v.object({ email: v.string(), password: v.string() }); declare function validateCredentials(email: string, password: string): Promise<{ id: string; name: string } | null>; // ---cut--- @Controller('/auth') class AuthController { @Post('/login') async login(req = request(LoginSchema)) { const body = await req.body(); const user = await validateCredentials(body.email, body.password); if (!user) { throw new HTTPException(401, { message: 'Invalid credentials' }); } setSession({ userId: user.id, name: user.name }); return { success: true }; } @Get('/me') me() { const session = getSession(); if (!session) { throw new HTTPException(401, { message: 'Not logged in' }); } return session; } @Post('/logout') logout() { destroySession(); return { success: true }; } } ``` ## Session API | Function | Description | |----------|-------------| | `getSession()` | Get current session data (`undefined` if not logged in) | | `setSession(data)` | Set session data (replaces existing) | | `updateSession(fn)` | Update session data with a function | | `destroySession()` | Destroy session and clear cookie | | `isNewSession()` | Check if this is a newly created session | | `getSessionId()` | Get the current session ID | ### setSession Create or replace the session: ```typescript import { setSession } from '@zeltjs/auth-session'; // ---cut--- setSession({ userId: '123', name: 'Alice', cart: [{ productId: 'abc', qty: 2 }], }); ``` ### updateSession Partially update the session: ```typescript import { updateSession } from '@zeltjs/auth-session'; // ---cut--- updateSession((session) => ({ ...session, lastActivity: Date.now(), })); ``` ### destroySession Clear the session and cookie (for logout): ```typescript import { destroySession } from '@zeltjs/auth-session'; // ---cut--- destroySession(); ``` ## Type-Safe Sessions Extend `SessionSchema` for type-safe session access: ```typescript import { SessionSchema } from '@zeltjs/auth-session'; interface CartItem { productId: string; qty: number; } // ---cut--- declare module '@zeltjs/auth-session' { interface SessionSchema { userId?: string; name?: string; email?: string; cart?: CartItem[]; } } ``` Now all session functions are typed: ```typescript import { getSession, setSession } from '@zeltjs/auth-session'; // ---cut--- const session = getSession(); // TypeScript knows: session?.userId, session?.name, session?.cart setSession({ userId: '123', name: 'Alice' }); // Type-checked against SessionSchema ``` ## Configuration Extend `SessionConfig` to customize behavior: ```typescript import { Config } from '@zeltjs/core'; import { SessionConfig } from '@zeltjs/auth-session'; // ---cut--- @Config class MySessionConfig extends SessionConfig { override readonly kvStoreNamespace = 'sessions:'; override get cookieName(): string { return 'my_session'; // default: 'session' } override get ttlSec(): number { return 86400 * 7; // 7 days (default: 1 day) } override get cookieOptions() { return { httpOnly: true, secure: true, sameSite: 'Strict' as const, path: '/', }; } } ``` ### Configuration Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `kv` | `KVAdaptor` | `MemoryKV` | KV adaptor (constructor arg 2) backing session storage | | `kvStoreNamespace` | `string` | `'session:'` | Namespace prefix for session keys | | `secret` | `string` | `env.getString('SESSION_SECRET')` | Secret for signing session IDs | | `cookieName` | `string` | `'session'` | Cookie name | | `ttlSec` | `number` | `86400` (1 day) | Session TTL in seconds | | `cookieOptions` | `object` | See below | Cookie configuration | ### Default Cookie Options ```typescript import { Config } from '@zeltjs/core'; import { SessionConfig } from '@zeltjs/auth-session'; @Config class MySessionConfig extends SessionConfig { // ---cut--- override get cookieOptions() { return { httpOnly: true, secure: this.env.getString('NODE_ENV', '') === 'production', sameSite: 'Lax' as const, path: '/', }; } } ``` ## Storage Backends ### Memory (Development) ```typescript import { Config, inject } from '@zeltjs/core'; import { MemoryKV } from '@zeltjs/kv'; import { SessionConfig } from '@zeltjs/auth-session'; // ---cut--- @Config class MySessionConfig extends SessionConfig { constructor(kv = inject(MemoryKV)) { super(undefined, kv); } } ``` ### Redis (Production) `SessionConfig` takes the KV adaptor as its second constructor argument. Pass a `RedisKVAdaptor` to `super()` to store sessions in Redis (leave the first argument as `undefined` to keep the default `Env` injection): ```typescript import { Config, inject } from '@zeltjs/core'; import { SessionConfig } from '@zeltjs/auth-session'; import { RedisKVAdaptor } from '@zeltjs/kv/adaptor-redis'; // ---cut--- @Config class MySessionConfig extends SessionConfig { constructor(kv = inject(RedisKVAdaptor)) { super(undefined, kv); } override readonly kvStoreNamespace = 'sessions:'; } ``` Using Redis requires registering `RedisConfig` (from `@zeltjs/redis`) so the adaptor can resolve its connection. ## Integration with User Context Sessions don't automatically set the user context. Add middleware to bridge them: ```typescript import { Middleware, Injectable, inject, setUser, type Next } from '@zeltjs/core'; import { getSession } from '@zeltjs/auth-session'; type User = { id: string; name: string; email: string; roles: string[] }; @Injectable() class UserRepository { async findById(id: string): Promise { return { id, name: '', email: '', roles: [] }; } } // ---cut--- @Middleware export class SessionAuthMiddleware { constructor(private userRepo = inject(UserRepository)) {} async use(next: Next): Promise { const session = getSession() as { userId?: string } | undefined; if (session?.userId) { const user = await this.userRepo.findById(session.userId); setUser( { id: user.id, name: user.name, email: user.email }, user.roles ); } await next(); return undefined; } } ``` Register after `SessionMiddleware`: ```typescript import { createApp, Config, Controller, Get, Middleware, Injectable, inject, setUser, type Next, http } from '@zeltjs/core'; import { MemoryKVService } from '@zeltjs/kv'; import { SessionMiddleware, SessionConfig, getSession } from '@zeltjs/auth-session'; type User = { id: string; name: string; email: string; roles: string[] }; @Injectable() class UserRepository { async findById(id: string): Promise { return { id, name: '', email: '', roles: [] }; } } @Config class MySessionConfig extends SessionConfig { override readonly kvStoreNamespace = 'sessions:'; } @Middleware class SessionAuthMiddleware { constructor(private userRepo = inject(UserRepository)) {} async use(next: Next) { const session = getSession() as { userId?: string } | undefined; if (session?.userId) { const user = await this.userRepo.findById(session.userId); setUser({ id: user.id, name: user.name, email: user.email }, user.roles); } await next(); return undefined; } } @Controller('/users') class UserController { @Get('/') findAll() { return { users: [] }; } } // ---cut--- const app = createApp([http({ controllers: [UserController], middlewares: [SessionMiddleware, SessionAuthMiddleware], })], { configs: [MySessionConfig] }); ``` ## Security Considerations ### CSRF Protection Session-based authentication requires CSRF protection. Consider using: - `SameSite=Strict` cookies (strongest, may affect UX) - `SameSite=Lax` cookies with CSRF tokens for mutations - Double-submit cookie pattern ### Session Fixation Always regenerate the session ID after login: ```typescript import { Controller, Post } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import { destroySession, setSession } from '@zeltjs/auth-session'; import * as v from 'valibot'; const LoginSchema = v.object({ email: v.string(), password: v.string() }); declare function validateCredentials(email: string, password: string): Promise<{ id: string; name: string }>; // ---cut--- @Controller('/auth') class AuthController { @Post('/login') async login(req = request(LoginSchema)) { const body = await req.body(); const user = await validateCredentials(body.email, body.password); destroySession(); // Clear old session setSession({ userId: user.id, name: user.name }); // Creates new ID return { success: true }; } } ``` ### Secure Cookies In production, always use secure cookies: ```typescript import { SessionConfig } from '@zeltjs/auth-session'; declare const _: SessionConfig; // ---cut--- const cookieOptions = { httpOnly: true, secure: true, // HTTPS only sameSite: 'Strict' as const, path: '/', }; ``` --- ## Custom Authentication Build your own authentication using Zelt's built-in primitives. No package required. ## When to Use Custom Auth - API key authentication - OAuth/OIDC with your own flow - mTLS or certificate-based auth - Proprietary authentication systems - Simple prototypes ## Core Primitives | Function | Description | |----------|-------------| | `setUser(user, roles)` | Set the authenticated user in request context | | `currentUser()` | Get the current user | | `currentRoles()` | Get the current user's roles | | `@Authorized(roles?)` | Require authentication/roles on routes | These are available from `@zeltjs/core` — no additional packages needed. ## API Key Authentication Use class middleware when authentication requires database access or other injected services. ### Basic API Key Middleware ```typescript import { Middleware, Injectable, inject, request, setUser, type Next } from '@zeltjs/core'; @Injectable() class ApiKeyRepository { async findByKey(key: string): Promise<{ id: string; name: string; scopes: string[] } | null> { return null; } } // ---cut--- @Middleware export class ApiKeyAuthMiddleware { constructor(private apiKeyRepo = inject(ApiKeyRepository)) {} async use(next: Next, req = request()): Promise { const apiKey = req.header('X-API-Key'); if (apiKey) { const client = await this.apiKeyRepo.findByKey(apiKey); if (client) { setUser( { id: client.id, name: client.name, type: 'api' }, client.scopes // e.g., ['read:users', 'write:posts'] ); } } await next(); return undefined; } } ``` ### With Revocation Check and Usage Tracking ```typescript import { Middleware, Injectable, inject, request, setUser, type Next } from '@zeltjs/core'; import { HTTPException } from 'hono/http-exception'; type ApiKey = { id: string; name: string; tier: string; scopes: string[]; revokedAt?: Date }; @Injectable() class ApiKeyService { async findByKey(key: string): Promise { return null; } async updateLastUsed(key: string): Promise {} } // ---cut--- @Middleware export class ApiKeyAuthMiddleware { constructor(private apiKeyService = inject(ApiKeyService)) {} async use(next: Next, req = request()): Promise { const apiKey = req.header('X-API-Key'); if (!apiKey) { await next(); return undefined; } const client = await this.apiKeyService.findByKey(apiKey); if (!client) { throw new HTTPException(401, { message: 'Invalid API key' }); } if (client.revokedAt) { throw new HTTPException(401, { message: 'API key revoked' }); } await this.apiKeyService.updateLastUsed(apiKey); setUser( { id: client.id, name: client.name, type: 'api', tier: client.tier }, client.scopes ); await next(); return undefined; } } ``` ## Basic Authentication ```typescript import { Middleware, Injectable, inject, request, setUser, type Next } from '@zeltjs/core'; @Injectable() class UserService { async validateCredentials( username: string, password: string ): Promise<{ id: string; name: string; roles: string[] } | null> { return null; } } // ---cut--- @Middleware export class BasicAuthMiddleware { constructor(private userService = inject(UserService)) {} async use(next: Next, req = request()): Promise { const auth = req.header('Authorization'); if (auth?.startsWith('Basic ')) { const base64 = auth.slice(6); const decoded = atob(base64); const [username, password] = decoded.split(':'); const user = await this.userService.validateCredentials(username, password); if (user) { setUser({ id: user.id, name: user.name }, user.roles); } } await next(); return undefined; } } ``` ## OAuth Integration ### With an OAuth Library For OAuth integration, use `@Config` for credentials and `@Injectable` for services: ```typescript import { Config, Env, Injectable, Middleware, inject, request, setUser, type Next } from '@zeltjs/core'; @Config class OAuthConfig { constructor(private env = inject(Env)) {} get clientId() { return this.env.getString('OAUTH_CLIENT_ID'); } get clientSecret() { return this.env.getString('OAUTH_CLIENT_SECRET'); } } type User = { id: string; name: string; email: string; roles: string[] }; @Injectable() class UserRepository { async findByOAuthId(sub: string): Promise { return null; } } @Injectable() class OAuth2Service { constructor(private _config = inject(OAuthConfig)) {} async verifyAccessToken(token: string): Promise<{ sub: string }> { return { sub: '' }; } } // ---cut--- @Middleware export class OAuthMiddleware { constructor( private oauth = inject(OAuth2Service), private userRepo = inject(UserRepository) ) {} async use(next: Next, req = request()): Promise { const token = req.header('Authorization')?.replace('Bearer ', ''); if (token) { try { const tokenInfo = await this.oauth.verifyAccessToken(token); const user = await this.userRepo.findByOAuthId(tokenInfo.sub); if (user) { setUser( { id: user.id, name: user.name, email: user.email }, user.roles ); } } catch { // Invalid token — continue without user } } await next(); return undefined; } } ``` ### OAuth Callback Handler ```typescript import { Controller, Get, Injectable, inject, request } from '@zeltjs/core'; type User = { id: string; oauthId?: string; name?: string; email?: string }; @Injectable() class OAuth2Service { async exchangeCode(code: string | undefined): Promise<{ access_token: string }> { return { access_token: '' }; } async getUserInfo(token: string): Promise<{ sub: string; name: string; email: string }> { return { sub: '', name: '', email: '' }; } } @Injectable() class UserRepository { async findByOAuthId(sub: string): Promise { return null; } async create(data: { oauthId: string; name: string; email: string }): Promise { return { id: '', ...data }; } } @Injectable() class SessionService { async createSession(user: User): Promise { return ''; } } // ---cut--- @Controller('/auth') class OAuthController { constructor( private oauth = inject(OAuth2Service), private userRepo = inject(UserRepository), private sessionService = inject(SessionService) ) {} @Get('/callback') async callback(req = request()) { const code = req.queryParam('code'); const _state = req.queryParam('state'); const tokens = await this.oauth.exchangeCode(code); const userInfo = await this.oauth.getUserInfo(tokens.access_token); let user = await this.userRepo.findByOAuthId(userInfo.sub); if (!user) { user = await this.userRepo.create({ oauthId: userInfo.sub, name: userInfo.name, email: userInfo.email, }); } const token = await this.sessionService.createSession(user); return { token }; } } ``` ## Multi-Provider Authentication Support multiple auth methods in one middleware. Use the framework-provided `JwtService` from `@zeltjs/auth-jwt`: ```typescript import { Middleware, Injectable, inject, request, setUser, type Next } from '@zeltjs/core'; import { JwtService } from '@zeltjs/auth-jwt'; @Injectable() class ApiKeyRepository { async findByKey(key: string): Promise<{ id: string; scopes: string[] } | null> { return null; } } // ---cut--- @Middleware export class MultiAuthMiddleware { constructor( private apiKeyRepo = inject(ApiKeyRepository), private jwtService = inject(JwtService) ) {} async use(next: Next, req = request()): Promise { const auth = req.header('Authorization'); const apiKey = req.header('X-API-Key'); // Try API key first if (apiKey) { const client = await this.apiKeyRepo.findByKey(apiKey); if (client) { setUser({ id: client.id, type: 'api' }, client.scopes); await next(); return undefined; } } // Then try Bearer token (JWT) if (auth?.startsWith('Bearer ')) { const token = auth.slice(7); try { const payload = await this.jwtService.verify(token); setUser({ id: payload.sub, type: 'user' }, payload.roles as string[]); } catch { // Invalid token } } await next(); return undefined; } } ``` ## Request Signing (HMAC) For secure machine-to-machine communication: ```typescript import { Middleware, Injectable, inject, request, setUser, type Next } from '@zeltjs/core'; import { HTTPException } from 'hono/http-exception'; type Client = { id: string; name: string; secret: string; permissions: string[] }; @Injectable() class ClientRepository { async findById(id: string): Promise { return null; } } @Injectable() class CryptoService { async hmacSha256(secret: string, data: string): Promise { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); return Array.from(new Uint8Array(signature)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let result = 0; for (let i = 0; i < a.length; i++) { result |= a.charCodeAt(i) ^ b.charCodeAt(i); } return result === 0; } } // ---cut--- @Middleware export class HmacAuthMiddleware { constructor( private clientRepo = inject(ClientRepository), private cryptoService = inject(CryptoService) ) {} async use(next: Next, req = request()): Promise { const signature = req.header('X-Signature'); const timestamp = req.header('X-Timestamp'); const clientId = req.header('X-Client-ID'); if (!signature || !timestamp || !clientId) { await next(); return undefined; } // Check timestamp (5 minute window) const now = Date.now(); const requestTime = parseInt(timestamp, 10); if (Math.abs(now - requestTime) > 5 * 60 * 1000) { throw new HTTPException(401, { message: 'Request expired' }); } // Get client secret const client = await this.clientRepo.findById(clientId); if (!client) { throw new HTTPException(401, { message: 'Unknown client' }); } // Verify signature const body = await req.bodyRaw(); const payload = `${timestamp}.${body}`; const expected = await this.cryptoService.hmacSha256(client.secret, payload); if (!this.cryptoService.timingSafeEqual(signature, expected)) { throw new HTTPException(401, { message: 'Invalid signature' }); } setUser({ id: client.id, name: client.name }, client.permissions); await next(); return undefined; } } ``` ## Testing Custom Auth Mock the user context in tests: ```typescript import { describe, it, expect } from 'vitest'; import { onTest } from '@zeltjs/testing'; import { createApp, setUser, Controller, Get, Authorized, currentUser, Middleware, type Next, http } from '@zeltjs/core'; @Controller('/users') class UserController { @Authorized() @Get('/me') me() { return currentUser(); } } // Middleware sets user within request context — required for setUser to work @Middleware class MockAuthMiddleware { async use(next: Next): Promise { setUser({ id: '123', name: 'Test User' }, ['admin']); await next(); return undefined; } } const app = createApp([http({ controllers: [UserController], middlewares: [MockAuthMiddleware], })]); const readyApp = await app.createRuntime(); // ---cut--- describe('Protected routes', () => { it('returns user data when authenticated', async () => { const testApp = await onTest(app); const res = await testApp.http.request('/users/me'); expect(res.status).toBe(200); expect(await res.json()).toEqual({ id: '123', name: 'Test User' }); }); }); ``` ## Best Practices 1. **Fail open in middleware** — Don't throw errors for missing auth; let `@Authorized` handle access control 2. **Use constant-time comparison** — For secrets and signatures, use `timingSafeEqual` 3. **Validate timestamps** — For signed requests, reject old timestamps to prevent replay attacks 4. **Log authentication failures** — But don't log sensitive data like passwords or full tokens 5. **Separate concerns** — Middleware authenticates (who?), `@Authorized` authorizes (can they?) --- ## Roles Roles are the foundation of Zelt's authorization system. They define what a user can do. ## What is a Role? A role is a simple string that represents a permission level or capability: ```typescript const adminRoles = ['admin', 'editor', 'viewer']; const teamRoles = ['owner', 'member', 'guest']; const permissionRoles = ['read:users', 'write:users', 'delete:users']; ``` Roles are assigned during authentication via `setUser()`: ```typescript import { setUser } from '@zeltjs/core'; declare const user: { id: string; name: string }; // ---cut--- setUser( { id: user.id, name: user.name }, ['admin', 'user'] // ← roles ); ``` ## Role Types `currentRoles()` always returns `readonly string[]` — role values aren't narrowed to a specific union at the type level. Define a local type for the roles your app uses and reference it wherever you assign or compare roles, so role names stay consistent: ```typescript type Role = 'admin' | 'editor' | 'viewer'; ``` ## Role Design Patterns ### Hierarchical Roles Define roles that imply other roles: ```typescript import { setUser } from '@zeltjs/core'; declare const user: { id: string; name: string; primaryRole: 'admin' | 'editor' | 'viewer' }; // ---cut--- type Role = 'admin' | 'editor' | 'viewer'; const roleHierarchy: Record = { admin: ['admin', 'editor', 'viewer'], editor: ['editor', 'viewer'], viewer: ['viewer'], }; // When setting user, expand roles setUser(user, roleHierarchy[user.primaryRole]); ``` ### Resource-Scoped Roles Include resource context in role names: ```typescript import { setUser } from '@zeltjs/core'; declare const user: { id: string; name: string }; // ---cut--- type Role = | 'admin' | `project:${string}:owner` | `project:${string}:member` | `team:${string}:admin`; // User is owner of project-123, member of team-456 setUser(user, ['project:123:owner', 'team:456:admin']); ``` ### Permission-Based Roles Use fine-grained permission strings: ```typescript type Permission = | 'read:users' | 'write:users' | 'delete:users' | 'read:posts' | 'write:posts'; // Roles map to permissions const rolePermissions: Record = { admin: ['read:users', 'write:users', 'delete:users', 'read:posts', 'write:posts'], editor: ['read:users', 'read:posts', 'write:posts'], viewer: ['read:users', 'read:posts'], }; ``` ## Where Roles Come From ### Database Store roles with the user record: ```typescript import { Middleware, Injectable, inject, setUser, type RequestContext, type Next } from '@zeltjs/core'; import { JwtService } from '@zeltjs/auth-jwt'; type User = { id: string; name: string; roles: string[] }; @Injectable() class UserRepository { async findById(id: string): Promise { return { id, name: '', roles: [] }; } } // ---cut--- @Middleware class AuthMiddleware { constructor( private jwtService = inject(JwtService), private userRepo = inject(UserRepository) ) {} async use(c: RequestContext, next: Next): Promise { const token = c.req.header('Authorization')?.replace('Bearer ', ''); if (token) { const payload = await this.jwtService.verify(token); const user = await this.userRepo.findById(payload.sub!); setUser({ id: user.id, name: user.name }, user.roles); } await next(); return undefined; } } ``` ### JWT Claims Include roles in the JWT payload: ```typescript import { Controller, Post, Config, inject } from '@zeltjs/core'; import { JwtService, JwtConfig, type JwtPayload, type ResolveUserResult } from '@zeltjs/auth-jwt'; type User = { id: string; roles: string[] }; // ---cut--- @Controller('/auth') class AuthController { constructor(private jwtService = inject(JwtService)) {} @Post('/login') async login(user: User) { const token = await this.jwtService.sign({ sub: user.id, roles: user.roles, }); return { token }; } } @Config class MyJwtConfig extends JwtConfig { override get resolveUser(): (payload: JwtPayload) => Promise { return async (payload) => ({ user: { id: payload.sub! }, roles: payload.roles as string[], }); } } ``` ### Session Data Store roles in the session: ```typescript import { Middleware, Injectable, inject, setUser, type RequestContext, type Next } from '@zeltjs/core'; type Session = { userId: string; roles: string[] }; type User = { id: string; name: string }; @Injectable() class SessionService { getSession(): Session | null { return null; } setSession(_data: Session): void {} } @Injectable() class UserRepository { async findById(id: string): Promise { return { id, name: '' }; } } // ---cut--- @Middleware class SessionAuthMiddleware { constructor( private sessionService = inject(SessionService), private userRepo = inject(UserRepository) ) {} async use(c: RequestContext, next: Next): Promise { const session = this.sessionService.getSession(); if (session) { const user = await this.userRepo.findById(session.userId); setUser(user, session.roles); } await next(); return undefined; } } ``` ### External Service Fetch roles from an identity provider: ```typescript import { Middleware, Injectable, inject, setUser, type RequestContext, type Next } from '@zeltjs/core'; type UserInfo = { sub: string; name: string }; @Injectable() class IdentityProviderService { async getUserInfo(token: string): Promise { return { sub: '', name: '' }; } async getRoles(sub: string): Promise { return []; } } // ---cut--- @Middleware class ExternalAuthMiddleware { constructor(private idp = inject(IdentityProviderService)) {} async use(c: RequestContext, next: Next): Promise { const token = c.req.header('Authorization')?.replace('Bearer ', ''); if (token) { const userInfo = await this.idp.getUserInfo(token); const roles = await this.idp.getRoles(userInfo.sub); setUser({ id: userInfo.sub, name: userInfo.name }, roles); } await next(); return undefined; } } ``` ## Role Assignment Strategies ### Static Assignment Roles are set once and rarely change: ```typescript import { Controller, Authorized, Post, Injectable, inject } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import * as v from 'valibot'; const RolesSchema = v.object({ roles: v.array(v.string()) }); @Injectable() class UserRepository { async updateRoles(id: string, roles: string[]): Promise {} } // ---cut--- @Controller('/users') class UserRolesController { constructor(private userRepo = inject(UserRepository)) {} @Authorized(['admin']) @Post('/:id/roles') async assignRoles(req = request(RolesSchema)) { const id = req.pathParam('id'); const data = await req.body(); await this.userRepo.updateRoles(id, data.roles); return { success: true }; } } ``` ### Dynamic Assignment Roles are computed based on context: ```typescript import { Middleware, Injectable, inject, setUser, currentUser, type RequestContext, type Next } from '@zeltjs/core'; type Project = { ownerId: string; memberIds: string[] }; type User = { id: string; name: string }; @Injectable() class ProjectRepository { async findById(id: string): Promise { return { ownerId: '', memberIds: [] }; } } // ---cut--- @Middleware class ProjectRolesMiddleware { constructor(private projectRepo = inject(ProjectRepository)) {} async use(c: RequestContext, next: Next): Promise { const user = currentUser() as User | undefined; const projectId = c.req.param('projectId'); if (user && projectId) { const project = await this.projectRepo.findById(projectId); const roles: string[] = []; if (project.ownerId === user.id) { roles.push('project:owner'); } if (project.memberIds.includes(user.id)) { roles.push('project:member'); } setUser(user, roles); } await next(); return undefined; } } ``` ### Time-Based Roles Roles expire or activate based on time: ```typescript import { setUser } from '@zeltjs/core'; declare const user: { id: string; name: string; roles: string[]; roleGrants: Array<{ role: string; startsAt?: number; expiresAt?: number }>; }; // ---cut--- const roles = user.roles.filter(role => { const grant = user.roleGrants.find(g => g.role === role); if (!grant) return true; const now = Date.now(); if (grant.startsAt && now < grant.startsAt) return false; if (grant.expiresAt && now > grant.expiresAt) return false; return true; }); setUser(user, roles); ``` ## Accessing Roles ### In Handlers ```typescript import { Controller, currentRoles, Get } from '@zeltjs/core'; // ---cut--- @Controller('/app') class AppController { @Get('/dashboard') dashboard() { const roles = currentRoles(); return { canManageUsers: roles.includes('admin'), canEditContent: roles.includes('editor') || roles.includes('admin'), }; } } ``` ### In Services ```typescript import { currentRoles, currentUser } from '@zeltjs/core'; type Post = { authorId: string }; interface User { id: string; } // ---cut--- class PostService { canDelete(post: Post): boolean { const roles = currentRoles(); const user = currentUser() as User | undefined; if (roles.includes('admin')) return true; if (post.authorId === user?.id) return true; return false; } } ``` ## Best Practices ### Keep Roles Simple Use flat strings, not nested objects: ```typescript // ✅ Good const goodRoles = ['admin', 'editor', 'viewer']; // ❌ Avoid const badRoles = [{ name: 'admin', level: 10, permissions: [] }]; ``` ### Use Roles for Coarse Access Roles answer "can this user access this feature area?" not "can this user edit this specific record?": ```typescript import { Controller, Authorized, Get } from '@zeltjs/core'; // ---cut--- // ✅ Role-based: "Can access admin section" @Controller('/admin') class AdminController { @Authorized(['admin']) @Get('/dashboard') adminDashboard() {} } // ❌ Not a role: "Can edit post #123" // → Handle in service logic instead ``` ### Avoid Role Explosion Don't create roles for every action: ```typescript // ❌ Too many roles const tooManyRoles = ['can_view_users', 'can_create_users', 'can_edit_users', 'can_delete_users']; // ✅ Group into meaningful roles const meaningfulRoles = ['admin', 'user_manager', 'viewer']; ``` ### Document Your Roles Maintain a central reference: ```typescript /** * Application Roles * * - admin: Full system access * - editor: Can create and modify content * - viewer: Read-only access * - moderator: Can manage user-generated content */ type Role = 'admin' | 'editor' | 'viewer' | 'moderator'; ``` --- ## Access Control The `@Authorized` decorator enforces authentication and role requirements on routes. ## Basic Usage ### Require Authentication Use `@Authorized()` without arguments to require any authenticated user: ```typescript import { Controller, Get, Authorized } from '@zeltjs/core'; // ---cut--- @Controller('/dashboard') class DashboardController { @Authorized() @Get('/') index() { return { stats: [] }; } } ``` If no user is set, returns `401 Unauthorized`: ```json { "code": "UNAUTHORIZED", "message": "Authentication required" } ``` ### Require Specific Roles Pass role names to restrict access: ```typescript import { Controller, Get, Authorized } from '@zeltjs/core'; // ---cut--- @Controller('/admin') class AdminController { @Authorized(['admin']) @Get('/users') listUsers() { return { users: [] }; } } ``` If the user lacks required roles, returns `403 Forbidden`: ```json { "code": "FORBIDDEN", "message": "Insufficient permissions" } ``` ## Role Matching ### OR Logic (Any Role) By default, access is granted if the user has **any** of the specified roles: ```typescript import { Controller, Authorized, Delete } from '@zeltjs/core'; // ---cut--- @Controller('/admin') class AdminController { @Authorized(['admin', 'moderator']) @Delete('/posts/:id') removePost() { // User needs 'admin' OR 'moderator' } } ``` ### AND Logic (All Roles) For AND logic, use multiple `@Authorized` decorators: ```typescript import { Controller, Authorized, Get } from '@zeltjs/core'; // ---cut--- @Controller('/content') class ContentController { @Authorized(['verified']) @Authorized(['premium']) @Get('/exclusive-content') exclusiveContent() { // User needs 'verified' AND 'premium' } } ``` Or check in the handler: ```typescript import { Controller, Authorized, Get, currentRoles } from '@zeltjs/core'; import { HTTPException } from 'hono/http-exception'; // ---cut--- @Controller('/content') class ContentController { @Authorized() @Get('/exclusive-content') exclusiveContent(roles = currentRoles()) { if (!roles.includes('verified') || !roles.includes('premium')) { throw new HTTPException(403, { message: 'Premium verified users only' }); } return { content: '...' }; } } ``` ## Decorator Placement ### Method Level Apply to specific routes: ```typescript import { Controller, Get, Post, Delete, Authorized } from '@zeltjs/core'; // ---cut--- @Controller('/posts') class PostController { @Get('/') list() { // Public — no auth required } @Authorized() @Post('/') create() { // Requires authentication } @Authorized(['admin']) @Delete('/:id') delete() { // Requires admin role } } ``` ### With Other Decorators `@Authorized` works with other method decorators: ```typescript import { Controller, Authorized, Post } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import { RateLimit } from '@zeltjs/rate-limit'; import * as v from 'valibot'; const CreatePostSchema = v.object({ title: v.string(), content: v.string() }); // ---cut--- @Controller('/api') class ApiController { @Authorized() @RateLimit({ limit: 100, windowSec: 60, key: 'posts' }) @Post('/posts') async create(req = request(CreatePostSchema)) { const data = await req.body(); return { created: true }; } } ``` ## Error Responses | Status | Code | Condition | |--------|------|-----------| | 401 | `UNAUTHORIZED` | No user set (not authenticated) | | 403 | `FORBIDDEN` | User lacks required roles | ### Customizing Error Messages Handle authorization errors in your error handler: ```typescript import { createApp, Controller, Get, Authorized, HTTPException, type RequestContext, http } from '@zeltjs/core'; @Controller('/dashboard') class DashboardController { @Authorized() @Get('/') index() { return { stats: [] }; } } @Controller('/admin') class AdminController { @Authorized(['admin']) @Get('/users') listUsers() { return { users: [] }; } } // ---cut--- const app = createApp([http({ controllers: [DashboardController, AdminController], // @ts-expect-error shorthand error handler example onError: (error: Error, c: RequestContext) => { if (error instanceof HTTPException) { if (error.status === 401) { return c.json({ error: 'Please log in to continue', loginUrl: '/auth/login', }, 401); } if (error.status === 403) { return c.json({ error: 'You do not have permission to access this resource', requiredRoles: error.message, }, 403); } } throw error; }, })]); ``` ## Common Patterns ### Public Routes with Optional Auth Don't use `@Authorized` — check the user manually: ```typescript import { Controller, Get, Injectable, inject, request, currentUser } from '@zeltjs/core'; type Post = { authorId: string }; type User = { id: string }; @Injectable() class PostRepository { async findById(id: string): Promise { return { authorId: '' }; } } // ---cut--- @Controller('/posts') class PostController { constructor(private postRepo = inject(PostRepository)) {} @Get('/:id') async getPost(req = request()) { const id = req.pathParam('id'); const user = currentUser() as User | undefined; const post = await this.postRepo.findById(id); return { ...post, canEdit: user?.id === post.authorId, }; } } ``` ### Owner-Only Access Combine `@Authorized` with ownership checks: ```typescript import { Controller, Authorized, Put, Injectable, inject, currentUser, currentRoles } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import { HTTPException } from 'hono/http-exception'; import * as v from 'valibot'; const UpdateSchema = v.object({ title: v.string(), content: v.string() }); type Post = { authorId: string }; type User = { id: string }; @Injectable() class PostRepository { async findById(id: string): Promise { return { authorId: '' }; } async update(id: string, _data: unknown): Promise { return { authorId: '' }; } } // ---cut--- @Controller('/posts') class PostController { constructor(private postRepo = inject(PostRepository)) {} @Authorized() @Put('/:id') async updatePost(req = request(UpdateSchema)) { const id = req.pathParam('id'); const data = await req.body(); const user = currentUser() as User; const post = await this.postRepo.findById(id); if (post.authorId !== user.id && !currentRoles().includes('admin')) { throw new HTTPException(403, { message: 'Not your post' }); } return this.postRepo.update(id, data); } } ``` ### Role Hierarchy Check for any role in a hierarchy: ```typescript import { Controller, Authorized, Put, currentRoles } from '@zeltjs/core'; import { HTTPException } from 'hono/http-exception'; // ---cut--- const isEditor = (roles: readonly string[]) => roles.some(r => ['admin', 'editor'].includes(r)); @Controller('/posts') class PostController { @Authorized() @Put('/:id') updatePost(roles = currentRoles()) { if (!isEditor(roles)) { throw new HTTPException(403, { message: 'Editors only' }); } // ... } } ``` ### Resource-Scoped Authorization For complex scenarios, move logic to a service: ```typescript import { Controller, Delete, Authorized, Injectable, inject, request, currentUser, currentRoles } from '@zeltjs/core'; import { HTTPException } from 'hono/http-exception'; type Post = { isPublic: boolean; authorId: string }; type User = { id: string }; @Injectable() class PostRepository { async findById(id: string): Promise { return { isPublic: false, authorId: '' }; } async delete(_id: string): Promise {} } // ---cut--- @Injectable() class PostAuthorizationService { canView(post: Post): boolean { if (post.isPublic) return true; const user = currentUser() as User | undefined; return user?.id === post.authorId; } canEdit(post: Post): boolean { const user = currentUser() as User | undefined; const roles = currentRoles(); if (roles.includes('admin')) return true; return user?.id === post.authorId; } canDelete(): boolean { const roles = currentRoles(); return roles.includes('admin'); } } @Controller('/posts') class PostController { constructor( private postRepo = inject(PostRepository), private authService = inject(PostAuthorizationService) ) {} @Authorized() @Delete('/:id') async delete(req = request()) { const id = req.pathParam('id'); const post = await this.postRepo.findById(id); if (!this.authService.canDelete()) { throw new HTTPException(403, { message: 'Cannot delete this post' }); } await this.postRepo.delete(id); return { deleted: true }; } } ``` ## Testing Protected Routes ### Without Authentication ```typescript import { it, expect } from 'vitest'; import { createApp, Controller, Get, Authorized, http } from '@zeltjs/core'; @Controller('/dashboard') class DashboardController { @Authorized() @Get('/') index() { return { stats: [] }; } } const app = createApp([http({ controllers: [DashboardController] })]); const readyApp = await app.createRuntime(); // ---cut--- it('returns 401 for unauthenticated requests', async () => { const res = await readyApp.http.request('/dashboard'); expect(res.status).toBe(401); }); ``` ### With Authentication Use a middleware to inject the user within request context — `setUser()` must be called during request handling, not in test setup: ```typescript import { it, expect } from 'vitest'; import { createApp, Controller, Get, Authorized, Middleware, request, setUser, type Next, http } from '@zeltjs/core'; @Middleware class MockAuthMiddleware { async use(next: Next, req = request()): Promise { if (req.header('X-Test-User')) { setUser({ id: '123', name: 'Test' }, ['user']); } await next(); return undefined; } } @Controller('/dashboard') class DashboardController { @Authorized() @Get('/') index() { return { stats: [] }; } } const app = createApp([http({ controllers: [DashboardController], middlewares: [MockAuthMiddleware] })]); const readyApp = await app.createRuntime(); // ---cut--- it('returns data for authenticated users', async () => { const res = await readyApp.http.request('/dashboard', { headers: { 'X-Test-User': 'true' } }); expect(res.status).toBe(200); }); ``` ### Testing Role Requirements ```typescript import { it, expect } from 'vitest'; import { createApp, Controller, Get, Authorized, Middleware, request, setUser, type Next, http } from '@zeltjs/core'; @Middleware class MockRoleMiddleware { async use(next: Next, req = request()): Promise { const role = req.header('X-Test-Role'); if (role) { setUser({ id: '123', name: 'Test' }, [role]); } await next(); return undefined; } } @Controller('/admin') class AdminController { @Authorized(['admin']) @Get('/users') listUsers() { return { users: [] }; } } const app = createApp([http({ controllers: [AdminController], middlewares: [MockRoleMiddleware] })]); const readyApp = await app.createRuntime(); // ---cut--- it('returns 403 for non-admin users', async () => { const res = await readyApp.http.request('/admin/users', { headers: { 'X-Test-Role': 'user' } }); expect(res.status).toBe(403); }); it('allows admin access', async () => { const res = await readyApp.http.request('/admin/users', { headers: { 'X-Test-Role': 'admin' } }); expect(res.status).toBe(200); }); ``` ## Best Practices 1. **Use `@Authorized()` for protected routes** — Don't manually check `currentUser()` for basic auth requirements 2. **Keep role checks coarse** — Use `@Authorized` for feature-level access, services for resource-level logic 3. **Fail closed** — When in doubt, deny access; it's easier to grant than revoke 4. **Log authorization failures** — Track failed access attempts for security monitoring 5. **Test both paths** — Always test authenticated and unauthenticated scenarios --- ## Dependency Injection :::info Coming Soon Detailed dependency injection documentation is under development. ::: Zelt uses [needle-di](https://github.com/nicosommi/needle-di) under the hood for dependency injection, providing a lightweight and type-safe DI container. ## Quick Overview ```typescript import { Injectable, inject } from '@zeltjs/core'; // ---cut--- @Injectable() export class DatabaseService { query(sql: string) { // ... } } @Injectable() export class UserRepository { constructor(private db = inject(DatabaseService)) {} findAll() { return this.db.query('SELECT * FROM users'); } } ``` See the [Services](./services) documentation for practical usage patterns. ## Decorator Composition Zelt provides utilities to combine multiple decorators into a single meta-decorator. This is useful for creating reusable custom decorators that bundle related functionality. ### `composeClassDecorators` Combines multiple class decorators into one. ```typescript import { Controller } from '@zeltjs/core'; import { createClassDecorator, composeClassDecorators } from '@zeltjs/decorator-metadata'; // ---cut--- const GraphqlController = (path: string) => composeClassDecorators( Controller(path), createClassDecorator({ decorator: 'GraphqlController' }) ); @GraphqlController('/api') class UserResolver {} ``` ### `composeMethodDecorators` Combines multiple method decorators into one. ```typescript import { createClassDecorator, createMethodDecorator, composeMethodDecorators, } from '@zeltjs/decorator-metadata'; // ---cut--- const Controller = () => createClassDecorator({}); const Route = (method: string, path: string) => createMethodDecorator({ decorator: 'Route', method, path }); const Query = (path: string) => composeMethodDecorators( Route('GET', path), createMethodDecorator({ decorator: 'Query' }) ); @Controller() class TestController { @Query('/users') getUsers() {} } ``` ### `composePropertyDecorators` Combines multiple property decorators into one. ```typescript import { createClassDecorator, createPropertyDecorator, composePropertyDecorators, } from '@zeltjs/decorator-metadata'; // ---cut--- const Entity = () => createClassDecorator({}); const Column = (opts?: { nullable?: boolean }) => createPropertyDecorator({ decorator: 'Column', nullable: opts?.nullable ?? false }); const Searchable = () => createPropertyDecorator({ decorator: 'Searchable' }); const SearchableColumn = (opts?: { nullable?: boolean }) => composePropertyDecorators(Column(opts), Searchable()); @Entity() class User { @SearchableColumn() name!: string; } ``` ### How It Works - **Props are merged**: Each decorator's props are appended to the metadata in order - **Trace points to usage**: The source position trace points to where the composed decorator is applied (the class definition for `composeClassDecorators`, the method for `composeMethodDecorators`, or the property for `composePropertyDecorators`), not where the decorator factory was defined - **Use case**: Create custom meta-decorators that bundle multiple decorators for cleaner, more semantic code --- ## Services Services are classes that handle **business logic** and can be **injected** into controllers or other services. This separation of concerns makes your code more testable and maintainable. ## Defining Services A service is a class decorated with `@Injectable()`: ```typescript import { Injectable } from '@zeltjs/core'; @Injectable() export class UserService { private users = new Map(); findAll() { return Array.from(this.users.values()); } findOne(id: string) { return this.users.get(id); } create(name: string) { const id = crypto.randomUUID(); const user = { id, name }; this.users.set(id, user); return user; } } ``` ## Dependency Injection Use `inject()` to inject services into controllers: ```typescript import { Controller, Get, Post, inject, Injectable } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import * as v from 'valibot'; @Injectable() class UserService { private users = new Map(); findAll() { return Array.from(this.users.values()); } findOne(id: string) { return this.users.get(id); } create(name: string) { const id = crypto.randomUUID(); const user = { id, name }; this.users.set(id, user); return user; } } const CreateUserBody = v.object({ name: v.string() }); // ---cut--- @Controller('/users') export class UserController { constructor(private userService = inject(UserService)) {} @Get('/') findAll() { return { users: this.userService.findAll() }; } @Get('/:id') findOne(req = request()) { const id = req.pathParam('id'); const user = this.userService.findOne(id); if (!user) { throw new Error('User not found'); } return user; } @Post('/') async create(req = request(CreateUserBody)) { const body = await req.body(); return this.userService.create(body.name); } } ``` ## Service-to-Service Injection Services can inject other services: ```typescript import { Injectable, inject } from '@zeltjs/core'; @Injectable() class DatabaseService { query(sql: string) { return Promise.resolve([]); } } @Injectable() class LoggerService { log(msg: string) { console.log(msg); } } // ---cut--- @Injectable() export class UserService { constructor( private db = inject(DatabaseService), private logger = inject(LoggerService) ) {} async findAll() { this.logger.log('Finding all users'); return this.db.query('SELECT * FROM users'); } } ``` ## Singleton Scope By default, services are **singletons** — the same instance is shared across all injections within the application lifecycle. This is ideal for: - Database connections - Configuration services - Caching services ```typescript import { Injectable, Env, inject } from '@zeltjs/core'; @Injectable() export class ConfigService { constructor(private env = inject(Env)) {} get databaseUrl() { return this.env.getString('DATABASE_URL'); } get apiKey() { return this.env.getString('API_KEY'); } } ``` :::tip For configuration, prefer using `@Config` classes with `inject()`. See [Configuration](./configuration.md) for details. ::: ## Testing with Mock Services The singleton pattern makes testing straightforward — you can provide mock implementations: ```typescript import { describe, it, expect } from 'vitest'; import { Controller, Get, inject, Injectable } from '@zeltjs/core'; import { createTestTarget } from '@zeltjs/testing'; @Injectable() class UserService { findAll(): { id: string; name: string }[] { return []; } } @Controller('/users') class UserController { constructor(private userService = inject(UserService)) {} @Get('/') findAll() { return { users: this.userService.findAll() }; } } // ---cut--- describe('UserController', () => { it('should return all users', async () => { const mockUsers = [{ id: '1', name: 'John' }]; const { target } = await createTestTarget(UserController, { overrides: [{ provide: UserService, useValue: { findAll: () => mockUsers } as UserService }], }); const result = target.findAll(); expect(result).toEqual({ users: mockUsers }); }); }); ``` ## Best Practices 1. **Single Responsibility** — Each service should have one clear purpose 2. **Interface Segregation** — Keep service methods focused and cohesive 3. **Dependency Injection** — Always inject dependencies rather than creating them directly 4. **Testability** — Design services to be easily mockable in tests --- ## Configuration Zelt provides a type-safe configuration system using the `@Config` decorator and `inject()` helper. ## Defining Configuration Use the `@Config` decorator to define a configuration class: ```typescript import { Config, Env, inject } from '@zeltjs/core'; @Config export class DatabaseConfig { constructor(private env = inject(Env)) {} get host() { return this.env.getString('DATABASE_HOST', 'localhost'); } get port() { return this.env.getNumber('DATABASE_PORT', 5432); } get connectionString() { return `postgres://${this.host}:${this.port}/mydb`; } } ``` ## Using Configuration Inject configuration into services or controllers using `inject()`: ```typescript import { Injectable, inject, Config, Env } from '@zeltjs/core'; @Config class DatabaseConfig { constructor(private env = inject(Env)) {} get host() { return this.env.getString('DATABASE_HOST', 'localhost'); } get port() { return this.env.getNumber('DATABASE_PORT', 5432); } get connectionString() { return `postgres://${this.host}:${this.port}/mydb`; } } // ---cut--- @Injectable() export class DatabaseService { constructor(private config = inject(DatabaseConfig)) {} connect() { return this.config.connectionString; } } ``` ## Registering Configuration Register config classes when creating the app: ```typescript import { createApp, Config, Env, inject, Controller, Get, http } from '@zeltjs/core'; @Config class DatabaseConfig { constructor(private env = inject(Env)) {} get host() { return this.env.getString('DATABASE_HOST', 'localhost'); } get port() { return this.env.getNumber('DATABASE_PORT', 5432); } get connectionString() { return `postgres://${this.host}:${this.port}/mydb`; } } @Controller('/') class AppController { @Get('/') index() { return { ok: true }; } } // ---cut--- const app = createApp([http({ controllers: [AppController], })], { configs: [DatabaseConfig] }); ``` ## Overriding Configuration Override configuration values for testing by extending the config class: ```typescript import { Config, createApp, Env, inject, http } from '@zeltjs/core'; declare class AppController {} @Config class DatabaseConfig { constructor(private env = inject(Env)) {} get host() { return this.env.getString('DATABASE_HOST', 'localhost'); } get port() { return this.env.getNumber('DATABASE_PORT', 5432); } get connectionString() { return `postgres://${this.host}:${this.port}/mydb`; } } // ---cut--- @Config export class TestDatabaseConfig extends DatabaseConfig { override get host() { return 'test-db'; } override get port() { return 5433; } } // In test setup const app = createApp([http({ controllers: [AppController], })], { configs: [TestDatabaseConfig] }); ``` `inject(DatabaseConfig)` resolves to the overriding `TestDatabaseConfig` instance because the subclass is registered for the parent's class token. ## Abstract Configuration Use `@Config({ abstract: true })` to declare a config base class with no default implementation. This is useful for a config contract that every environment must supply a concrete value for — there is no sensible fallback: ```typescript import { Config, createApp } from '@zeltjs/core'; @Config({ abstract: true }) export abstract class PaymentGatewayConfig { abstract get apiKey(): string; } export class StripeConfig extends PaymentGatewayConfig { override get apiKey() { return 'sk_test_...'; } } // ---cut--- const app = createApp([], { configs: [StripeConfig] }); ``` If an abstract config is registered without a concrete subclass resolving it — whether left unregistered, passed directly in `configs`, or only resolved by an abstract subclass — `createRuntime()` (or the first `inject()` of the token) throws a `ZeltAppConfigurationError` with reason `abstract_leaf_without_concrete`. ### Fallback Configuration `createRuntime({ fallbackConfigs })` registers config subclasses that apply only when nothing else resolves the base config. Resolution priority, from highest to lowest: 1. `createRuntime({ configs })` — runtime override 2. `createApp([...], { configs })` — user-provided 3. `createRuntime({ fallbackConfigs })` — fallback 4. The base config class's own default getter value `fallbackConfigs` is commonly used to satisfy an abstract config with a development-only default while requiring production code to pass a concrete `configs` entry explicitly: ```typescript import { Config, createApp } from '@zeltjs/core'; @Config({ abstract: true }) abstract class PaymentGatewayConfig { abstract get apiKey(): string; } class DevPaymentGatewayConfig extends PaymentGatewayConfig { override get apiKey() { return 'sk_test_dev'; } } // ---cut--- const app = createApp([]); const readyApp = await app.createRuntime({ fallbackConfigs: [DevPaymentGatewayConfig], }); ``` ## Environment-Based Configuration `inject(Env)` reads environment variables from the platform-specific source registered by the adapter. No additional setup is needed for the common case. ### Node.js Environment When using `onNode()`, `ProcessEnvAdaptor` is registered automatically, so `inject(Env)` reads from `process.env` without any extra config: ```typescript import { Config, Env, inject, createApp, Controller, Get, http } from '@zeltjs/core'; @Controller('/') class AppController { @Get('/') index() { return { ok: true }; } } // ---cut--- @Config export class DatabaseConfig { constructor(private env = inject(Env)) {} get host() { return this.env.getString('DATABASE_HOST', 'localhost'); } get port() { return this.env.getNumber('DATABASE_PORT', 5432); } get connectionString() { return `postgres://${this.host}:${this.port}/mydb`; } } const app = createApp([http({ controllers: [AppController], })], { configs: [DatabaseConfig] }); ``` ### Loading `.env` Files To load a `.env` file, import `dotenv/config` at the entry point of your application before anything else: ```typescript // @errors: 2882 import 'dotenv/config'; import { onNode } from '@zeltjs/adapter-node'; // ...rest of app setup ``` `inject(Env)` will then read the variables populated by dotenv from `process.env`. ### Cloudflare Workers Environment For Cloudflare Workers, environment configuration is handled automatically by `onCloudflareWorkers()`. See the [Cloudflare Workers Getting Started guide](./getting-started/cloudflare-workers) for details. ## TypeScript Decorator Configuration Zelt supports both TC39 standard decorators and legacy TypeScript decorators. The framework automatically detects which mode is being used at runtime. ### TC39 Standard Decorators (Recommended) For new projects, use TC39 standard decorators. No special TypeScript configuration is needed: ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext" } } ``` ### Legacy Decorators For compatibility with existing codebases, enable legacy decorators: ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "experimentalDecorators": true } } ``` ### Detection Behavior Zelt automatically detects the decorator mode based on the runtime context: - **TC39 mode**: Decorator receives a context object with `kind`, `name`, and `metadata` properties - **Legacy mode**: Decorator receives `target`, `propertyKey`, and `descriptor` arguments Both modes work identically from an API perspective—you don't need to change your code when switching between them. --- ## Error Handling Zelt provides a simple error handling mechanism based on Hono's `HTTPException`. ## Error Response Format All errors are returned in a consistent JSON format: ```json { "code": "ERROR_CODE", "message": "Error description" } ``` ## Built-in Error Types ### VALIDATION_FAILED Returned when request body validation fails (status 400): ```json { "code": "VALIDATION_FAILED", "issues": [ { "kind": "validation", "type": "email", "message": "Invalid email", "path": ["email"] } ] } ``` ### INTERNAL_ERROR Returned when an unhandled error occurs (status 500): ```json { "code": "INTERNAL_ERROR", "message": "internal server error" } ``` In development mode (`NODE_ENV=development`), the actual error message is included for debugging. ## Throwing HTTPExceptions Use Hono's `HTTPException` to throw HTTP errors by specifying a status code and either a message or a custom response. ### Custom Message For basic text responses, just set the error `message`: ```typescript import { HTTPException } from '@zeltjs/core'; throw new HTTPException(401, { message: 'Unauthorized' }); ``` ### Custom Response For JSON responses, or to set response headers, use the `res` option. ```typescript import { HTTPException } from '@zeltjs/core'; const errorResponse = Response.json( { code: 'USER_NOT_FOUND', message: 'User not found' }, { status: 404 } ); throw new HTTPException(404, { res: errorResponse }); ``` With custom headers: ```typescript import { HTTPException } from '@zeltjs/core'; // ---cut--- const errorResponse = new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': 'Bearer error="invalid_token"', }, }); throw new HTTPException(401, { res: errorResponse }); ``` ### Cause Use the `cause` option to attach the original error for debugging: ```typescript import { Middleware, HTTPException, type RequestContext, type Next } from '@zeltjs/core'; async function authorize(c: RequestContext): Promise { const token = c.req.header('Authorization'); if (!token) throw new Error('No token'); } @Middleware class AuthMiddleware { // ---cut--- async use(c: RequestContext, next: Next) { try { await authorize(c); } catch (cause) { throw new HTTPException(401, { message: 'Authorization failed', cause }); } await next(); return undefined; } } ``` ## Custom Error Codes Define reusable error responses to maintain consistency across your API: ```typescript import { HTTPException } from '@zeltjs/core'; const notFoundResponse = Response.json( { code: 'USER_NOT_FOUND', message: 'User not found' }, { status: 404 } ); const forbiddenResponse = Response.json( { code: 'FORBIDDEN', message: 'Access denied' }, { status: 403 } ); // Usage throw new HTTPException(404, { res: notFoundResponse }); throw new HTTPException(403, { res: forbiddenResponse }); ``` Or create a factory function: ```typescript import { HTTPException } from '@zeltjs/core'; // ---cut--- const createErrorResponse = ( status: number, code: string, message: string ): Response => { return Response.json({ code, message }, { status }); }; // Usage const response = createErrorResponse(404, 'USER_NOT_FOUND', 'User not found'); throw new HTTPException(404, { res: response }); ``` ## Error Types for OpenAPI Use the built-in error types to document error responses in your OpenAPI spec: ```typescript import type { ErrorBody, ValidationErrorBody } from '@zeltjs/core'; ``` These types define the structure of error responses: - `ErrorBody` — Union of all error types (VALIDATION_FAILED | INTERNAL_ERROR) - `ValidationErrorBody` — Only the validation error type ## Error Handling Flow ```mermaid flowchart TD REQ["Request"] --> MW["Middleware chain"] MW --> RH["Route handler"] RH -- "throws HTTPException" --> GETRESP["HTTPException.getResponse()"] GETRESP --> CUSTOM["Custom error response"] RH -- "throws Error" --> HANDLE["handleError()"] HANDLE --> ERR500["500 INTERNAL_ERROR"] RH --> SUCCESS["Success response"] ``` ## Custom Error Handlers For more complex error handling logic, use the `@ErrorHandler` decorator to create reusable error handler classes. ### Creating an Error Handler ```typescript import { ErrorHandler, RequestContext } from '@zeltjs/core'; @ErrorHandler class DatabaseErrorHandler { onError(error: Error, c: RequestContext): Response | undefined { if (error.name === 'PrismaClientKnownRequestError') { return Response.json( { code: 'DATABASE_ERROR', message: 'Database operation failed' }, { status: 409 } ); } return undefined; } } ``` The `onError` method receives: - `error` — The thrown error - `c` — The Hono request context Return a `Response` to handle the error, or `undefined` to pass it to the next handler. ### Registering Error Handlers Pass error handlers to the `http(...)` feature with `controllers` via the `errorHandlers` option: ```typescript import { createApp, Controller, Get, ErrorHandler, RequestContext, http } from '@zeltjs/core'; @Controller('/users') class UserController { @Get('/') findAll() { return { users: [] }; } } @ErrorHandler class DatabaseErrorHandler { onError(error: Error, c: RequestContext) { return undefined; } } @ErrorHandler class ValidationErrorHandler { onError(error: Error, c: RequestContext) { return undefined; } } // ---cut--- const app = createApp([http({ controllers: [UserController], errorHandlers: [DatabaseErrorHandler, ValidationErrorHandler], })]); ``` ### Handler Chain Error handlers execute in the order they are registered in `http({ errorHandlers: [...] })`: 1. First handler's `onError` is called 2. If it returns `undefined`, the next handler is called 3. If all handlers return `undefined`, the default error handler runs ```typescript import { createApp, Controller, Get, ErrorHandler, RequestContext, http } from '@zeltjs/core'; class CustomError extends Error {} @Controller('/') class MyController { @Get('/') index() { return { ok: true }; } } // ---cut--- @ErrorHandler class FirstHandler { onError(error: Error, c: RequestContext) { if (error instanceof CustomError) { return Response.json({ code: 'CUSTOM' }, { status: 400 }); } return undefined; } } @ErrorHandler class FallbackHandler { onError(error: Error, c: RequestContext) { console.error('Unhandled error:', error); return undefined; } } createApp([http({ controllers: [MyController], errorHandlers: [FirstHandler, FallbackHandler], })]); ``` ### Dependency Injection Error handlers support dependency injection. Use constructor injection to access services: ```typescript import { ErrorHandler, RequestContext, inject } from '@zeltjs/core'; declare class LoggerService { error(msg: string, ctx: object): void; } // ---cut--- @ErrorHandler class LoggingErrorHandler { constructor(private logger = inject(LoggerService)) {} onError(error: Error, c: RequestContext) { this.logger.error('Request failed', { error, path: c.req.path }); return undefined; } } ``` ## Framework Error Classes Zelt provides structured error classes for framework-level errors. These classes follow a consistent naming convention (`Zelt*Error`) and include typed context for debugging: | Error Class | Description | |------------|-------------| | `ZeltDecoratorUsageError` | Invalid decorator usage (e.g., applied to static method) | | `ZeltLifecycleStateError` | Invalid lifecycle state (e.g., calling method after shutdown) | | `ZeltContextNotAvailableError` | Primitive called outside execution context | | `ZeltAppConfigurationError` | Invalid app configuration | | `ZeltRouteConfigurationError` | Invalid route configuration | | `ZeltMiddlewareExecutionError` | Middleware execution error (e.g., next() called multiple times) | | `ZeltNotImplementedError` | Method not implemented | | `ZeltSchemaValidationError` | Invalid schema definition | ### Usage ```typescript import { ZeltAppConfigurationError } from '@zeltjs/core'; try { // ... } catch (error) { if (error instanceof ZeltAppConfigurationError) { console.log(error.context.reason); // 'no_http_or_commands' | 'duplicate_command' } } ``` ### Error Context Each error class includes a `context` property with structured information: ```ts twoslash // @noErrors // Reason: type-only example without runtime code // ZeltDecoratorUsageError context type DecoratorUsageErrorContext = { decoratorName: string; reason: 'static_method' | 'missing_decorator'; targetName?: string; } // ZeltLifecycleStateError context type LifecycleStateErrorContext = { operation: string; currentState: 'disposed' | 'ready' | 'not_ready'; } ``` ## Best Practices 1. **Use descriptive error codes** — Prefer `USER_NOT_FOUND` over `NOT_FOUND` 2. **Include actionable messages** — Help API consumers understand what went wrong 3. **Avoid exposing internal details** — In production, don't include stack traces or internal error messages 4. **Document error responses** — Use OpenAPI schemas to document all possible error codes 5. **Order error handlers by specificity** — Place specific handlers before generic ones 6. **Use framework errors** — Catch `Zelt*Error` classes to handle framework-specific issues --- ## Logging Zelt provides a built-in `Logger` module with structured logging, configurable transports, and context propagation. ## Basic Usage Inject the `Logger` into your services or controllers: ```typescript import { Injectable, inject, Logger } from '@zeltjs/core'; @Injectable() export class OrderService { constructor(private logger = inject(Logger)) {} processOrder(orderId: string) { this.logger.info(`Processing order: ${orderId}`); try { // ... process order this.logger.debug('Order validation passed'); } catch (error) { this.logger.error(`Failed to process order: ${orderId}`); throw error; } } } ``` ## Log Levels The Logger supports four log levels in order of severity: | Level | Method | Description | | ------- | ---------------- | ------------------------------- | | `debug` | `logger.debug()` | Detailed debugging information | | `info` | `logger.info()` | General informational messages | | `warn` | `logger.warn()` | Warning messages | | `error` | `logger.error()` | Error messages | Messages are only output if their level is equal to or higher than the configured level. For example, with `level: 'info'`, `debug()` messages are suppressed. ## Structured Logging Pass context as the second argument to include structured data: ```typescript import { inject, Logger } from '@zeltjs/core'; const logger = inject(Logger); const orderId = '123', userId = '456'; // ---cut--- logger.info('Order processed', { orderId, userId, duration: 150 }); // Output: 13:45:23 INFO Order processed {"orderId":"123","userId":"456","duration":150} ``` ## Child Loggers Create child loggers with bound context that persists across all log calls: ```typescript import { Injectable, inject, Logger } from '@zeltjs/core'; // ---cut--- @Injectable() export class OrderService { private logger: Logger; constructor(baseLogger = inject(Logger)) { this.logger = baseLogger.child({ service: 'OrderService' }); } processOrder(orderId: string) { const orderLogger = this.logger.child({ orderId }); orderLogger.info('Processing started'); // Output includes: {"service":"OrderService","orderId":"123"} } } ``` ## Global Context with withLogContext Use `withLogContext` to propagate context across async boundaries using `AsyncLocalStorage`: ```typescript import { withLogContext, Logger, inject } from '@zeltjs/core'; declare const someService: { process(): void }; // ---cut--- const logger = inject(Logger); withLogContext({ requestId: 'abc-123' }, () => { logger.info('Request received'); // Context is automatically included in all logs within this scope someService.process(); }); ``` ## Configuration ### Basic Configuration Configure the Logger using `LoggerConfig`: ```typescript import { Config, Env, inject, LoggerConfig, ConsoleTransport, JsonlFormatter, type LogLevel, } from '@zeltjs/core'; type TransportBinding = { transport: { write(msg: string): void }; formatter: { format(entry: unknown): string } }; // ---cut--- @Config export class AppLoggerConfig extends LoggerConfig { constructor( private env = inject(Env), private consoleTransport = inject(ConsoleTransport), private jsonlFormatter = inject(JsonlFormatter), ) { super(); } override get level(): LogLevel { return (this.env.getString('LOG_LEVEL') as LogLevel) ?? 'info'; } override get transports(): readonly TransportBinding[] { return [{ transport: this.consoleTransport, formatter: this.jsonlFormatter }]; } } ``` ### Using PrettyFormatter For human-readable output in development, use `PrettyFormatter`: ```typescript import { Config, inject, LoggerConfig, ConsoleTransport, PrettyFormatter, } from '@zeltjs/core'; type TransportBinding = { transport: { write(msg: string): void }; formatter: { format(entry: unknown): string } }; // ---cut--- @Config export class DevLoggerConfig extends LoggerConfig { constructor( private consoleTransport = inject(ConsoleTransport), private prettyFormatter = inject(PrettyFormatter), ) { super(); } override get level() { return 'debug' as const; } override get transports(): readonly TransportBinding[] { return [{ transport: this.consoleTransport, formatter: this.prettyFormatter }]; } } ``` `PrettyFormatter` outputs colored logs in TTY environments: ``` 13:45:23 INFO Order processed {"orderId":"123"} 13:45:23 ERROR Failed to process {"error":"timeout"} ``` Register the config when creating the app: ```typescript import { createApp, Config, LoggerConfig, Controller, Get, http } from '@zeltjs/core'; @Config class AppLoggerConfig extends LoggerConfig {} @Controller('/') class AppController { @Get('/') index() { return { ok: true }; } } // ---cut--- const app = createApp([http({ controllers: [AppController], })], { configs: [AppLoggerConfig] }); ``` ## Transports and Formatters The Logger uses a pluggable transport/formatter architecture: | Component | Description | | ------------------- | ---------------------------------------------- | | `ConsoleTransport` | Writes to stdout/stderr | | `JsonlFormatter` | JSON Lines format (one JSON object per line) | | `PrettyFormatter` | Human-readable format with optional colors | ### Custom Transport Implement `LoggerTransport` for custom output destinations: ```typescript import type { LoggerTransport } from '@zeltjs/core'; export class FileTransport implements LoggerTransport { write(message: string): void { // Write to file } } ``` ### Custom Formatter Implement `LoggerFormatter` for custom output formats: ```typescript import type { LoggerFormatter, LogEntry } from '@zeltjs/core'; export class CustomFormatter implements LoggerFormatter { format(entry: LogEntry): string { return `[${entry.level}] ${entry.message}`; } } ``` ## Default Behavior Without custom configuration: - Level: `'info'` (debug messages are suppressed) - Transport: `ConsoleTransport` - Formatter: `JsonlFormatter` --- ## Event Bus The `@zeltjs/eventbus` package provides a type-safe event bus with memory and Redis adapters for pub/sub messaging. ## Installation ```bash pnpm add @zeltjs/eventbus @zeltjs/core ``` For Redis support: ```bash pnpm add @zeltjs/eventbus @zeltjs/core @zeltjs/redis ``` ## Overview The event bus enables decoupled communication between components through publish/subscribe messaging. Events are fully typed via TypeScript declaration merging. ## Defining Events Extend the `EventBusSchema` interface to define your events: ```typescript twoslash import type { EventBusSchema } from '@zeltjs/eventbus'; // ---cut--- declare module '@zeltjs/eventbus' { interface EventBusSchema { 'user.created': { userId: string; email: string }; 'order.placed': { orderId: string; total: number }; 'notification.send': { to: string; message: string }; } } ``` This provides full type safety for event names and payloads. ## App Setup Register the `eventbus` feature in `createApp`. It takes an `adaptor` class (the adapter to use for publishing and subscribing) and an optional `handlers` array of subscriber classes: ```typescript twoslash import { Injectable, inject, LifecycleManager, createApp, http } from '@zeltjs/core'; import type { Lifecycle } from '@zeltjs/core'; import { MemoryEventBusAdaptor, eventbus } from '@zeltjs/eventbus'; declare module '@zeltjs/eventbus' { interface EventBusSchema { 'user.created': { userId: string; email: string }; } } @Injectable() class NotificationHandlers implements Lifecycle { private unsubscribes: Array<() => void> = []; constructor( private readonly eventBus = inject(MemoryEventBusAdaptor), lifecycle = inject(LifecycleManager), ) { lifecycle.register(this); } async startup(): Promise { const unsub = await this.eventBus.on('user.created', (data) => { console.log(`Welcome email sent to ${data.email}`); }); this.unsubscribes.push(unsub); } async shutdown(): Promise { for (const unsub of this.unsubscribes) unsub(); this.unsubscribes = []; } } // ---cut--- const app = createApp([ http({ controllers: [] }), eventbus({ adaptor: MemoryEventBusAdaptor, handlers: [NotificationHandlers] }), ]); ``` `adaptor` selects which `EventBusAdaptor` implementation backs `emit`/`on`/`once` for handlers that inject it directly. `handlers` lists subscriber classes that nothing else in the app depends on — without listing them here, they would never be constructed and their `startup()` subscriptions would never run. ## Subscribing with Lifecycle Subscriber classes implement `Lifecycle` from `@zeltjs/core`: they subscribe in `startup()` and keep the returned unsubscribe functions to call in `shutdown()`. ```typescript twoslash declare module '@zeltjs/eventbus' { interface EventBusSchema { 'user.created': { userId: string; email: string }; } } import { Injectable, inject, LifecycleManager } from '@zeltjs/core'; import type { Lifecycle } from '@zeltjs/core'; import { MemoryEventBusAdaptor } from '@zeltjs/eventbus'; // ---cut--- @Injectable() export class NotificationHandlers implements Lifecycle { private unsubscribes: Array<() => void> = []; constructor( private readonly eventBus = inject(MemoryEventBusAdaptor), lifecycle = inject(LifecycleManager), ) { lifecycle.register(this); } async startup(): Promise { const unsub = await this.eventBus.on('user.created', (data) => { console.log(`Welcome email sent to ${data.email}`); }); this.unsubscribes.push(unsub); } async shutdown(): Promise { for (const unsub of this.unsubscribes) unsub(); this.unsubscribes = []; } } @Injectable() export class UserService { constructor(private eventBus = inject(MemoryEventBusAdaptor)) {} async createUser(email: string) { const userId = crypto.randomUUID(); await this.eventBus.emit('user.created', { userId, email }); return { userId }; } } ``` `UserService` publishes events by injecting the same adapter class directly — publishers don't need to be listed in `handlers`, since `emit` doesn't require the class to be pre-constructed by the feature. ## Memory Adapter For single-process applications, use the in-memory adapter. `MemoryEventBusAdaptor` is built on [mitt](https://github.com/developit/mitt); events are local to the process and lost on restart: ```typescript twoslash import { MemoryEventBusAdaptor } from '@zeltjs/eventbus/adaptor-memory'; ``` ## Redis Adapter For distributed applications, use the Redis adapter. `RedisEventBusAdaptor` implements `Lifecycle` itself. It duplicates the `@zeltjs/redis` client into a dedicated subscriber connection when constructed (clients are created with `lazyConnect`, so this performs no I/O), `startup()` opens that connection, `on()` subscribes a channel the first time it's used for an event, and `shutdown()` disconnects the subscriber connection. Calling `on()`/`once()` before `startup()` only registers the handler; `startup()` performs the actual bulk SUBSCRIBE for everything registered until then. Calling `on()`/`once()` after `startup()` sends SUBSCRIBE for new channels immediately and the returned promise doesn't resolve until Redis confirms it. ```typescript twoslash import type { EventBusSchema } from '@zeltjs/eventbus'; import { Injectable, inject } from '@zeltjs/core'; import { RedisEventBusAdaptor } from '@zeltjs/eventbus/adaptor-redis'; declare module '@zeltjs/eventbus' { interface EventBusSchema { 'order.placed': { orderId: string; total: number }; } } // ---cut--- @Injectable() export class OrderService { constructor(private eventBus = inject(RedisEventBusAdaptor)) {} async placeOrder(items: { price: number }[]) { const orderId = crypto.randomUUID(); const total = items.reduce((sum, item) => sum + item.price, 0); await this.eventBus.emit('order.placed', { orderId, total }); return { orderId }; } } ``` The Redis adapter requires `@zeltjs/redis` to be configured. Register `RedisConfig` alongside the `eventbus` feature: ```typescript twoslash import { Injectable, inject, LifecycleManager, createApp, http } from '@zeltjs/core'; import type { Lifecycle } from '@zeltjs/core'; import { eventbus } from '@zeltjs/eventbus'; import { RedisEventBusAdaptor } from '@zeltjs/eventbus/adaptor-redis'; import { RedisConfig } from '@zeltjs/redis'; declare module '@zeltjs/eventbus' { interface EventBusSchema { 'order.placed': { orderId: string; total: number }; } } @Injectable() class OrderHandlers implements Lifecycle { private unsubscribes: Array<() => void> = []; constructor( private readonly eventBus = inject(RedisEventBusAdaptor), lifecycle = inject(LifecycleManager), ) { lifecycle.register(this); } async startup(): Promise { const unsub = await this.eventBus.on('order.placed', (data) => { console.log(`Order ${data.orderId} placed for ${data.total}`); }); this.unsubscribes.push(unsub); } async shutdown(): Promise { for (const unsub of this.unsubscribes) unsub(); this.unsubscribes = []; } } // ---cut--- const app = createApp( [ http({ controllers: [] }), eventbus({ adaptor: RedisEventBusAdaptor, handlers: [OrderHandlers] }), ], { configs: [RedisConfig] }, ); ``` `@zeltjs/redis` is an optional peer dependency of `@zeltjs/eventbus` — only install it when you use `RedisEventBusAdaptor`. See [Redis KV Driver](./kv-redis.md) for customizing `RedisConfig` (connection URL, retry strategy, and so on). ## API Reference ### eventbus(options) Registers the event bus feature. Adds it to the app under the `eventbus` key. | Option | Description | |--------|--------------| | `adaptor` | The `EventBusAdaptor` class to construct and expose (`MemoryEventBusAdaptor` or `RedisEventBusAdaptor`) | | `handlers` | Optional subscriber classes to force-construct at startup, so their `Lifecycle.startup()` subscriptions run | ### EventBusAdaptor Interface Both adapters implement this interface: | Method | Description | |--------|-------------| | `emit(event, data)` | Publish an event with payload | | `on(event, handler)` | Subscribe to an event. Resolves with an unsubscribe function once the subscription is established; rejects if subscribing fails | | `once(event, handler)` | Subscribe to an event once. Resolves with an unsubscribe function once the subscription is established; rejects if subscribing fails | ### MemoryEventBusAdaptor In-memory event bus built on [mitt](https://github.com/developit/mitt). Events are local to the process. ```typescript twoslash import { MemoryEventBusAdaptor } from '@zeltjs/eventbus/adaptor-memory'; ``` ### RedisEventBusAdaptor Redis-backed event bus using pub/sub. Events are distributed across processes. ```typescript twoslash import { RedisEventBusAdaptor } from '@zeltjs/eventbus/adaptor-redis'; ``` ## Unsubscribing Both `on()` and `once()` return an unsubscribe function: ```typescript twoslash import type { EventBusSchema } from '@zeltjs/eventbus'; import { MemoryEventBusAdaptor } from '@zeltjs/eventbus/adaptor-memory'; declare module '@zeltjs/eventbus' { interface EventBusSchema { 'user.created': { userId: string; email: string }; } } const eventBus = new MemoryEventBusAdaptor(); // ---cut--- const unsubscribe = await eventBus.on('user.created', (data) => { console.log(data.email); }); // Later, stop listening unsubscribe(); ``` ## Best Practices ### Event Naming Use dot notation for event names: `domain.action` ```typescript twoslash import type { EventBusSchema } from '@zeltjs/eventbus'; // ---cut--- declare module '@zeltjs/eventbus' { interface EventBusSchema { 'user.created': { userId: string }; 'user.updated': { userId: string; changes: string[] }; 'user.deleted': { userId: string }; 'order.placed': { orderId: string }; 'order.shipped': { orderId: string; trackingNumber: string }; } } ``` ### Idempotent Handlers Design event handlers to be idempotent — safe to run multiple times with the same data: ```typescript twoslash import type { EventBusSchema } from '@zeltjs/eventbus'; import { MemoryEventBusAdaptor } from '@zeltjs/eventbus/adaptor-memory'; import { eq } from 'drizzle-orm'; import { pgTable, serial, text } from 'drizzle-orm/pg-core'; import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; declare module '@zeltjs/eventbus' { interface EventBusSchema { 'order.placed': { orderId: string; total: number }; } } const notifications = pgTable('notifications', { id: serial('id').primaryKey(), orderId: text('order_id').notNull(), type: text('type').notNull(), }); const db = drizzle(postgres('postgres://localhost:5432/app')); const eventBus = new MemoryEventBusAdaptor(); // ---cut--- await eventBus.on('order.placed', async (data) => { const [existing] = await db .select() .from(notifications) .where(eq(notifications.orderId, data.orderId)) .limit(1); if (existing) return; await db.insert(notifications).values({ orderId: data.orderId, type: 'order_confirmation', }); }); ``` ### Error Handling Wrap handlers in try-catch to prevent errors from affecting other subscribers: ```typescript twoslash import type { EventBusSchema } from '@zeltjs/eventbus'; import { MemoryEventBusAdaptor } from '@zeltjs/eventbus/adaptor-memory'; import { Injectable } from '@zeltjs/core'; declare module '@zeltjs/eventbus' { interface EventBusSchema { 'user.created': { userId: string; email: string }; } } @Injectable() class MailService { async sendWelcome(email: string): Promise {} } const mailService = new MailService(); const eventBus = new MemoryEventBusAdaptor(); // ---cut--- await eventBus.on('user.created', async (data) => { try { await mailService.sendWelcome(data.email); } catch (error) { console.error('Failed to send welcome email:', error); } }); ``` --- ## IPC Bridge The Electron adapter replaces HTTP sockets with Electron's IPC mechanism. Standard `Request`/`Response` objects are serialized into IPC-safe payloads, sent between processes, and deserialized on the other side — your controllers see the same Web Fetch API as any other adapter. ## How It Works ```mermaid sequenceDiagram participant R as Renderer participant P as Preload participant M as Main (Zelt app) R->>P: ipcFetch(request) P->>M: invoke over the IPC channel M->>M: app.fetch(request) M-->>P: Response P-->>R: Response ``` Text content (JSON, HTML, XML) is serialized as strings; binary content as `ArrayBuffer`. ## Configuration All three layers must agree on the same channel string: ```typescript import { createApp, http } from '@zeltjs/core'; import { onElectron } from '@zeltjs/adapter-electron'; import { exposeIpc } from '@zeltjs/adapter-electron/preload'; import { ipcFetch } from '@zeltjs/adapter-electron/renderer'; const app = createApp([http({ controllers: [] })]); const input = new Request('http://zelt-app/hello'); const init = undefined; // ---cut--- // main const electronZelt = await onElectron(app, { ipcChannel: 'http://zelt-app' }); // preload exposeIpc({ channel: 'http://zelt-app' }); // renderer ipcFetch(input, init, { channel: 'http://zelt-app' }); ``` The channel must start with `http://` or `https://`. The default is `'http://zelt-ipc'` if omitted. ## Main Process: `onElectron()` ```typescript import { createApp, http } from '@zeltjs/core'; import { onElectron } from '@zeltjs/adapter-electron'; const app = createApp([http({ controllers: [] })]); // ---cut--- const electronZelt = await onElectron(app, { ipcChannel: 'http://zelt-app', warmup: true, // default: resolve all controllers at startup }); ``` ### Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `ipcChannel` | `` `http://${string}` \| `https://${string}` `` | `'http://zelt-ipc'` | IPC channel identifier | | `warmup` | `boolean` | `true` | Resolve all controllers at startup | | `ipcFeature` | `string` | `'http'` | Key of the HTTP feature to bind to the IPC bridge, for apps with multiple `http()` features | ### Return Value (`OnElectronApp`) Each configured feature is namespaced under its key (`http` by default). HTTP features additionally expose `listen()` to serve over TCP instead of IPC: | Property | Type | Description | |----------|------|-------------| | `http.fetch` | `(request: Request) => Promise` | Handle a request directly | | `http.request` | `(input: string \| Request, init?: RequestInit) => Promise` | Handle a request built from a URL/path and `RequestInit` | | `http.listen` | `(portOrOptions?: number \| ListenOptions) => Promise` | Serve this feature over TCP instead of IPC | | `shutdown` | `() => Promise` | Graceful shutdown | | `get` | `(Class) => Promise` | Resolve a service from DI | Breaking change: the old `electronZelt.fetch` shorthand is gone — use `electronZelt.http.fetch` instead (or the key you configured via `ipcFeature`). ### Warmup By default, `onElectron()` eagerly resolves all controllers at startup (`warmup: true`). This ensures services are initialized before the first request. Set `warmup: false` for lazy initialization on first request. ## Preload Script: `exposeIpc()` ```typescript import { exposeIpc } from '@zeltjs/adapter-electron/preload'; // ---cut--- exposeIpc({ channel: 'http://zelt-app' }); ``` `exposeIpc()` registers an IPC sender function and exposes it to the renderer via `contextBridge.exposeInMainWorld()` (or `globalThis` if context isolation is disabled). The exposed key is the channel string itself, so `ipcFetch` on the renderer side can look it up from `globalThis[channel]`. ## Renderer: `ipcFetch()` ```typescript import { ipcFetch } from '@zeltjs/adapter-electron/renderer'; // ---cut--- const response = await ipcFetch('http://zelt-app/hello/world', undefined, { channel: 'http://zelt-app', }); const data = await response.json(); ``` `ipcFetch()` has the same signature as `fetch()`, plus an optional third argument for the channel. It converts the request into an IPC payload, sends it through the preload bridge, and returns a standard `Response`. ### Creating a Wrapper In practice, wrap `ipcFetch` so the channel is configured once: ```typescript import { ipcFetch } from '@zeltjs/adapter-electron/renderer'; // ---cut--- const CHANNEL = 'http://zelt-app'; export const apiFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => ipcFetch(input, init, { channel: CHANNEL }); ``` ### Using with Hono Client For type-safe API calls, combine with [`@zeltjs/hono-client`](../hono-client): ```typescript import { ipcFetch } from '@zeltjs/adapter-electron/renderer'; declare function hc(baseUrl: string, options?: { fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise }): T; // AppType is generated by @zeltjs/hono-client from your app definition declare type AppType = Record; // ---cut--- const zeltIpcFetch = (input: RequestInfo | URL, init?: RequestInit): Promise => ipcFetch(input, init, { channel: 'http://zelt-app' }); export const client = hc('http://zelt-app', { fetch: zeltIpcFetch, }); ``` `AppType` is generated from your app definition — either by the CLI plugin (`zelt build`) or, when your build is driven by electron-vite instead of the Zelt CLI, programmatically via [`GeneratorService.generateFromApp()`](../hono-client#programmatic-generation). For renderer code that cannot import from the main process build output, generate with `portable: true`. ## Accessing the IPC Event In controllers, use `ipcEvent()` to access the underlying `IpcMainInvokeEvent`: ```typescript import { Controller, Get } from '@zeltjs/core'; import { ipcEvent } from '@zeltjs/adapter-electron'; // ---cut--- @Controller('/system') export class SystemController { @Get('/sender') getSender() { const event = ipcEvent(); return { processId: event?.processId }; } } ``` ## Shutdown Connect Zelt shutdown to Electron's quit lifecycle: ```typescript import { createApp, http } from '@zeltjs/core'; import { ElectronAdaptor, onElectron } from '@zeltjs/adapter-electron'; const app = createApp([http({ controllers: [] })]); // ---cut--- const electronZelt = await onElectron(app, { ipcChannel: 'http://zelt-app' }); const electronApp = await electronZelt.get(ElectronAdaptor); electronApp.ready.app.on('will-quit', () => { void electronZelt.shutdown(); }); ``` Using `will-quit` keeps the HTTP bridge alive until Electron is actually quitting, so the renderer can send final IPC calls during teardown. --- ## Window Management The Electron adapter provides injectable services for managing `BrowserWindow` instances through Zelt's DI system. ## ElectronAdaptor `ElectronAdaptor` is a lifecycle service that wraps Electron's core APIs. It resolves after `app.whenReady()`, giving you safe access to Electron primitives. ```typescript import { Injectable, inject } from '@zeltjs/core'; import { ElectronAdaptor } from '@zeltjs/adapter-electron'; // ---cut--- @Injectable() export class AppLifecycleService { constructor(private electron = inject(ElectronAdaptor)) {} async initialize() { const { app, ipcMain, dialog } = this.electron.ready; } } ``` ### Available APIs The `ready` property exposes: | Property | Type | Description | |----------|------|-------------| | `app` | `App` | Electron app instance | | `ipcMain` | `IpcMain` | IPC main module | | `protocol` | `Protocol` | Protocol handler | | `shell` | `Shell` | Shell integration | | `screen` | `Screen` | Display info | | `dialog` | `Dialog` | Native dialogs | | `Menu` | `typeof Menu` | Menu class | | `createBrowserWindow` | `(options) => BrowserWindow` | Create a window | | `getAllWindows` | `() => BrowserWindow[]` | List all windows | | `fromWebContents` | `(webContents) => BrowserWindow \| null` | Find window by web contents | | `fromId` | `(id) => BrowserWindow \| null` | Find window by ID | | `getFocusedWindow` | `() => BrowserWindow \| null` | Get focused window | ## WindowDefinition Define windows as data: ```typescript import type { WindowDefinition } from '@zeltjs/adapter-electron'; declare const join: (...paths: string[]) => string; // ---cut--- const mainWindow: WindowDefinition = { id: 'main', loadTarget: { type: 'file', path: join(__dirname, '../renderer/index.html') }, options: { width: 900, height: 670, webPreferences: { contextIsolation: true, nodeIntegration: false, preload: join(__dirname, '../preload/index.js'), sandbox: true, }, }, }; ``` ### WindowLoadTarget | Type | Properties | Description | |------|-----------|-------------| | `{ type: 'file' }` | `path: string` | Load a local HTML file | | `{ type: 'url' }` | `url: string` | Load a URL (useful for dev server) | ## Creating Windows via DI Encapsulate window creation in a service: ```typescript import { Injectable, inject } from '@zeltjs/core'; import type { WindowDefinition, WindowLoadTarget } from '@zeltjs/adapter-electron'; declare const join: (...paths: string[]) => string; declare class EnvService { isDevelopment: boolean; rendererUrl?: string; } // ---cut--- @Injectable() export class MainWindow { constructor(private env = inject(EnvService)) {} create(): WindowDefinition { const loadTarget: WindowLoadTarget = this.env.isDevelopment && this.env.rendererUrl ? { type: 'url', url: this.env.rendererUrl } : { type: 'file', path: join(__dirname, '../renderer/index.html') }; return { id: 'main', loadTarget, options: { width: 900, height: 670, show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, preload: join(__dirname, '../preload/index.js'), sandbox: true, }, }, }; } } ``` ## Window Registry `ElectronWindowRegistryService` manages the lifecycle of multiple windows: ```typescript import { Injectable, inject } from '@zeltjs/core'; import { ElectronWindowRegistryService } from '@zeltjs/adapter-electron'; import type { WindowDefinition } from '@zeltjs/adapter-electron'; // ---cut--- @Injectable() export class WindowManagerService { constructor(private registry = inject(ElectronWindowRegistryService)) {} openMain(definition: WindowDefinition) { const handle = this.registry.open(definition); handle.on('ready-to-show', () => handle.show()); return handle; } closeAll() { this.registry.closeAll(); } get windowCount() { return this.registry.count(); } } ``` ### API | Method | Description | |--------|-------------| | `open(definition)` | Open a new window or focus an existing one with the same ID | | `close(id)` | Close a specific window | | `closeAll()` | Close all managed windows | | `count()` | Number of open windows | ## WindowHandle `open()` returns a `WindowHandle` — a safe wrapper around `BrowserWindow`: | Method | Description | |--------|-------------| | `close()` | Close the window | | `focus()` | Focus the window | | `show()` | Show the window | | `isDestroyed()` | Check if destroyed | | `getTitle()` | Get window title | | `getBounds()` / `setBounds()` | Get/set window position and size | | `loadFile(path)` / `loadURL(url)` | Load content | | `on(event, handler)` | Listen to `'closed'` or `'ready-to-show'` | | `webContents.send(channel, ...args)` | Send to renderer | --- ## Commands Zelt provides CLI command support with dependency injection through `@zeltjs/core`. ## Creating a Command Define the schema with `cliSchema()` as a module-level constant, then pass it to `args()` in the `@Command` decorated class for type-safe CLI commands: ```typescript import { Command, cliSchema, args } from '@zeltjs/core'; const greetSchema = cliSchema({ args: [{ name: 'name', type: 'string' }], }); @Command({ name: 'greet', description: 'Greet a user', }) export class GreetCommand { run(ctx = args(greetSchema)) { console.log(`Hello, ${ctx.name}!`); } } ``` ## Configuration Create a `src/cli.ts` entry point for your CLI: ```typescript import { createApp, Command, cliSchema, args, command } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; const greetSchema = cliSchema({ args: [{ name: 'name', type: 'string' }] }); @Command({ name: 'greet', description: 'Greet a user' }) class GreetCommand { run(ctx = args(greetSchema)) { console.log(`Hello, ${ctx.name}!`); } } // ---cut--- const app = createApp([command([GreetCommand])]); const nodeApp = await onNode(app); await nodeApp.commands.execCommand([...nodeApp.args]); ``` Then configure `cli.entry` in your `zelt.config.ts`: ```typescript // @filename: src/app.ts import { createApp, command } from '@zeltjs/core'; export const app = createApp([command([])]); // @filename: zelt.config.ts import { defineConfig } from '@zeltjs/cli'; export default defineConfig({ app: () => import('./src/app').then((m) => m.app), cli: { entry: './src/cli.ts' }, }); ``` ## Running Commands Use `zelt run` to execute commands: ```bash # Run a command zelt run greet Alice # With custom config zelt run -c ./config/zelt.config.ts greet Alice ``` ## Schema Definition The `cliSchema()` function defines typed arguments and options. The resulting schema is a plain value: declare it in its own module (for example `greet-schema.lib.ts`), import it into the command, and pass it to `args()`. The argv is only parsed and validated when `args(schema)` is called — a command that never calls `args()` accepts extra argv without error. ### Positional Arguments ```typescript import { Command, cliSchema, args } from '@zeltjs/core'; // ---cut--- const copySchema = cliSchema({ args: [ { name: 'source', type: 'string' }, { name: 'destination', type: 'string' }, ], }); @Command({ name: 'copy' }) export class CopyCommand { run(ctx = args(copySchema)) { console.log(`Copying ${ctx.source} to ${ctx.destination}`); } } ``` ### Options (Flags) ```typescript import { Command, cliSchema, args } from '@zeltjs/core'; // ---cut--- const buildSchema = cliSchema({ options: [ { name: 'watch', type: 'boolean', alias: 'w' }, { name: 'outDir', type: 'string', alias: 'o', default: 'dist' }, ], }); @Command({ name: 'build' }) export class BuildCommand { run(ctx = args(buildSchema)) { if (ctx.watch) { console.log('Watching for changes...'); } console.log(`Output directory: ${ctx.outDir}`); } } ``` ```bash # Usage zelt run build --watch --outDir=out zelt run build -w -o out ``` ### Combined Arguments and Options ```typescript import { Command, cliSchema, args } from '@zeltjs/core'; // ---cut--- const deploySchema = cliSchema({ args: [ { name: 'environment', type: 'string' }, ], options: [ { name: 'dryRun', type: 'boolean' }, { name: 'tag', type: 'string' }, ], }); @Command({ name: 'deploy' }) export class DeployCommand { run(ctx = args(deploySchema)) { const { environment, dryRun, tag } = ctx; if (dryRun) { console.log(`[DRY RUN] Would deploy to ${environment}`); } else { console.log(`Deploying ${tag ?? 'latest'} to ${environment}`); } } } ``` ## Schema Types ### Argument Types | Type | Description | |------|-------------| | `string` | String value | | `number` | Numeric value (automatically parsed) | Arguments can be marked as optional: ```typescript import { cliSchema } from '@zeltjs/core'; // ---cut--- const schema = cliSchema({ args: [ { name: 'file', type: 'string' }, { name: 'count', type: 'number', optional: true }, ], }); ``` ### Option Types | Type | Description | |------|-------------| | `string` | String option | | `number` | Numeric option (automatically parsed) | | `boolean` | Boolean flag | Options can have defaults: ```typescript import { cliSchema } from '@zeltjs/core'; // ---cut--- const schema = cliSchema({ options: [ { name: 'port', type: 'number', default: 3000 }, { name: 'verbose', type: 'boolean' }, // defaults to false ], }); ``` ## Transient Scope Commands are registered as **transient** — a new instance is created for each execution. This ensures: - Clean state for each command run - No shared mutable state between executions - Dependencies injected via `inject()` remain singletons ```typescript import { Command, inject } from '@zeltjs/core'; declare class DatabaseService {} // ---cut--- @Command({ name: 'process' }) export class ProcessCommand { private startTime = Date.now(); // Fresh for each execution constructor(private db = inject(DatabaseService)) {} // Singleton, shared run() { console.log(`Started at: ${this.startTime}`); } } ``` ## Dependency Injection Commands support dependency injection: ```typescript import { Command, cliSchema, args, inject } from '@zeltjs/core'; declare class DatabaseService { runMigrations(): Promise; } // ---cut--- const migrateSchema = cliSchema({ options: [ { name: 'force', type: 'boolean' }, ], }); @Command({ name: 'migrate' }) export class MigrateCommand { constructor(private readonly db = inject(DatabaseService)) {} async run(ctx = args(migrateSchema)) { if (ctx.force) { console.log('Force migration enabled'); } await this.db.runMigrations(); console.log('Migrations completed'); } } ``` ### Where to Call `inject()` and `args()` Call `inject()` in the constructor and `args()` as a default parameter of `run()`. The command context that `args()` reads from only exists while `run()` is executing, so calling `args()` in the constructor throws `ZeltContextNotAvailableError`. ```typescript import { Command, cliSchema, args } from '@zeltjs/core'; // ---cut--- const greetSchema = cliSchema({ args: [{ name: 'name', type: 'string' }] }); @Command({ name: 'greet' }) export class GreetCommand { // ❌ No command context yet — throws ZeltContextNotAvailableError constructor(private readonly ctx = args(greetSchema)) {} run() { console.log(`Hello, ${this.ctx.name}!`); } } ``` ```typescript import { Command, cliSchema, args } from '@zeltjs/core'; // ---cut--- const greetSchema = cliSchema({ args: [{ name: 'name', type: 'string' }] }); @Command({ name: 'greet' }) export class GreetCommand { // ✅ run() executes inside the command context run(ctx = args(greetSchema)) { console.log(`Hello, ${ctx.name}!`); } } ``` ## Programmatic Execution Commands can be executed programmatically using `onNode()`: ```typescript import { createApp, Command, cliSchema, args, command } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; const migrateSchema = cliSchema({ options: [{ name: 'force', type: 'boolean' }] }); @Command({ name: 'migrate' }) class MigrateCommand { run(ctx = args(migrateSchema)) {} } // ---cut--- const app = createApp([command([MigrateCommand])]); const nodeApp = await onNode(app); const result = await nodeApp.commands.execCommand(['migrate', '--force']); console.log(`Exit code: ${result.exitCode}`); ``` ## Async Commands Commands can be async: ```typescript import { Command } from '@zeltjs/core'; // ---cut--- @Command({ name: 'sync' }) export class SyncCommand { async run() { console.log('Starting sync...'); await this.fetchData(); await this.processData(); console.log('Sync completed'); } private async fetchData() { // ... } private async processData() { // ... } } ``` --- ## Scheduler Zelt provides declarative scheduling decorators for running tasks at specified intervals or cron expressions. ## Overview The scheduler API consists of: - **`@Scheduled`** — Class decorator marking a class as a scheduler - **`@Cron(expression)`** — Run at specific cron expression - **`@Daily({ hour, minute? })`** — Run once per day - **`@Hourly({ minute? })`** — Run once per hour - **`@Weekly({ day, hour, minute? })`** — Run once per week - **`@Every({ minutes | seconds })`** — Run at fixed intervals ## Basic Usage ### Creating a Scheduler ```typescript import { Scheduled, Cron, Daily, Hourly } from '@zeltjs/core'; @Scheduled() class ReportScheduler { @Daily({ hour: 9 }) async sendDailyReport() { console.log('Sending daily report...'); } @Hourly() async checkHealth() { console.log('Health check...'); } } ``` ### Registering Schedulers Pass scheduler classes to `createApp()`: ```typescript import { createApp, Controller, Get, Scheduled, Daily, Hourly, http, scheduler } from '@zeltjs/core'; @Controller('/users') class UserController { @Get('/') findAll() { return { users: [] }; } } @Scheduled() class ReportScheduler { @Daily({ hour: 9 }) async sendDailyReport() { console.log('Sending daily report...'); } @Hourly() async checkHealth() { console.log('Health check...'); } } // ---cut--- const app = createApp([http({ controllers: [UserController] }), scheduler([ReportScheduler])]); ``` ### Starting the Scheduler The scheduler requires explicit startup. After calling `onNode()` and `createRuntime()`, call `schedulers.startScheduler()` to begin executing scheduled tasks: ```typescript import { createApp, Controller, Get, Scheduled, Daily, Hourly, http, scheduler } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; @Controller('/users') class UserController { @Get('/') findAll() { return { users: [] }; } } @Scheduled() class ReportScheduler { @Daily({ hour: 9 }) async sendDailyReport() {} @Hourly() async checkHealth() {} } const app = createApp([http({ controllers: [UserController] }), scheduler([ReportScheduler])]); const nodeApp = await onNode(app); // ---cut--- await nodeApp.schedulers.startScheduler(); ``` To stop the scheduler gracefully: ```typescript import { createApp, Controller, Get, Scheduled, Daily, Hourly, http, scheduler } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; @Controller('/users') class UserController { @Get('/') findAll() { return { users: [] }; } } @Scheduled() class ReportScheduler { @Daily({ hour: 9 }) async sendDailyReport() {} @Hourly() async checkHealth() {} } const app = createApp([http({ controllers: [UserController] }), scheduler([ReportScheduler])]); const nodeApp = await onNode(app); // ---cut--- await nodeApp.schedulers.stopScheduler(); ``` The scheduler is **not started automatically** when the app becomes ready. This design allows you to: - Run HTTP server without scheduled tasks (e.g., during testing) - Control scheduler lifecycle independently from the server - Conditionally enable scheduling based on environment ## Decorator Reference ### @Cron Run at specific cron expression: ```typescript import { Scheduled, Cron } from '@zeltjs/core'; // ---cut--- @Scheduled() class BackupScheduler { @Cron('0 2 * * *') async runBackup() { // Runs at 2:00 AM every day } @Cron('*/5 * * * *') async quickCheck() { // Runs every 5 minutes } } ``` With timezone: ```typescript import { Scheduled, Cron } from '@zeltjs/core'; // ---cut--- @Scheduled() class TimezoneScheduler { @Cron('0 9 * * *', { tz: 'Asia/Tokyo' }) async morningTask() { // Runs at 9:00 AM JST } } ``` ### @Daily Run once per day at specified hour: ```typescript import { Scheduled, Daily } from '@zeltjs/core'; // ---cut--- @Scheduled() class DailyTasks { @Daily({ hour: 6 }) async earlyMorning() { // Runs at 6:00 AM } @Daily({ hour: 23, minute: 30 }) async lateNight() { // Runs at 11:30 PM } @Daily({ hour: 9, tz: 'America/New_York' }) async newYorkMorning() { // Runs at 9:00 AM EST/EDT } } ``` ### @Hourly Run once per hour: ```typescript import { Scheduled, Hourly } from '@zeltjs/core'; // ---cut--- @Scheduled() class HourlyTasks { @Hourly() async everyHour() { // Runs at minute 0 of every hour } @Hourly({ minute: 30 }) async halfPast() { // Runs at minute 30 of every hour } } ``` ### @Weekly Run once per week: ```typescript import { Scheduled, Weekly } from '@zeltjs/core'; // ---cut--- @Scheduled() class WeeklyTasks { @Weekly({ day: 'monday', hour: 9 }) async mondayMeeting() { // Runs every Monday at 9:00 AM } @Weekly({ day: 'friday', hour: 17, minute: 30 }) async weeklyReport() { // Runs every Friday at 5:30 PM } } ``` Available days: `'sunday'`, `'monday'`, `'tuesday'`, `'wednesday'`, `'thursday'`, `'friday'`, `'saturday'` ### @Every Run at fixed intervals: ```typescript import { Scheduled, Every } from '@zeltjs/core'; // ---cut--- @Scheduled() class PollingTasks { @Every({ minutes: 5 }) async pollApi() { // Runs every 5 minutes } @Every({ seconds: 30 }) async frequentCheck() { // Runs every 30 seconds } } ``` ## Dependency Injection Schedulers support dependency injection like controllers: ```typescript import { Scheduled, Daily, inject, Injectable } from '@zeltjs/core'; @Injectable() class EmailService { send(email: string, subject: string, body: string) { return Promise.resolve(); } } @Injectable() class UserRepository { findWithPendingReminders() { return Promise.resolve([{ email: 'user@example.com' }]); } } // ---cut--- @Scheduled() class NotificationScheduler { constructor( private emailService = inject(EmailService), private userRepo = inject(UserRepository), ) {} @Daily({ hour: 8 }) async sendReminders() { const users = await this.userRepo.findWithPendingReminders(); for (const user of users) { await this.emailService.send(user.email, 'Reminder', '...'); } } } ``` ## Node.js Entry Point For Node.js applications, use `onNode()` and explicitly start the scheduler: ```typescript import { onNode } from '@zeltjs/adapter-node'; import { createApp, Scheduled, Daily, http, scheduler } from '@zeltjs/core'; @Scheduled() class MyScheduler { @Daily({ hour: 9 }) async task() {} } const app = createApp([http({ controllers: [] }), scheduler([MyScheduler])]); // ---cut--- const nodeApp = await onNode(app); const handle = await nodeApp.http.listen(3000); // Start scheduled tasks await nodeApp.schedulers.startScheduler(); process.on('SIGTERM', async () => { await nodeApp.schedulers.stopScheduler(); await handle.shutdown(); }); ``` You can conditionally enable the scheduler using configuration: ```typescript import { createApp, Config, Env, inject, Scheduled, Daily, http, scheduler } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; @Config class SchedulerConfig { constructor(private env = inject(Env)) {} get enabled() { return this.env.getBoolean('ENABLE_SCHEDULER', true); } } @Scheduled() class MyScheduler { @Daily({ hour: 9 }) async task() {} } const app = createApp([http({ controllers: [] }), scheduler([MyScheduler])], { configs: [SchedulerConfig] }); const nodeApp = await onNode(app); // ---cut--- const config = await nodeApp.get(SchedulerConfig); if (config.enabled) { await nodeApp.schedulers.startScheduler(); } ``` ## Cron Expression Format Zelt uses standard cron format with optional seconds: ``` ┌──────────── second (optional, 0-59) │ ┌────────── minute (0-59) │ │ ┌──────── hour (0-23) │ │ │ ┌────── day of month (1-31) │ │ │ │ ┌──── month (1-12) │ │ │ │ │ ┌── day of week (0-6, Sunday=0) │ │ │ │ │ │ * * * * * * ``` Common patterns: | Pattern | Description | |---------|-------------| | `* * * * *` | Every minute | | `0 * * * *` | Every hour | | `0 0 * * *` | Every day at midnight | | `0 9 * * 1` | Every Monday at 9:00 AM | | `*/15 * * * *` | Every 15 minutes | | `0 0 1 * *` | First day of every month | --- ## Database The `@zeltjs/db` package provides ORM-agnostic database abstraction with automatic transaction propagation using AsyncLocalStorage. ## Installation ```bash pnpm add @zeltjs/db ``` ## Overview Zelt's database abstraction solves a common problem: propagating transactions through your service layer without passing transaction objects explicitly. Using Node.js AsyncLocalStorage, transactions automatically flow through async call chains. ## Creating a Database Service Extend `DatabaseService` to integrate your ORM: ```typescript import { Config, Env, Injectable, inject } from '@zeltjs/core'; import { DatabaseService } from '@zeltjs/db'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; @Config export class DatabaseConfig { constructor(private env = inject(Env)) {} get url(): string { return this.env.getStringOrThrow('DATABASE_URL'); } } type DrizzleReady = { client: PostgresJsDatabase; sql: postgres.Sql }; @Injectable() export class DrizzleService extends DatabaseService { constructor(private config = inject(DatabaseConfig)) { super(); } async setup(): Promise { const sql = postgres(this.config.url); return { client: drizzle(sql), sql }; } transaction( client: PostgresJsDatabase, fn: (tx: PostgresJsDatabase) => Promise, ): Promise { return client.transaction(fn); } async shutdown(): Promise { await this.ready.sql.end(); } } ``` Key points: - `setup()` — Create the client and any handles `shutdown()` will need (e.g. a raw connection pool); the returned object is sealed into `this.ready` - `transaction()` — Execute a function within a transaction - `shutdown()` — Close connections held in `this.ready` during graceful shutdown ## Using the Database Service ### Direct Usage Inject the service and access the client: ```typescript import { Config, Env, Injectable, inject } from '@zeltjs/core'; import { DatabaseService } from '@zeltjs/db'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import { pgTable, serial, text } from 'drizzle-orm/pg-core'; import postgres from 'postgres'; @Config class DatabaseConfig { constructor(private env = inject(Env)) {} get url(): string { return this.env.getStringOrThrow('DATABASE_URL'); } } type DrizzleReady = { client: PostgresJsDatabase; sql: postgres.Sql }; @Injectable() class DrizzleService extends DatabaseService { constructor(private config = inject(DatabaseConfig)) { super(); } async setup(): Promise { const sql = postgres(this.config.url); return { client: drizzle(sql), sql }; } transaction(client: PostgresJsDatabase, fn: (tx: PostgresJsDatabase) => Promise): Promise { return client.transaction(fn); } async shutdown(): Promise { await this.ready.sql.end(); } } const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull(), }); // ---cut--- @Injectable() export class UserRepository { constructor(private db = inject(DrizzleService)) {} async findAll() { return this.db.client.select().from(users); } async create(name: string, email: string) { return this.db.client.insert(users).values({ name, email }); } } ``` The `client` property automatically returns: - The transaction client if inside a transaction - The original client otherwise ### Transaction Decorator Create a decorator for your database service: ```typescript import { Config, Env, Injectable, inject } from '@zeltjs/core'; import { createTransactionDecorator, DatabaseService } from '@zeltjs/db'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; @Config class DatabaseConfig { constructor(private env = inject(Env)) {} get url(): string { return this.env.getStringOrThrow('DATABASE_URL'); } } type DrizzleReady = { client: PostgresJsDatabase; sql: postgres.Sql }; @Injectable() class DrizzleService extends DatabaseService { constructor(private config = inject(DatabaseConfig)) { super(); } async setup(): Promise { const sql = postgres(this.config.url); return { client: drizzle(sql), sql }; } transaction(client: PostgresJsDatabase, fn: (tx: PostgresJsDatabase) => Promise): Promise { return client.transaction(fn); } async shutdown(): Promise { await this.ready.sql.end(); } } // ---cut--- export const Transaction = createTransactionDecorator(DrizzleService); ``` Apply it to methods that should run in a transaction: ```typescript import { Config, Env, Injectable, inject } from '@zeltjs/core'; import { createTransactionDecorator, DatabaseService } from '@zeltjs/db'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; @Config class DatabaseConfig { constructor(private env = inject(Env)) {} get url(): string { return this.env.getStringOrThrow('DATABASE_URL'); } } type DrizzleReady = { client: PostgresJsDatabase; sql: postgres.Sql }; @Injectable() class DrizzleService extends DatabaseService { constructor(private config = inject(DatabaseConfig)) { super(); } async setup(): Promise { const sql = postgres(this.config.url); return { client: drizzle(sql), sql }; } transaction(client: PostgresJsDatabase, fn: (tx: PostgresJsDatabase) => Promise): Promise { return client.transaction(fn); } async shutdown(): Promise { await this.ready.sql.end(); } } const Transaction = createTransactionDecorator(DrizzleService); type OrderItem = { productId: string; quantity: number }; @Injectable() class OrderRepository { async create(userId: string, items: OrderItem[]) { return { id: 'order-1', userId, items }; } } @Injectable() class InventoryRepository { async decrement(productId: string, quantity: number) {} } // ---cut--- @Injectable() export class OrderService { constructor( private orderRepo = inject(OrderRepository), private inventoryRepo = inject(InventoryRepository), ) {} @Transaction() async placeOrder(userId: string, items: OrderItem[]) { const order = await this.orderRepo.create(userId, items); for (const item of items) { await this.inventoryRepo.decrement(item.productId, item.quantity); } return order; } } ``` All repository calls within `placeOrder` automatically use the same transaction. If any operation fails, the entire transaction rolls back. ### Transaction Middleware For request-scoped transactions, use middleware: ```typescript import { Config, Env, Injectable, inject } from '@zeltjs/core'; import { createTransactionMiddleware, DatabaseService } from '@zeltjs/db'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; @Config class DatabaseConfig { constructor(private env = inject(Env)) {} get url(): string { return this.env.getStringOrThrow('DATABASE_URL'); } } type DrizzleReady = { client: PostgresJsDatabase; sql: postgres.Sql }; @Injectable() class DrizzleService extends DatabaseService { constructor(private config = inject(DatabaseConfig)) { super(); } async setup(): Promise { const sql = postgres(this.config.url); return { client: drizzle(sql), sql }; } transaction(client: PostgresJsDatabase, fn: (tx: PostgresJsDatabase) => Promise): Promise { return client.transaction(fn); } async shutdown(): Promise { await this.ready.sql.end(); } } // ---cut--- export const TransactionMiddleware = createTransactionMiddleware(DrizzleService); ``` Apply to controllers: ```typescript import { Config, Controller, Env, Injectable, Post, UseMiddleware, inject, request } from '@zeltjs/core'; import { createTransactionMiddleware, DatabaseService } from '@zeltjs/db'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as v from 'valibot'; @Config class DatabaseConfig { constructor(private env = inject(Env)) {} get url(): string { return this.env.getStringOrThrow('DATABASE_URL'); } } type DrizzleReady = { client: PostgresJsDatabase; sql: postgres.Sql }; @Injectable() class DrizzleService extends DatabaseService { constructor(private config = inject(DatabaseConfig)) { super(); } async setup(): Promise { const sql = postgres(this.config.url); return { client: drizzle(sql), sql }; } transaction(client: PostgresJsDatabase, fn: (tx: PostgresJsDatabase) => Promise): Promise { return client.transaction(fn); } async shutdown(): Promise { await this.ready.sql.end(); } } const TransactionMiddleware = createTransactionMiddleware(DrizzleService); const PlaceOrderBody = v.object({ userId: v.string(), items: v.array(v.object({ productId: v.string(), quantity: v.number() })), }); @Injectable() class OrderService { async placeOrder(userId: string, items: { productId: string; quantity: number }[]) { return { id: 'order-1', userId, items }; } } // ---cut--- @Controller('/orders') @UseMiddleware(TransactionMiddleware) export class OrderController { constructor(private orderService = inject(OrderService)) {} @Post('/') async create(req = request(PlaceOrderBody)) { const data = await req.body(); return this.orderService.placeOrder(data.userId, data.items); } } ``` Every request to this controller runs in a transaction. ## Transaction Propagation Transactions propagate through async call chains automatically: ```typescript import { Config, Env, Injectable, inject } from '@zeltjs/core'; import { createTransactionDecorator, DatabaseService } from '@zeltjs/db'; import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; @Config class DatabaseConfig { constructor(private env = inject(Env)) {} get url(): string { return this.env.getStringOrThrow('DATABASE_URL'); } } type DrizzleReady = { client: PostgresJsDatabase; sql: postgres.Sql }; @Injectable() class DrizzleService extends DatabaseService { constructor(private config = inject(DatabaseConfig)) { super(); } async setup(): Promise { const sql = postgres(this.config.url); return { client: drizzle(sql), sql }; } transaction(client: PostgresJsDatabase, fn: (tx: PostgresJsDatabase) => Promise): Promise { return client.transaction(fn); } async shutdown(): Promise { await this.ready.sql.end(); } } const Transaction = createTransactionDecorator(DrizzleService); @Injectable() class LedgerRepository { async debit(orderId: string, amount: number) {} } @Injectable() class NotificationService { async sendReceipt(orderId: string) {} } @Injectable() class OrderRepository { async markComplete(orderId: string) {} } // ---cut--- @Injectable() export class PaymentService { constructor( private ledgerRepo = inject(LedgerRepository), private notificationService = inject(NotificationService), ) {} @Transaction() async processPayment(orderId: string, amount: number) { await this.ledgerRepo.debit(orderId, amount); await this.notificationService.sendReceipt(orderId); } } @Injectable() export class OrderService { constructor( private orderRepo = inject(OrderRepository), private paymentService = inject(PaymentService), ) {} @Transaction() async completeOrder(orderId: string) { await this.orderRepo.markComplete(orderId); await this.paymentService.processPayment(orderId, 100); } } ``` When `completeOrder` calls `processPayment`, both run in the same transaction — the inner `@Transaction()` joins the existing transaction rather than starting a new one. ## Lifecycle Integration `DatabaseService` integrates with Zelt's lifecycle system. `DrizzleService` is an ordinary injectable — nothing "registers" it. The first time something injects it (a repository, for example), it is constructed and registered with the `LifecycleManager` automatically. The app runs any pending lifecycles before it starts serving: 1. `setup()` runs during app startup, and its return value is sealed into `this.ready` 2. `shutdown()` runs during app shutdown ## API Reference ### DatabaseService | Property/Method | Description | |----------------|-------------| | `client` | Current database client (transaction-aware) | | `ready` | Protected: `ReadyValue` sealed from `setup()`'s return value; throws if accessed before startup | | `setup()` | Abstract: Create the client and any shutdown handles; the returned object is sealed into `ready` | | `transaction(client, fn)` | Abstract: Execute function in transaction | | `withTransaction(fn)` | Run function in a new or existing transaction | | `shutdown()` | Abstract: Close connections on shutdown | ### Factory Functions | Function | Description | |----------|-------------| | `createTransactionDecorator(Service)` | Create `@Transaction()` decorator | | `createTransactionMiddleware(Service)` | Create transaction middleware class | --- ## Key-Value Store Zelt provides `@zeltjs/kv` for namespace-based key-value storage with TTL support and atomic operations. ## Overview The KV module provides: - **`KVAdaptor` / `AtomicKVAdaptor`** — Top-level adaptors that create namespaced stores - **`KVStore` / `AtomicKVStore`** — Interfaces for data operations (get, set, del, etc.) - **`MemoryKV`** — In-memory implementation with automatic garbage collection - **Promise-based API** — All operations return `Promise` and throw on errors ## Installation ```bash pnpm add @zeltjs/kv ``` ## Basic Usage Inject `MemoryKV` and create a namespaced store: ```typescript import { Injectable, inject } from '@zeltjs/core'; import { MemoryKV, type AtomicKVStore } from '@zeltjs/kv'; interface User { id: string; name: string; } // ---cut--- @Injectable() export class CacheService { private store: AtomicKVStore; constructor(private kv = inject(MemoryKV)) { this.store = this.kv.namespace('cache'); } async getUser(id: string): Promise { return this.store.get(`user:${id}`); } async setUser(id: string, user: User): Promise { await this.store.set(`user:${id}`, user, { ttlSec: 3600 }); } } ``` ## KVStore Methods | Method | Description | |--------|-------------| | `get(key)` | Retrieve a value by key | | `set(key, value, opts?)` | Store a value with optional TTL | | `del(key)` | Delete a key | | `has(key)` | Check if a key exists | | `expire(key, ttlSec)` | Update TTL for an existing key | | `namespace(prefix)` | Create a child namespace | ### TTL (Time-To-Live) ```typescript import { inject } from '@zeltjs/core'; import { MemoryKV } from '@zeltjs/kv'; const store = inject(MemoryKV).namespace('sessions'); // ---cut--- await store.set('session:abc', { userId: '123' }, { ttlSec: 1800 }); // Extend TTL for an existing key (useful for session touch) await store.expire('session:abc', 1800); ``` ## Atomic Operations `AtomicKVStore` extends `KVStore` with atomic operations: | Method | Description | |--------|-------------| | `incr(key, by?, opts?)` | Atomic increment (creates key if missing) | | `setnx(key, value, opts?)` | Set only if key does not exist | ### Rate Limiting with incr ```typescript import { Injectable, inject } from '@zeltjs/core'; import { MemoryKV, type AtomicKVStore } from '@zeltjs/kv'; // ---cut--- @Injectable() export class RateLimiter { private store: AtomicKVStore; constructor(kv = inject(MemoryKV)) { this.store = kv.namespace('ratelimit'); } async checkLimit(clientId: string, limit: number): Promise { const count = await this.store.incr(`req:${clientId}`, 1, { ttlSec: 60 }); return count <= limit; } } ``` ### Distributed Locks with setnx ```typescript import { inject } from '@zeltjs/core'; import { MemoryKV } from '@zeltjs/kv'; const store = inject(MemoryKV).namespace('locks'); // ---cut--- const acquired = await store.setnx('lock:resource', true, { ttlSec: 30 }); if (acquired) { // Lock acquired, do work, then release await store.del('lock:resource'); } ``` ## Namespacing Namespaces provide logical separation of keys. They can be nested: ```typescript import { inject } from '@zeltjs/core'; import { MemoryKV } from '@zeltjs/kv'; const kv = inject(MemoryKV); // ---cut--- const users = kv.namespace('users'); const sessions = kv.namespace('sessions'); const adminSessions = sessions.namespace('admin'); ``` ## Error Handling KV operations throw errors on failure. Use try-catch for error handling: ```typescript import { inject } from '@zeltjs/core'; import { MemoryKV } from '@zeltjs/kv'; const store = inject(MemoryKV).namespace('data'); const value = { data: 'test' }; // ---cut--- try { await store.set('key', value, { ttlSec: -1 }); console.log('Success'); } catch (error) { console.error((error as Error).message); } ``` Error types: `INVALID_TTL`, `EMPTY_NAMESPACE`, `INVALID_VALUE`, `STORE_OPERATION_FAILED`. ## MemoryKV `MemoryKV` is an in-memory implementation for development and testing. It serializes values to JSON and runs garbage collection every 60 seconds. ```typescript import { createApp, Controller, Get, http } from '@zeltjs/core'; import { MemoryKV } from '@zeltjs/kv'; @Controller('/') class AppController { @Get('/') index() { return { ok: true }; } } // ---cut--- const app = createApp([http({ controllers: [AppController], })]); ``` --- ## Redis KV Driver `@zeltjs/kv` ships a Redis backend through its `@zeltjs/kv/adaptor-redis` entry point. `RedisKVAdaptor` implements `AtomicKVAdaptor` on top of [ioredis](https://github.com/redis/ioredis), supporting atomic operations like `incr` and `setnx`. ## Installation ```bash pnpm add @zeltjs/kv @zeltjs/redis ``` Peer dependency: ```bash pnpm add @zeltjs/core ``` ## Basic Setup Inject `RedisKVAdaptor` and create a namespaced store. `namespace()` returns an `AtomicKVStore` directly, and `get()` resolves to the value (or `undefined` when the key is missing) — there is no result wrapper to unwrap: ```typescript twoslash import { Injectable, inject } from '@zeltjs/core'; import { RedisKVAdaptor } from '@zeltjs/kv/adaptor-redis'; import type { AtomicKVStore, Defined } from '@zeltjs/kv'; // ---cut--- @Injectable() export class CacheService { private store: AtomicKVStore; constructor(kv = inject(RedisKVAdaptor)) { this.store = kv.namespace('cache:'); } async get(key: string): Promise { return this.store.get(key); } async set(key: string, value: T, ttlSec?: number): Promise { await this.store.set(key, value, { ttlSec }); } } ``` Register `RedisConfig` and `RedisKVAdaptor` when creating the app. `RedisConfig` provides the connection settings (consumed by `RedisService`, which `RedisKVAdaptor` depends on), so listing `RedisKVAdaptor` in `injectables` is enough — its dependencies resolve automatically: ```typescript twoslash import { createApp, Controller, Get, http } from '@zeltjs/core'; import { RedisKVAdaptor } from '@zeltjs/kv/adaptor-redis'; import { RedisConfig } from '@zeltjs/redis'; @Controller('/app') class AppController { @Get('/') get() { return {}; } } // ---cut--- const app = createApp([http({ controllers: [AppController], })], { configs: [RedisConfig] }); ``` By default, `RedisConfig` reads the connection URL from the `REDIS_URL` environment variable, falling back to `redis://localhost:6379`. ## Custom Configuration Extend `RedisConfig` to customize connection settings. The `options` getter returns ioredis `RedisOptions`: ```typescript twoslash import { Config } from '@zeltjs/core'; import { RedisConfig } from '@zeltjs/redis'; // ---cut--- @Config class CustomRedisConfig extends RedisConfig { override get url(): string { return this.env.getString('REDIS_URL', 'redis://localhost:6379'); } override get options() { return { maxRetriesPerRequest: 3, retryStrategy: (times: number) => Math.min(times * 100, 3000), }; } } ``` Register your custom config instead of the default: ```typescript twoslash import { createApp, Config, Controller, Get, http } from '@zeltjs/core'; import { RedisConfig } from '@zeltjs/redis'; import { RedisKVAdaptor } from '@zeltjs/kv/adaptor-redis'; @Config class CustomRedisConfig extends RedisConfig { override get url(): string { return this.env.getString('REDIS_URL', 'redis://localhost:6379'); } override get options() { return { maxRetriesPerRequest: 3 }; } } @Controller('/app') class AppController { @Get('/') get() { return {}; } } // ---cut--- const app = createApp([http({ controllers: [AppController], })], { configs: [CustomRedisConfig] }); ``` ## API Reference ### RedisKVAdaptor | Method | Description | |--------|-------------| | `namespace(prefix)` | Returns a namespaced `AtomicKVStore` | `RedisKVAdaptor` participates in the application lifecycle. The underlying ioredis connection is owned by `RedisService` and is disconnected automatically on shutdown (see [Graceful Shutdown](#graceful-shutdown)). ### AtomicKVStore Methods | Method | Description | |--------|-------------| | `get(key)` | Retrieve a value, or `undefined` if missing | | `set(key, value, opts?)` | Store a value with optional TTL | | `del(key)` | Delete a key | | `has(key)` | Check if key exists | | `expire(key, ttlSec)` | Update TTL for an existing key | | `incr(key, by?, opts?)` | Atomic increment | | `setnx(key, value, opts?)` | Set if not exists | | `namespace(prefix)` | Create nested namespace | ## Production Setup For production deployments, configure connection pooling and retry behavior: ```typescript twoslash import { Config } from '@zeltjs/core'; import { RedisConfig } from '@zeltjs/redis'; // ---cut--- @Config class ProductionRedisConfig extends RedisConfig { override get options() { return { maxRetriesPerRequest: 3, enableReadyCheck: true, retryStrategy: (times: number) => { if (times > 10) return null; return Math.min(times * 200, 5000); }, }; } } ``` ### Graceful Shutdown You do not need to disconnect Redis manually. `RedisService` registers itself with the lifecycle manager, so when the application shuts down it disconnects the ioredis client automatically. With `@zeltjs/adapter-node`, `onNode` installs `SIGINT`/`SIGTERM` handlers that trigger this shutdown, and `handle.shutdown()` does the same: ```typescript twoslash import { createApp, Controller, Get, http } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; import { RedisKVAdaptor } from '@zeltjs/kv/adaptor-redis'; import { RedisConfig } from '@zeltjs/redis'; @Controller('/app') class AppController { @Get('/') get() { return {}; } } const app = createApp([http({ controllers: [AppController] })], { configs: [RedisConfig] }); const nodeApp = await onNode(app); // ---cut--- const handle = await nodeApp.http.listen({ port: 3000 }); // Disconnects the server and runs lifecycle shutdown (Redis included) await handle.shutdown(); ``` --- ## Unit Testing Zelt provides `@zeltjs/testing` package with utilities for unit testing your services with dependency injection support. ## Installation ```bash pnpm add -D @zeltjs/testing ``` ## Test Runner Adapters Import from the adapter for your test runner. This auto-registers cleanup via `afterAll`. ### Vitest ```typescript // @noErrors // Reason: import-only example for test framework setup import { onTest, createTestTarget } from '@zeltjs/testing/vitest'; ``` ### Jest ```typescript // @noErrors // Reason: import-only example for test framework setup import { onTest, createTestTarget } from '@zeltjs/testing/jest'; ``` ### Bun ```typescript // @noErrors // Reason: import-only example for test framework setup import { onTest, createTestTarget } from '@zeltjs/testing/bun'; ``` ### Node.js Test Runner ```typescript // @noErrors // Reason: import-only example for test framework setup import { onTest, createTestTarget } from '@zeltjs/testing/node'; ``` ### Manual Setup If you prefer manual control or use a different test runner, import from the base package and call `shutdownAll()` yourself: ```typescript // @noErrors // Reason: import-only example for test framework setup import { onTest, createTestTarget, shutdownAll } from '@zeltjs/testing'; import { afterAll } from 'your-test-runner'; afterAll(shutdownAll); ``` ## createTestTarget `createTestTarget` is the primary testing utility for instantiating services with dependency injection. It automatically handles lifecycle management and cleanup. ```typescript import { describe, it, expect } from 'vitest'; import { createTestTarget } from '@zeltjs/testing'; import { Injectable } from '@zeltjs/core'; @Injectable() class UserService { async create(data: { name: string }) { return data; } } // ---cut--- describe('UserService', () => { it('should create user', async () => { const { target } = await createTestTarget(UserService); const user = await target.create({ name: 'Alice' }); expect(user.name).toBe('Alice'); }); }); ``` ### Options | Option | Type | Description | |--------|------|-------------| | `configs` | `Class[]` | Configuration classes to register | | `overrides` | `Override[]` | Mock implementations for dependencies | ### Return Value | Property | Type | Description | |----------|------|-------------| | `target` | `T` | The instantiated service | | `get` | `(cls) => T` | Resolve additional dependencies from the container | | `shutdown` | `() => Promise` | Cleanup function (auto-registered to `shutdownAll`) | ## Mocking Dependencies Use `overrides` to replace real implementations with mocks (Solitary Unit Test): ```typescript import { describe, it, expect, vi } from 'vitest'; import { createTestTarget } from '@zeltjs/testing'; import { Injectable, inject } from '@zeltjs/core'; @Injectable() class EmailService { async send(to: string, subject: string) { return { to, subject }; } } @Injectable() class UserService { constructor(private emailService = inject(EmailService)) {} async register(data: { email: string }) { await this.emailService.send(data.email, 'Welcome!'); } } // ---cut--- describe('UserService', () => { it('should send welcome email', async () => { const mockEmailService = { send: vi.fn().mockResolvedValue(undefined), }; const { target } = await createTestTarget(UserService, { overrides: [ { provide: EmailService, useValue: mockEmailService }, ], }); await target.register({ email: 'alice@example.com' }); expect(mockEmailService.send).toHaveBeenCalledWith( 'alice@example.com', expect.stringContaining('Welcome') ); }); }); ``` ## Lifecycle Management `createTestTarget` automatically registers shutdown functions to `shutdownAll`: 1. **Startup**: All registered `Lifecycle` implementations are started when the test target is created 2. **Shutdown**: Call `shutdownAll()` in your test runner's global teardown (handled automatically by adapter imports) This ensures resources are properly cleaned up even if tests fail. ## Testing Commands When testing CLI commands, you need to create a fresh app instance for each test. App instances cannot be reused after `createRuntime()` is called. ### The Problem Reusing a global app instance causes errors: ```typescript import { describe, it, expect } from 'vitest'; import { createApp, Command, ZeltLifecycleStateError, command } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; @Command({ name: 'greet' }) class GreetCommand { run() { console.log('Hello!'); } } const app = createApp([command([GreetCommand])]); // ---cut--- describe('GreetCommand', () => { it('test 1', async () => { const nodeApp = await onNode(app); await nodeApp.commands.execCommand(['greet']); // works }); it('test 2 — reusing the same app instance throws', async () => { // ❌ Cannot call onNode() on an already-ready app await expect(onNode(app)).rejects.toThrow(ZeltLifecycleStateError); }); }); ``` Once `onNode()` is called, the app transitions to `ready` state. Calling `onNode()` again on the same instance fails because lifecycle hooks cannot be re-registered. ### The Solution Create a new app instance for each test: ```typescript import { describe, it, afterEach } from 'vitest'; import { createApp, Command, command } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; @Command({ name: 'greet' }) class GreetCommand { run() { console.log('Hello!'); } } // ---cut--- describe('GreetCommand', () => { let nodeApp: | { shutdown(): Promise; commands: { execCommand(argv: readonly string[]): Promise<{ exitCode: number }> }; } | undefined; afterEach(async () => { await nodeApp?.shutdown(); }); it('test 1', async () => { const app = createApp([command([GreetCommand])]); nodeApp = await onNode(app); await nodeApp.commands.execCommand(['greet']); // works }); it('test 2', async () => { const app = createApp([command([GreetCommand])]); nodeApp = await onNode(app); await nodeApp.commands.execCommand(['greet']); // works — fresh app instance }); }); ``` ### Using a Factory Function For cleaner tests, extract app creation into a factory: ```typescript import { describe, it, afterEach } from 'vitest'; import { createApp, Command, command } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; @Command({ name: 'greet' }) class GreetCommand { run() { console.log('Hello!'); } } // ---cut--- function createTestApp() { return createApp([command([GreetCommand])]); } describe('GreetCommand', () => { let nodeApp: | { shutdown(): Promise; commands: { execCommand(argv: readonly string[]): Promise<{ exitCode: number }> }; } | undefined; afterEach(async () => { await nodeApp?.shutdown(); }); it('executes successfully', async () => { nodeApp = await onNode(createTestApp()); const result = await nodeApp.commands.execCommand(['greet']); // assert result }); }); ``` --- ## Integration Testing For integration tests that require external services like Redis or PostgreSQL, use Testcontainers. Zelt provides pre-configured container configs that integrate with the lifecycle system. ## Installation ```bash pnpm add -D @zeltjs/testing testcontainers ``` ## Redis Integration Testing ```typescript import { ConfigClass } from '@zeltjs/core'; declare function describe(name: string, fn: () => void): void; declare function it(name: string, fn: () => void | Promise): void; declare function expect(value: T): { toBe(expected: T): void; }; declare function createTestTarget(cls: new (...args: never[]) => T, opts?: { configs?: readonly ConfigClass[] }): Promise<{ target: T; shutdown: () => Promise }>; import { RedisTestContainerConfig } from '@zeltjs/redis/testing'; declare class CacheService { set(key: string, value: string): Promise; get(key: string): Promise; } // ---cut--- describe('CacheService', () => { it('should cache values in Redis', async () => { const { target } = await createTestTarget(CacheService, { configs: [RedisTestContainerConfig], }); await target.set('key', 'value'); const result = await target.get('key'); expect(result).toBe('value'); }); }); ``` `RedisTestContainerConfig` automatically: - Starts a Redis container before tests - Provides connection URL to services depending on `RedisConfig` - Stops and cleans up the container after tests ## Custom Container Config Create your own container config by implementing the `Lifecycle` interface: ```typescript import { Config, inject, LifecycleManager, type Lifecycle } from '@zeltjs/core'; declare class GenericContainer { constructor(image: string); withEnvironment(env: Record): this; withExposedPorts(port: number): this; start(): Promise; } declare interface StartedTestContainer { getHost(): string; getMappedPort(port: number): number; stop(): Promise; } // ---cut--- @Config export class PostgresTestContainerConfig implements Lifecycle { private container: StartedTestContainer | undefined; private connectionUrl = ''; constructor(lifecycle = inject(LifecycleManager)) { lifecycle.register(this); } async startup(): Promise { this.container = await new GenericContainer('postgres:16-alpine') .withEnvironment({ POSTGRES_USER: 'test', POSTGRES_PASSWORD: 'test', POSTGRES_DB: 'testdb', }) .withExposedPorts(5432) .start(); const host = this.container.getHost(); const port = this.container.getMappedPort(5432); this.connectionUrl = `postgres://test:test@${host}:${port}/testdb`; } async shutdown(): Promise { await this.container?.stop(); } get url(): string { return this.connectionUrl; } } ``` ## Sociable Unit Tests Integration tests with Testcontainers are ideal for "Sociable Unit Tests" — testing units that collaborate with real dependencies rather than mocks: ```typescript import { ConfigClass } from '@zeltjs/core'; declare function describe(name: string, fn: () => void): void; declare function it(name: string, fn: () => void | Promise): void; declare function expect(value: T): { toBe(expected: T): void; }; type TestTargetResult = { target: T; get: (cls: new (...args: never[]) => U) => U; shutdown: () => Promise }; declare function createTestTarget(cls: new (...args: never[]) => T, opts?: { configs?: readonly ConfigClass[] }): Promise>; import { RedisTestContainerConfig } from '@zeltjs/redis/testing'; declare class SessionService { create(data: { userId: string }): Promise<{ id: string }>; } declare class UserService { fromSession(sessionId: string): Promise<{ id: string }>; } // ---cut--- describe('SessionService with real Redis', () => { it('should persist session across service calls', async () => { const { target, get } = await createTestTarget(SessionService, { configs: [RedisTestContainerConfig], }); const userService = get(UserService); const session = await target.create({ userId: '123' }); const user = await userService.fromSession(session.id); expect(user.id).toBe('123'); }); }); ``` --- ## E2E Testing Test your application's HTTP endpoints end-to-end using Hono's built-in request helper or the type-safe client. ## HTTP Testing ```typescript import { createApp, Controller, Get, request, http } from '@zeltjs/core'; declare function describe(name: string, fn: () => void): void; declare function it(name: string, fn: () => void | Promise): void; declare function expect(value: T): { toBe(expected: T): void; toEqual(expected: unknown): void; }; @Controller('/hello') class HelloController { @Get('/:name') greet(req = request()) { const name = req.pathParam('name'); return { message: `Hello, ${name}!` }; } } const app = createApp([http({ controllers: [HelloController] })]); const readyApp = await app.createRuntime(); // ---cut--- describe('Hello API', () => { it('should return greeting', async () => { const res = await readyApp.http.request('/hello/world'); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual({ message: 'Hello, world!' }); }); }); ``` ## Testing with Type-Safe Client Use the generated `AppType` with Hono's client for fully typed tests. See [OpenAPI & Type Generation](../openapi.md) for how to generate `AppType`. ```typescript import { createApp, Controller, Get, request, http } from '@zeltjs/core'; declare function hc(baseUrl: string, options?: { fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise }): T; declare function describe(name: string, fn: () => void): void; declare function it(name: string, fn: () => void | Promise): void; declare function expect(value: T): { toBe(expected: T): void; }; @Controller('/hello') class HelloController { @Get('/:name') greet(req = request()) { const name = req.pathParam('name'); return { message: `Hello, ${name}!` }; } } const app = createApp([http({ controllers: [HelloController] })]); const readyApp = await app.createRuntime(); type AppType = { hello: { ':name': { $get: (opts: { param: { name: string } }) => Promise }> } } }; // ---cut--- describe('Hello API', () => { const client = hc('http://localhost', { fetch: (input: RequestInfo | URL, init?: RequestInit) => readyApp.http.fetch(new Request(input, init)), }); it('should return greeting with type safety', async () => { const res = await client.hello[':name'].$get({ param: { name: 'world' } }); expect(res.status).toBe(200); const body = await res.json(); expect(body.message).toBe('Hello, world!'); }); }); ``` ## Full Application Testing For complete E2E tests with real dependencies, use `onTest()` to apply test config overrides to your production app: ```typescript import { createApp, Controller, Get, Post, response, http } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import { onTest } from '@zeltjs/testing/vitest'; import { RedisConfig } from '@zeltjs/redis'; import { RedisTestContainerConfig } from '@zeltjs/redis/testing'; import * as v from 'valibot'; declare function hc(baseUrl: string, options?: { fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise }): T; declare function describe(name: string, fn: () => void): void; declare function it(name: string, fn: () => void | Promise): void; declare function beforeAll(fn: () => void | Promise): void; declare function expect(value: T): { toBe(expected: T): void; }; const UserBody = v.object({ name: v.string(), email: v.pipe(v.string(), v.email()) }); @Controller('/users') class UserController { @Get('/:id') findOne(req = request()) { const id = req.pathParam('id'); return { id, name: 'Alice', email: 'alice@example.com' }; } @Post('/') async create(req = request(UserBody), res = response()) { const body = await req.body(); return res.json({ id: '1', ...body }, 201); } } type AppType = { users: { $post: (opts: { json: { name: string; email: string } }) => Promise }>; ':id': { $get: (opts: { param: { id: string } }) => Promise }> }; }; }; // ---cut--- // Production app - same as your real application const app = createApp([http({ controllers: [UserController] })], { configs: [RedisConfig] }); describe('API E2E', () => { let testApp: Awaited>; let client: AppType; beforeAll(async () => { // onTest() overrides RedisConfig with RedisTestContainerConfig testApp = await onTest(app, { configs: [RedisTestContainerConfig], }); client = hc('http://localhost', { fetch: (input: RequestInfo | URL, init?: RequestInit) => testApp.http.fetch(new Request(input, init)), }); }); it('should create and retrieve user', async () => { const createRes = await client.users.$post({ json: { name: 'Alice', email: 'alice@example.com' }, }); expect(createRes.status).toBe(201); const { id } = await createRes.json(); const getRes = await client.users[':id'].$get({ param: { id }, }); expect(getRes.status).toBe(200); const user = await getRes.json(); expect(user.name).toBe('Alice'); }); }); ``` --- ## Hono Client Zelt generates type-safe client types (`AppType`) for Hono's `hc` client — enabling fully type-safe API calls with IDE autocomplete. ## Overview The `@zeltjs/hono-client` package generates `AppType` from your controller signatures. This type integrates with Hono's `hc` client to provide: - Full TypeScript inference for request parameters and response bodies - IDE autocomplete for API endpoints - Compile-time type checking for API calls ## Installation ```bash pnpm add @zeltjs/hono-client ``` ## Configuration (CLI Plugin) Add `honoClientPlugin` to your `zelt.config.ts`: ```typescript declare function defineConfig(config: { entry: string; plugins?: any[]; }): any; declare function honoClientPlugin(options?: { entry?: string; outDir?: string; output?: string; }): any; // ---cut--- export default defineConfig({ entry: './dist/app.js', plugins: [ honoClientPlugin({ outDir: './generated', output: 'app-type.ts', }), ], }); ``` ### Plugin Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `entry` | `string` | config.entry | Path to built app module | | `outDir` | `string` | `'./generated'` | Output directory | | `output` | `string` | `'app-type.ts'` | Output filename | ## Generating AppType Run `zelt build` to generate the type file: ```bash pnpm zelt build ``` This generates `/` (default: `generated/app-type.ts`) containing the `AppType`. ## Programmatic Generation When your build is not driven by the Zelt CLI — an electron-vite pipeline, a custom build script, a monorepo task runner — use `GeneratorService.generateFromApp()` directly. It takes the `http` feature of your app and returns the generated type source as a string: ```typescript import { createApp, http } from '@zeltjs/core'; import { GeneratorService } from '@zeltjs/hono-client'; declare function writeFileSync(path: string, data: string, encoding: 'utf-8'): void; const app = createApp([http({ controllers: [] })]); // ---cut--- const generator = new GeneratorService(); const content = await generator.generateFromApp(app.http, { distDir: './dist' }); writeFileSync('./dist/app-type.generated.ts', content, 'utf-8'); ``` Run this as a build step after your app modules are compiled — for example a script executed by `node` against the built output, or an electron-vite `buildStart` hook. ### Generate Options | Option | Type | Description | |--------|------|-------------| | `distDir` | `string` | Directory of the built app output; generated imports are resolved against it | | `portable` | `boolean` | Emit a self-contained type file with resolved literal types instead of imports into `distDir` | | `tsconfig` | `string` | Path to the project tsconfig (required when `portable: true`) | | `projectRoot` | `string` | Project root for path resolution (required when `portable: true`) | Use `portable: true` when the generated file is consumed outside the server package — for example an Electron renderer or a separate frontend workspace that cannot import from the server's `dist`. ### Generated app-type.ts ```typescript import type { Route, BuildAppType } from '@zeltjs/hono-client'; // ---cut--- // THIS FILE IS GENERATED BY @zeltjs/hono-client. DO NOT EDIT. export type AppType = BuildAppType<[ Route<'GET', '/hello/:name', () => { message: string }>, Route<'POST', '/hello', (input: { name: string }) => { id: string }>, ]>; ``` ## Using AppType ### Type-Safe API Client ```typescript type AppType = { hello: { ':name': { $get(args: { param: { name: string } }): Promise }>; }; }; }; declare function hc(baseUrl: string): T; // ---cut--- const client = hc('https://api.example.com'); // Fully typed - IDE autocomplete and type checking const response = await client.hello[':name'].$get({ param: { name: 'world' }, }); if (response.ok) { const data = await response.json(); // data is typed as { message: string } console.log(data.message); } ``` ### Testing with Type-Safe Client ```typescript import { createApp, Controller, Get, request, http } from '@zeltjs/core'; declare function describe(name: string, fn: () => void): void; declare function it(name: string, fn: () => Promise): void; declare function expect(value: any): { toBe(expected: any): void }; type AppType = { hello: { ':name': { $get(args: { param: { name: string } }): Promise }>; }; }; }; declare function hc(baseUrl: string, options?: { fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise }): T; @Controller('/hello') class HelloController { @Get('/:name') greet(req = request()) { const name = req.pathParam('name'); return { message: `Hello, ${name}!` }; } } const app = createApp([http({ controllers: [HelloController] })]); const readyApp = await app.createRuntime(); // ---cut--- describe('Hello API', () => { const client = hc('http://localhost', { fetch: (input, init) => readyApp.http.fetch(new Request(input, init)), }); it('should return greeting', async () => { const res = await client.hello[':name'].$get({ param: { name: 'world' }, }); expect(res.status).toBe(200); const body = await res.json(); expect(body.message).toBe('Hello, world!'); }); }); ``` ## How It Works 1. **Metadata Extraction** — Reads route metadata from your Zelt app at build time 2. **Type Generation** — Generates `AppType` from the extracted route information 3. **Client Integration** — The generated type integrates with Hono's `hc` client The generated `AppType` maps your controller routes to Hono's route types, enabling the `hc` client to infer parameter and response types automatically. --- ## OpenAPI Zelt automatically generates OpenAPI 3.1 specifications from your controllers — no decorators or annotations required. ## Overview The `@zeltjs/openapi` package analyzes your controller method signatures at build time and generates a standard OpenAPI 3.1 specification. ## Installation import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; ```bash npm install @zeltjs/openapi ``` ```bash pnpm add @zeltjs/openapi ``` ```bash bun add @zeltjs/openapi ``` ### With the Valibot adapter If you're generating schemas from Valibot, use `@zeltjs/validator-valibot/openapi` and install `@valibot/to-json-schema`: ```bash npm install @zeltjs/openapi @valibot/to-json-schema ``` ```bash pnpm add @zeltjs/openapi @valibot/to-json-schema ``` ```bash bun add @zeltjs/openapi @valibot/to-json-schema ``` :::tip Version Compatibility `@valibot/to-json-schema` must match your `valibot` version. See [Validation - Installation](./validation.md#installation) for details. ::: ## Configuration Create a `zelt.config.ts` file in your project root: ```typescript type OpenApiConfig = { controllers: string[]; dist: string; tsconfig: string; }; declare function defineConfig(config: OpenApiConfig): OpenApiConfig; // ---cut--- export default defineConfig({ controllers: ['./src/**/*.controller.ts'], dist: './generated', tsconfig: './tsconfig.json', }); ``` ### Configuration Options | Option | Type | Description | |--------|------|-------------| | `controllers` | `string[]` | Glob patterns to find controller files | | `dist` | `string` | Output directory for generated files | | `tsconfig` | `string` | Path to tsconfig.json (required for OpenAPI generation) | Controllers are automatically discovered by scanning files matching the glob patterns and detecting classes with `@Controller` decorator. ## Generating OpenAPI Spec ### One-time Build ```bash pnpm zelt-openapi build ``` This generates `/openapi.json`. ### Watch Mode ```bash pnpm zelt-openapi watch ``` Continuously regenerates when controllers change. ### npm Scripts Add to your `package.json`: ```json { "scripts": { "generate": "zelt-openapi build", "generate:watch": "zelt-openapi watch" } } ``` ## Generated openapi.json Standard OpenAPI 3.1 specification: ```json { "openapi": "3.1.0", "info": { "title": "zelt app", "version": "0.0.0" }, "paths": { "/hello/{name}": { "get": { "parameters": [...], "responses": {...} } } }, "components": { "schemas": {...} } } ``` ## How It Works Zelt uses a "zero-annotation" approach inspired by [Scramble](https://scramble.dedoc.co/): 1. **Static Analysis** — Analyzes controller method signatures at build time 2. **Type Extraction** — Extracts request/response types from TypeScript types 3. **Schema Generation** — Converts TypeScript types to JSON Schema for OpenAPI This means your runtime code stays clean — no decorators or schema definitions needed beyond what you already write for validation. --- ## GraphQL `@zeltjs/graphql` is experimental. The runtime manifest shape and generated helper APIs may change before stable release. GraphQL support is built around a shared runtime manifest: - `schemaSdl` - resolver bindings - runtime metadata such as enum, scalar, and union mappings The executor consumes the runtime manifest. Code-first and schema-first are frontends that produce the same manifest, which is delivered to the running app as a prebuilt module rather than loaded by the app itself. ```mermaid flowchart LR subgraph CF["Code-first"] CFR["resolvers(args() schemas)"] end subgraph SF["Schema-first"] SFS["schema.graphql"] -- "zelt graphql codegen" --> SFH["typed helpers"] SFR["resolvers"] -. "import" .-> SFH end CFR -- "zelt build / dev" --> GRT["generated GraphQL runtime"] SFR -- "zelt build / dev" --> GRT GRT -- "app start" --> EP(["/graphql"]) classDef sot fill:#f8fafc,stroke:#334155,stroke-width:2px classDef gen fill:#eef2f7,stroke:#cbd5e1,stroke-dasharray:5 5 class CFR,SFS,SFR sot class SFH,GRT gen ``` Solid nodes are sources you edit; dashed nodes are generated. When you change a source, rerun the command on its outgoing edge — stale generated output surfaces as type or build errors. ## API boundary Supported experimental app-authoring APIs: - `graphql()` - `Resolver` - `Query` - `Mutation` - `ResolveField` - `args()` - `gqlScalar()` - `GqlOutput` `graphql({ path, resolvers, schema? })` declares the endpoint. Whether the endpoint is code-first or schema-first is decided per endpoint, by whether `schema` is passed: omit it for code-first, or pass the `schema` export from a `zelt graphql codegen`-generated helper for schema-first. Either way, `graphql()` never references the generated runtime directly — that is supplied separately, through the adapter's `prebuilt` option (see Build flow below). Each endpoint has an identity key: `graphql({ path, resolvers, name })` — an optional `name`, defaulting to `'graphql'` when omitted, same convention as `http()`'s `name` option. The key namespaces the endpoint's prebuilt entry and generated filename. Mounting more than one `graphql()` requires a distinct `name` per endpoint; two endpoints sharing a key is a build-time error. `name` must match `/^[A-Za-z0-9_-]+$/` and cannot be a reserved Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`-`9`, `LPT1`-`9`); `graphql()` throws immediately for an invalid `name`. Any mix of endpoints — multiple schema-first lines, multiple code-first lines, or both together — can coexist in the same app; each line binds only its own schema (or resolver-derived SDL) and resolvers. Generated-code APIs are exported for schema-first helpers only: - `readGraphqlArgs()` - `validateGraphqlArgs()` Build-time APIs such as `graphqlPlugin()`, `generateGraphqlSdl()`, `generateSdlForResolvers()`, schema-first codegen, metadata inspection, and type conversion are exported from `@zeltjs/graphql/codegen` only. Runtime integration APIs used by adapters and framework internals — including `createGraphqlExecutor()`, `executeGraphqlRequest()`, `GraphqlRuntimeManifest`, `GeneratedGraphqlRuntime`, `GraphqlPrebuiltEntry`, and `computeGraphqlPrebuiltHash()` — remain on `@zeltjs/graphql`. Application code does not normally need them. ## Code-first ```ts no-check import { createApp, http } from '@zeltjs/core'; import { args, graphql, Query, Resolver } from '@zeltjs/graphql'; import * as v from 'valibot'; const GetProductInput = v.object({ id: v.string(), }); type Product = { readonly id: string; readonly name: string; }; @Resolver() class ProductResolver { @Query() product(input = args(GetProductInput)): Product { return { id: input.id, name: 'Keyboard' }; } } export const app = createApp([ http({ children: [ graphql({ path: '/graphql', resolvers: [ProductResolver], }), ], }), ]); ``` `args(schema)` defines GraphQL field arguments from a Standard Schema and validates them at runtime. ## Schema-first ```graphql type Query { product(id: ID!): Product } type Product { id: ID! name: String! } ``` ```bash zelt graphql codegen --schema src/graphql/schema.graphql --out src/generated/graphql.ts ``` This writes `src/generated/graphql.ts` with a `Gql` namespace of typed helpers and a `schema` export (`{ sdl }`) that identifies this schema. It also upserts an entry into `/.zelt/graphql-codegen.json`, pairing the schema's content hash with this helper's path — `graphqlPlugin()` uses that pairing later to find where to write resolverChecks for an endpoint (see Build flow below). ```ts no-check import { Query, Resolver } from '@zeltjs/graphql'; import { Gql } from '../../generated/graphql'; @Resolver() class ProductResolver { @Query() product(input = Gql.Query.product.args()): Gql.Query.product.Result { return { id: input.id, name: 'Keyboard' }; } } ``` Pass the helper's `schema` export to `graphql()` to bind this endpoint to it: ```ts no-check import { createApp, http } from '@zeltjs/core'; import { graphql } from '@zeltjs/graphql'; import { schema } from './generated/graphql'; import { ProductResolver } from './graphql/product.resolver'; export const app = createApp([ http({ children: [ graphql({ path: '/graphql', resolvers: [ProductResolver], schema, }), ], }), ]); ``` Each schema-first `graphql()` endpoint is paired with its schema this way — in application code, not through plugin configuration. Because the pairing lives at the `graphql()` call site, an app can mount several schema-first lines (or mix schema-first and code-first lines) side by side, each with its own `name`: ```ts no-check graphql({ name: 'storefront', path: '/graphql', resolvers: [...], schema: storefrontSchema }), graphql({ name: 'admin', path: '/admin/graphql', resolvers: [...], schema: adminSchema }), ``` `storefrontSchema` and `adminSchema` come from two separate `zelt graphql codegen` runs, each with its own `--out`. The two lines never share resolvers or schema — a query sent to `/graphql` only sees the storefront schema's fields, and a query sent to `/admin/graphql` only sees the admin schema's. Additional runtime validation can be layered onto generated helpers: ```ts no-check @Query() product(input = Gql.Query.product.args(GetProductInput)): Gql.Query.product.Result { return { id: input.id, name: 'Keyboard' }; } ``` In schema-first mode, SDL remains the source of truth. A Standard Schema passed to generated args helpers is treated as additional validation. `args()` is intentionally not part of the user-facing API. Schema-first types should come from generated helpers, not handwritten generic arguments. ## Build flow Only the platform entry file imports build-generated runtime output — everything under `.zelt/`. The app definition never imports it, so building an app for the first time never hits a chicken-and-egg problem there. Schema-first apps do import generated code (the typed helpers `zelt graphql codegen` writes to `src/generated/graphql.ts`), but codegen runs directly from `schema.graphql` and doesn't require evaluating the app, so no chicken-and-egg problem arises there either. ```ts no-check import { graphqlPlugin } from '@zeltjs/graphql/codegen'; ``` Register the plugin in `zelt.config.ts`: ```ts no-check import { defineConfig } from '@zeltjs/cli'; import { graphqlPlugin } from '@zeltjs/graphql/codegen'; export default defineConfig({ app: () => import('./src/app').then((m) => m.app), plugins: [graphqlPlugin()], build: { entry: './src/node.ts' }, dev: { entry: './src/node.ts' }, }); ``` `zelt build` and `zelt dev` then run two generation steps automatically: 1. Each registered `graphqlPlugin()` writes `.zelt/graphql/.runtime.ts` (`export const graphqlPrebuilt = { runtime, resolversHash }`) and a sibling `.graphql` SDL file, one pair per `graphql({ path, resolvers })` endpoint. `` is the endpoint's validated key (its `name`, or `graphql` when omitted; see above), so two `graphql()` endpoints only collide if they share a key — pass a distinct `name` to each to disambiguate. 2. The CLI collects every plugin's contributions and writes `.zelt/prebuilt.ts` (`export const zeltPrebuilt = {...} satisfies ZeltPrebuilt`), which re-exports each generated module under its key, namespaced under the `graphql` feature. This file is always generated, even when no plugin contributes anything. The platform entry file statically imports `zeltPrebuilt` and passes it to the adapter: ```ts no-check import { onNode } from '@zeltjs/adapter-node'; import { app } from './app'; import { zeltPrebuilt } from '../.zelt/prebuilt'; const nodeApp = await onNode(app, { prebuilt: zeltPrebuilt }); ``` Every adapter (`onNode`, `onBun`, `onCloudflareWorkers`, `onElectron`, `onLambda`) accepts the same `prebuilt` option. Because the entry file uses only a static `import`, this works unmodified under bundlers that require static imports, such as the Cloudflare Workers `wrangler` bundle — there is no filesystem fallback on any platform. A single `graphqlPlugin()` handles every endpoint in the app — code-first, schema-first, or a mix — because each endpoint carries its own line (see API boundary above). There is no `mode` option to choose between them. ### Code-first 1. Write resolvers. 2. Configure `graphql({ path, resolvers })`. 3. Add `graphqlPlugin()` to `plugins` in `zelt.config.ts`. 4. Run `zelt build` or `zelt dev`. 5. The platform entry file imports `zeltPrebuilt` from `../.zelt/prebuilt` and passes it to the adapter. ### Schema-first 1. Write `schema.graphql`. 2. Run `zelt graphql codegen --schema ... --out ...`. 3. Write resolvers using generated `Gql` helpers. 4. Configure `graphql({ path, resolvers, schema })`, passing the helper's `schema` export. 5. Add `graphqlPlugin()` to `plugins` in `zelt.config.ts` (same as code-first — no schema-first-specific options). 6. Run `zelt build` or `zelt dev`. 7. The platform entry file imports `zeltPrebuilt` from `../.zelt/prebuilt` and passes it to the adapter. Repeat steps 1-4 with a different `--out` and a distinct `name` per endpoint to add another schema-first line. Automatic schema-first codegen during `zelt dev` is not part of this release boundary. Run `zelt graphql codegen` explicitly whenever `schema.graphql` changes, before `zelt build`/`zelt dev` — a missing or stale codegen manifest entry (`/.zelt/graphql-codegen.json`) fails the build with an error telling you to rerun it. Every schema-first endpoint automatically gets a type-check file asserting each resolver method's return type is assignable to the corresponding generated `Gql.Query`/`Gql.Mutation` result type. It is written next to the codegen helper the endpoint's schema hashes to (`.resolver-checks.ts`, or `..resolver-checks.ts` when two endpoints share a helper) — there is no configuration for this; `graphqlPlugin()` discovers the pairing through the codegen manifest. ## Current limitations GraphQL requires the prebuilt module. If it is missing, if the endpoint's prebuilt entry is missing, or if the entry file does not import `zeltPrebuilt`, the endpoint throws at startup — there is no silent fallback on any platform. Each endpoint is looked up in the prebuilt module by its key (the endpoint's `name`, or `graphql` when omitted). If no entry exists under that key — because the prebuilt is missing the endpoint, or it hasn't been built yet — startup throws and tells you to rerun `zelt build`. Once an entry is found, its embedded `resolversHash` — a SHA-256 fingerprint over the endpoint path and the sorted resolver class names — is checked against the value recomputed from the running `graphql({ path, resolvers })` declaration. A mismatch means the prebuilt entry is stale relative to the current resolvers, and startup throws telling you to rerun `zelt build`. v1 only fingerprints the endpoint path and resolver class names — changes to resolver method signatures, argument types, or return types are not detected and still require rerunning `zelt build` manually. ### Code-first - Output type support is intentionally narrow. - Complex GraphQL interfaces are limited. - Code-first supports custom scalar codecs and named unions experimentally. - Field names default to method names. Explicit names are supported through decorators where available. - Field args use Standard Schema runtime validation and require a schema adapter for SDL generation. ### Schema-first - Schema-first codegen currently supports built-in scalars, object types, `Query`, and `Mutation`. - Custom scalars, enums, unions, interfaces, and input objects are intentionally limited or deferred. - Schema-first support for custom scalar codecs and named unions is still limited and will be expanded separately. - Root `Query` and `Mutation` fields must have resolver bindings. - Object fields may rely on GraphQL default field resolution. - Generated `Gql.Query..args()` helpers are the main schema-first args API. - User-facing `args()` is intentionally not supported. --- ## Using BullMQ [BullMQ](https://docs.bullmq.io/) is a powerful job queue library for Node.js backed by Redis. This guide shows how to integrate BullMQ with Zelt using dependency injection and lifecycle management. ## Installation ```bash pnpm add bullmq ioredis ``` ## Basic Setup Create a service that manages the Redis connection and exposes the BullMQ client: ```typescript import { Injectable, inject, Config, Env, LifecycleManager, type Lifecycle } from '@zeltjs/core'; import { Redis, type RedisOptions } from 'ioredis'; import { Queue, Worker, type Job } from 'bullmq'; // ---cut--- @Config class BullMQConfig { constructor(private env = inject(Env)) {} get connection(): RedisOptions { return { host: this.env.getString('REDIS_HOST', 'localhost'), port: this.env.getNumber('REDIS_PORT', 6379), }; } } @Injectable() class BullMQService implements Lifecycle { readonly client: Redis; constructor( private config = inject(BullMQConfig), lifecycle = inject(LifecycleManager), ) { this.client = new Redis(this.config.connection); lifecycle.register(this); } async startup(): Promise {} async shutdown(): Promise { await this.client.quit(); } } ``` ## Creating Queues Inject `BullMQService` and create queues using the shared connection: ```typescript import { Injectable, inject } from '@zeltjs/core'; import { Redis } from 'ioredis'; import { Queue } from 'bullmq'; declare class BullMQService { readonly client: Redis; } // ---cut--- @Injectable() class EmailService { private readonly queue: Queue; constructor(bullmq = inject(BullMQService)) { this.queue = new Queue('email', { connection: bullmq.client }); } async sendWelcomeEmail(to: string): Promise { await this.queue.add('welcome', { to, subject: 'Welcome!', body: '...' }); } async sendPasswordReset(to: string, token: string): Promise { await this.queue.add('password-reset', { to, token }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 }, }); } } ``` ## Creating Workers Workers process jobs from the queue. Register them with the lifecycle manager for graceful shutdown: ```typescript import { Injectable, inject, LifecycleManager, type Lifecycle } from '@zeltjs/core'; import { Redis } from 'ioredis'; import { Worker, type Job } from 'bullmq'; declare class BullMQService { readonly client: Redis; } declare class EmailClient { send(to: string, subject: string, body: string): Promise; sendPasswordReset(to: string, token: string): Promise; } type EmailJobData = { to: string; subject?: string; body?: string; token?: string }; // ---cut--- @Injectable() class EmailWorker implements Lifecycle { private readonly worker: Worker; constructor( bullmq = inject(BullMQService), private emailClient = inject(EmailClient), lifecycle = inject(LifecycleManager), ) { this.worker = new Worker('email', this.process.bind(this), { connection: bullmq.client, concurrency: 5, }); lifecycle.register(this); } private async process(job: Job): Promise { switch (job.name) { case 'welcome': await this.emailClient.send(job.data.to, job.data.subject!, job.data.body!); break; case 'password-reset': await this.emailClient.sendPasswordReset(job.data.to, job.data.token!); break; } } async startup(): Promise {} async shutdown(): Promise { await this.worker.close(); } } ``` ## Using in Controllers Enqueue jobs from your HTTP controllers: ```typescript import { Controller, Post, inject } from '@zeltjs/core'; import { request } from '@zeltjs/core'; import * as v from 'valibot'; declare class EmailService { sendWelcomeEmail(to: string): Promise; } // ---cut--- @Controller('/users') class UserController { constructor(private emailService = inject(EmailService)) {} @Post('/register') async register(req = request(v.object({ email: v.string() }))) { const body = await req.body(); // ... create user await this.emailService.sendWelcomeEmail(body.email); return { message: 'User registered' }; } } ``` ## App Configuration Register your services in the app: ```typescript import { createApp, Config, Env, inject, http } from '@zeltjs/core'; type ConnectionOptions = { host?: string; port?: number }; declare class UserController {} @Config class BullMQConfig { constructor(private env = inject(Env)) {} get connection(): ConnectionOptions { return { host: 'localhost', port: 6379 }; } } // ---cut--- const app = createApp([http({ controllers: [UserController] })], { configs: [BullMQConfig] }); export default app; ``` To start workers, ensure they are instantiated at startup: ```typescript import { createApp, inject, Config, Env, http } from '@zeltjs/core'; type ConnectionOptions = { host?: string; port?: number }; declare class UserController {} declare class EmailWorker {} @Config class BullMQConfig { constructor(private env = inject(Env)) {} get connection(): ConnectionOptions { return { host: 'localhost', port: 6379 }; } } // ---cut--- const app = createApp([http({ controllers: [UserController] })], { configs: [BullMQConfig] }); // Instantiate worker to start processing const readyApp = await app.createRuntime(); await readyApp.get(EmailWorker); ``` ## Custom Configuration Extend `BullMQConfig` for different environments: ```typescript import { Config, Env, inject } from '@zeltjs/core'; type ConnectionOptions = { host?: string; port?: number; password?: string; tls?: object }; @Config class BullMQConfig { constructor(protected env = inject(Env)) {} get connection(): ConnectionOptions { return { host: 'localhost', port: 6379 }; } } // ---cut--- @Config class ProductionBullMQConfig extends BullMQConfig { override get connection(): ConnectionOptions { return { host: this.env.getRequired('REDIS_HOST'), port: this.env.getNumber('REDIS_PORT', 6379), password: this.env.getString('REDIS_PASSWORD') || undefined, tls: this.env.getBoolean('REDIS_TLS') ? {} : undefined, }; } } ``` ## Job Options BullMQ supports many job options. Use them directly: ```typescript import { Queue } from 'bullmq'; const queue = new Queue('reports', { connection: { host: 'localhost', port: 6379 } }); // ---cut--- await queue.add('report', { userId: 123 }, { delay: 60000, // Delay 1 minute attempts: 5, // Retry 5 times backoff: { type: 'exponential', delay: 2000 }, priority: 1, // Higher priority removeOnComplete: 100, // Keep last 100 completed removeOnFail: 50, // Keep last 50 failed }); ``` ## Scheduled Jobs For recurring jobs, use BullMQ's repeat feature: ```typescript import { Queue } from 'bullmq'; const queue = new Queue('reports', { connection: { host: 'localhost', port: 6379 } }); // ---cut--- await queue.add('daily-report', {}, { repeat: { pattern: '0 9 * * *', // Every day at 9:00 tz: 'Asia/Tokyo', }, }); ``` ## Monitoring Use [Bull Board](https://github.com/felixmosh/bull-board) or [Arena](https://github.com/bee-queue/arena) to monitor your queues. These integrate directly with BullMQ. ## Testing For testing, use a separate Redis instance or mock the queue: ```typescript import { describe, it, vi, expect } from 'vitest'; import { Injectable } from '@zeltjs/core'; import { Queue } from 'bullmq'; declare class BullMQService { readonly client: unknown; } @Injectable() class EmailService { private readonly queue: Queue; constructor(bullmq: Pick) { this.queue = new Queue('email', { connection: bullmq.client as any }); } async sendWelcomeEmail(to: string): Promise { await this.queue.add('welcome', { to, subject: 'Welcome!', body: '...' }); } } // ---cut--- describe('EmailService', () => { it('enqueues welcome email', async () => { const mockQueue = { add: vi.fn() }; const service = new EmailService({ client: {} }); (service as any).queue = mockQueue; await service.sendWelcomeEmail('test@example.com'); expect(mockQueue.add).toHaveBeenCalledWith('welcome', { to: 'test@example.com', subject: 'Welcome!', body: '...', }); }); }); ``` For integration tests, use Testcontainers with a test config override: ```typescript import { Config, Env, inject } from '@zeltjs/core'; declare class GenericContainer { constructor(image: string); withExposedPorts(port: number): this; start(): Promise<{ getHost(): string; getMappedPort(port: number): number }>; } type ConnectionOptions = { host?: string; port?: number }; @Config class BullMQConfig { constructor(protected env = inject(Env)) {} get connection(): ConnectionOptions { return { host: 'localhost', port: 6379 }; } } // ---cut--- const redis = await new GenericContainer('redis:7').withExposedPorts(6379).start(); @Config class TestBullMQConfig extends BullMQConfig { override get connection() { return { host: redis.getHost(), port: redis.getMappedPort(6379), }; } } // Use TestBullMQConfig in your test app setup ```