# 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. ## 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. ## Context Sharing Middleware can share data with handlers via `setContext()` and `getContext()`. ### Type-Safe Context Define your context shape using module augmentation: ```typescript // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import '@zeltjs/core'; // ---cut--- declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: number; name: string }; } } ``` ### Setting Context in Middleware ```typescript // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import { Middleware, request, setContext, type Next } from '@zeltjs/core'; declare function verifyToken(token: string | undefined): Promise<{ id: number; name: string }>; declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: number; name: string }; } } // ---cut--- @Middleware export class AuthMiddleware { async use(next: Next, req = request()): Promise { const token = req.header('Authorization'); const user = await verifyToken(token); setContext('user', user); await next(); return undefined; } } ``` ### Reading Context in Handlers ```typescript // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import { Controller, Get, getContext } from '@zeltjs/core'; declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: number; name: string }; } } // ---cut--- @Controller('/profile') export class ProfileController { @Get('/') getProfile(user = getContext('user')) { return { id: user?.id, name: user?.name }; } } ``` ## 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 { static readonly Token = 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 }; } } ``` ## Parameterized Middleware For middleware that requires configuration options, pass options as the second `@UseMiddleware()` argument: ```typescript import { Controller, UseMiddleware, Middleware, Post, type Next } from '@zeltjs/core'; // ---cut--- @Middleware export class RateLimitMiddleware { async use(next: Next, options: { limit: number; windowSec: number }) { const { limit, windowSec } = options; // ... rate limiting logic await next(); return undefined; } } @Controller('/api') export class ApiController { @UseMiddleware(RateLimitMiddleware, { limit: 10, windowSec: 60 }) @Post('/submit') submit() { return { submitted: true }; } } ``` The options parameter is passed to the middleware's `use()` method at runtime. ## Request Flow ``` Request ↓ Global Middleware (before next) ↓ Controller Middleware (before next) ↓ Method Middleware (before next) ↓ Route Handler ↓ Method Middleware (after next) ↓ Controller Middleware (after next) ↓ Global Middleware (after next) ↓ 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()`, `setContext()`, and `getContext()` for framework primitives. ### Restrict Access Use class middleware when you need to inject services: ```typescript // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import { Middleware, Injectable, inject, currentUser, type Next } from '@zeltjs/core'; declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: number; name: string }; } } @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 // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import { Controller, Get, currentUser, request } from '@zeltjs/core'; import { RateLimit } from '@zeltjs/rate-limit'; declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: string }; } } // ---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 ``` Is your client a browser with server-side rendering? ├── Yes → Sessions (cookie-based, automatic CSRF handling) └── No ├── SPA or Mobile app? → JWT (stateless, scalable) └── Machine-to-machine API? → Custom (API keys, mTLS) ``` ## Authentication Flow ``` Request ↓ ┌─────────────────────────────┐ │ Authentication Middleware │ │ • Extract credentials │ │ • Verify (JWT/Session/etc) │ │ • setUser(user, roles) │ └─────────────────────────────┘ ↓ ┌─────────────────────────────┐ │ @Authorized() Check │ │ • No user? → 401 │ │ • Missing role? → 403 │ │ • OK → Continue │ └─────────────────────────────┘ ↓ Route Handler ↓ 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 // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import '@zeltjs/core'; declare module '@zeltjs/core' { interface RequestContextSchema { user: { name: string }; } } 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; } } ``` ## Type-Safe User Context By default, `currentUser()` returns `Record`. Extend `RequestContextSchema` via declaration merging to get full type safety: ```typescript // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import '@zeltjs/core'; // ---cut--- declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: string; name: string; email: string; }; authRoles: ('admin' | 'editor' | 'user')[]; } } ``` Now all user-related functions are typed: ```typescript // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import '@zeltjs/core'; declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: string; name: string; email: string }; authRoles: ('admin' | 'editor' | 'user')[]; } } // ---cut--- import { currentUser, currentRoles, setUser } from '@zeltjs/core'; const user = currentUser(); // TypeScript knows: user?.id, user?.name, user?.email const roles = currentRoles(); // TypeScript knows: roles is ('admin' | 'editor' | 'user')[] setUser( { id: '123', name: 'Alice', email: 'alice@example.com' }, ['admin', 'user'] ); // Type-checked against RequestContextSchema ``` ### Where to Put the Type Declaration Create a `types/zelt.d.ts` file in your project: ```typescript // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS // types/zelt.d.ts import '@zeltjs/core'; // ---cut--- declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: string; name: string; email: string; avatarUrl?: string; }; authRoles: ('admin' | 'moderator' | 'user')[]; } } export {}; ``` Make sure your `tsconfig.json` includes this file: ```json { "include": ["src/**/*", "types/**/*"] } ``` ## User Design Best Practices ### Keep It Minimal Only include fields you need in handlers. Don't copy the entire database record: ```typescript // ---cut--- // ✅ Good — minimal context interface RequestContextSchemaGood { user: { id: string; name: string; }; } // ❌ Avoid — too much data interface RequestContextSchemaBad { user: { id: string; name: string; email: string; passwordHash: string; // Never include sensitive data createdAt: Date; updatedAt: Date; preferences: object; // ... 20 more fields }; } ``` ### Fetch Additional Data When Needed Use the user ID to fetch more data in specific handlers: ```typescript // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import '@zeltjs/core'; declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: string }; } } import { Controller, Get, Authorized, Injectable, inject, currentUser } from '@zeltjs/core'; type FullUser = { preferences: object }; @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 (!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 // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import '@zeltjs/core'; declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: string }; } } import { currentUser, currentRoles } from '@zeltjs/core'; interface Post { authorId: string; } // ---cut--- function canEdit(post: Post): boolean { const user = currentUser(); const roles = currentRoles(); if (roles.includes('admin')) return true; if (roles.includes('editor') && 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 { static readonly Token = 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 ); ``` ## Defining Role Types Use `RequestContextSchema` to type your roles: ```typescript // @noErrors // Reason: module augmentation requires full module resolution unavailable in Twoslash VFS import '@zeltjs/core'; // ---cut--- declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: string; name: string }; authRoles: ('admin' | 'editor' | 'viewer')[]; } } ``` This provides: - Autocomplete when calling `setUser()` - Type checking in `@Authorized(['...'])` - Type-safe `currentRoles()` return value ## 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. Each config class must have a static `Token` property: ```typescript import { Config, Env, inject } from '@zeltjs/core'; @Config export class DatabaseConfig { static readonly Token = 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 { static readonly Token = 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 { static readonly Token = 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 { static readonly Token = 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] }); ``` The `Token` property is inherited from the parent class, so `inject(DatabaseConfig)` will receive the overridden `TestDatabaseConfig` instance. ## 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 { static readonly Token = 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 ``` Request │ ▼ Middleware chain │ ▼ Route handler ─── throws HTTPException ──► HTTPException.getResponse() │ │ │ ▼ │ Custom error response │ ├─── throws Error ──► handleError() │ │ │ ▼ │ 500 INTERNAL_ERROR │ ▼ 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` --- ## 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 ``` Renderer Preload Main ──────── ─────── ──── ipcFetch(req) → toIpcRequest(req) → globalThis[channel](payload) ipcRenderer.invoke(channel, payload) ipcMain.handle(channel, payload) → toRequest(payload) → app.fetch(request) → toIpcResponse(response) ← IpcFetchResponse ← toResponse(payload) ← 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 Use the `@Command` decorator with `cliSchema()` and `args()` for type-safe CLI commands: ```typescript import { Command, cliSchema, args } from '@zeltjs/core'; @Command({ name: 'greet', description: 'Greet a user', }) export class GreetCommand { static schema = cliSchema({ args: [{ name: 'name', type: 'string' }], }); run(ctx = args(GreetCommand)) { 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'; @Command({ name: 'greet', description: 'Greet a user' }) class GreetCommand { static schema = cliSchema({ args: [{ name: 'name', type: 'string' }] }); run(ctx = args(GreetCommand)) { 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: ### Positional Arguments ```typescript import { Command, cliSchema, args } from '@zeltjs/core'; // ---cut--- @Command({ name: 'copy' }) export class CopyCommand { static schema = cliSchema({ args: [ { name: 'source', type: 'string' }, { name: 'destination', type: 'string' }, ], }); run(ctx = args(CopyCommand)) { console.log(`Copying ${ctx.source} to ${ctx.destination}`); } } ``` ### Options (Flags) ```typescript import { Command, cliSchema, args } from '@zeltjs/core'; // ---cut--- @Command({ name: 'build' }) export class BuildCommand { static schema = cliSchema({ options: [ { name: 'watch', type: 'boolean', alias: 'w' }, { name: 'outDir', type: 'string', alias: 'o', default: 'dist' }, ], }); run(ctx = args(BuildCommand)) { 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--- @Command({ name: 'deploy' }) export class DeployCommand { static schema = cliSchema({ args: [ { name: 'environment', type: 'string' }, ], options: [ { name: 'dryRun', type: 'boolean' }, { name: 'tag', type: 'string' }, ], }); run(ctx = args(DeployCommand)) { 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--- @Command({ name: 'migrate' }) export class MigrateCommand { static schema = cliSchema({ options: [ { name: 'force', type: 'boolean' }, ], }); constructor(private readonly db = inject(DatabaseService)) {} async run(ctx = args(MigrateCommand)) { if (ctx.force) { console.log('Force migration enabled'); } await this.db.runMigrations(); console.log('Migrations completed'); } } ``` ## 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'; @Command({ name: 'migrate' }) class MigrateCommand { static schema = cliSchema({ options: [{ name: 'force', type: 'boolean' }] }); run(ctx = args(MigrateCommand)) {} } // ---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 { static readonly Token = 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 | --- ## 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, cliSchema, ZeltLifecycleStateError, command } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; @Command({ name: 'greet' }) class GreetCommand { static schema = cliSchema({}); 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, cliSchema, command } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; @Command({ name: 'greet' }) class GreetCommand { static schema = cliSchema({}); 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, cliSchema, command } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; @Command({ name: 'greet' }) class GreetCommand { static schema = cliSchema({}); 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. ```text Code-first: Resolver code + args(schema) -> generated schema.graphql -> generated graphql-runtime.js -> /graphql runtime Schema-first: schema.graphql -> zelt graphql codegen -> generated typed helpers -> resolver code -> generated graphql-runtime.js -> /graphql runtime ``` ## API boundary Supported experimental app-authoring APIs: - `graphql()` - `Resolver` - `Query` - `Mutation` - `ResolveField` - `args()` - `gqlScalar()` - `GqlOutput` 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. ## 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], runtimeLoader: () => import('./dist/graphql-runtime.js'), runtimeModule: './dist/graphql-runtime.js', }), ], }), ]); ``` `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 ``` ```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' }; } } ``` 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 GraphQL endpoints require a generated runtime manifest. `runtimeModule` is the codegen output path; `runtimeLoader` is the portable runtime loading hook. ```ts no-check import { graphqlPlugin } from '@zeltjs/graphql/codegen'; ``` Code-first: 1. Write resolvers. 2. Configure `graphql({ runtimeModule, runtimeLoader })`. 3. Run `zelt build` or `graphqlPlugin()`. 4. The plugin generates `graphql-runtime.js` and a sibling `.graphql` file. 5. The caller-supplied loader loads the generated module. Schema-first: 1. Write `schema.graphql`. 2. Run `zelt graphql codegen --schema ... --out ...`. 3. Write resolvers using generated `Gql` helpers. 4. Configure `graphql({ runtimeModule, runtimeLoader })`. 5. Run `zelt build` or `graphqlPlugin({ mode: 'schema-first', ... })`. 6. The plugin generates `graphql-runtime.js` and a sibling `.graphql` file. 7. The caller-supplied loader loads the generated module. Move generation imports from `@zeltjs/graphql` to `@zeltjs/graphql/codegen`. String-only `runtimeModule` loading remains for compatibility, but the string is passed directly to `import()` and is not resolved against the Node working directory. Automatic schema-first codegen during `zelt dev` is not part of this release boundary. Use `zelt graphql codegen` explicitly for now. Pass `resolverChecks: { out, gqlTypesImport }` to `graphqlPlugin()` in schema-first mode to additionally generate a type-check file that asserts each resolver method's return type is assignable to the corresponding generated `Gql.Query`/`Gql.Mutation` result type. ## Current limitations ### 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 { static readonly Token = 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 { static readonly Token = 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 { static readonly Token = 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 { static readonly Token = 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 { static readonly Token = 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 ```