# 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. ## 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 ### Simple Header-Based ```typescript import type { FunctionMiddleware } from '@zeltjs/core'; import { setUser } from '@zeltjs/core'; export const apiKeyAuth: FunctionMiddleware = async (c, next) => { const apiKey = c.req.header('X-API-Key'); if (apiKey) { const client = await db.apiKeys.findByKey(apiKey); if (client) { setUser( { id: client.id, name: client.name, type: 'api' }, client.scopes // e.g., ['read:users', 'write:posts'] ); } } await next(); }; ``` ### With Rate Limiting per Key ```typescript import type { FunctionMiddleware } from '@zeltjs/core'; import { setUser } from '@zeltjs/core'; export const apiKeyAuth: FunctionMiddleware = async (c, next) => { const apiKey = c.req.header('X-API-Key'); if (!apiKey) { await next(); return; } const client = await db.apiKeys.findByKey(apiKey); if (!client) { throw new HTTPException(401, { message: 'Invalid API key' }); } if (client.revokedAt) { throw new HTTPException(401, { message: 'API key revoked' }); } await db.apiKeys.updateLastUsed(apiKey); setUser( { id: client.id, name: client.name, type: 'api', tier: client.tier }, client.scopes ); await next(); }; ``` ## Basic Authentication ```typescript import type { FunctionMiddleware } from '@zeltjs/core'; import { setUser } from '@zeltjs/core'; export const basicAuth: FunctionMiddleware = async (c, next) => { const auth = c.req.header('Authorization'); if (auth?.startsWith('Basic ')) { const base64 = auth.slice(6); const decoded = atob(base64); const [username, password] = decoded.split(':'); const user = await validateCredentials(username, password); if (user) { setUser( { id: user.id, name: user.name }, user.roles ); } } await next(); }; ``` ## OAuth Integration ### With an OAuth Library ```typescript import type { FunctionMiddleware } from '@zeltjs/core'; import { setUser } from '@zeltjs/core'; import { OAuth2Client } from 'your-oauth-library'; const oauth = new OAuth2Client({ clientId: process.env.OAUTH_CLIENT_ID, clientSecret: process.env.OAUTH_CLIENT_SECRET, }); export const oauthAuth: FunctionMiddleware = async (c, next) => { const token = c.req.header('Authorization')?.replace('Bearer ', ''); if (token) { try { const tokenInfo = await oauth.verifyAccessToken(token); const user = await db.users.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(); }; ``` ### OAuth Callback Handler ```typescript import { Controller, Get, queryParam } from '@zeltjs/core'; @Controller('/auth') class OAuthController { @Get('/callback') async callback(code = queryParam('code'), state = queryParam('state')) { const tokens = await oauth.exchangeCode(code); const userInfo = await oauth.getUserInfo(tokens.access_token); let user = await db.users.findByOAuthId(userInfo.sub); if (!user) { user = await db.users.create({ oauthId: userInfo.sub, name: userInfo.name, email: userInfo.email, }); } // Create your own session/JWT here const token = await createSession(user); return { token }; } } ``` ## Multi-Provider Authentication Support multiple auth methods in one middleware: ```typescript import type { FunctionMiddleware } from '@zeltjs/core'; import { setUser } from '@zeltjs/core'; export const multiAuth: FunctionMiddleware = async (c, next) => { const auth = c.req.header('Authorization'); const apiKey = c.req.header('X-API-Key'); // Try API key first if (apiKey) { const client = await db.apiKeys.findByKey(apiKey); if (client) { setUser({ id: client.id, type: 'api' }, client.scopes); await next(); return; } } // Then try Bearer token if (auth?.startsWith('Bearer ')) { const token = auth.slice(7); try { const payload = await verifyJwt(token); setUser({ id: payload.sub, type: 'user' }, payload.roles); } catch { // Invalid token } } await next(); }; ``` ## Request Signing (HMAC) For secure machine-to-machine communication: ```typescript import type { FunctionMiddleware } from '@zeltjs/core'; import { setUser } from '@zeltjs/core'; import { createHmac, timingSafeEqual } from 'crypto'; export const hmacAuth: FunctionMiddleware = async (c, next) => { const signature = c.req.header('X-Signature'); const timestamp = c.req.header('X-Timestamp'); const clientId = c.req.header('X-Client-ID'); if (!signature || !timestamp || !clientId) { await next(); return; } // 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 db.clients.findById(clientId); if (!client) { throw new HTTPException(401, { message: 'Unknown client' }); } // Verify signature const body = await c.req.text(); const payload = `${timestamp}.${body}`; const expected = createHmac('sha256', client.secret) .update(payload) .digest('hex'); if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { throw new HTTPException(401, { message: 'Invalid signature' }); } setUser({ id: client.id, name: client.name }, client.permissions); await next(); }; ``` ## Testing Custom Auth Mock the user context in tests: ```typescript import { describe, it, expect } from 'vitest'; import { createTestClient } from '@zeltjs/testing'; import { setUser } from '@zeltjs/core'; describe('Protected routes', () => { it('returns user data when authenticated', async () => { const client = createTestClient(app); // Mock authentication setUser({ id: '123', name: 'Test User' }, ['admin']); const res = await client.get('/users/me'); expect(res.status).toBe(200); expect(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?) --- ## 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 } from '@zeltjs/core'; import { JwtMiddleware, JwtConfig } from '@zeltjs/auth-jwt'; 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, bodyParam, inject } from '@zeltjs/core'; import { JwtService } from '@zeltjs/auth-jwt'; import * as v from 'valibot'; 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(body = bodyParam(LoginSchema)) { 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'; @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 const token = await jwtService.sign({ sub: user.id, roles: ['admin', 'user'], customClaim: 'value', }); ``` ### Verify Verify a token and get its payload (throws if invalid or expired): ```typescript try { const payload = await jwtService.verify(token); console.log(payload.sub); // user ID } catch (error) { // Token is invalid or expired } ``` ### Decode Decode without verification (useful for reading expired tokens): ```typescript const payload = jwtService.decode(token); if (payload) { console.log(payload.sub); } ``` ## Configuration Extend `JwtConfig` to customize behavior: ```typescript import { JwtConfig, type JwtPayload, type ResolveUserResult } from '@zeltjs/auth-jwt'; import { Config } from '@zeltjs/core'; @Config class CustomJwtConfig extends JwtConfig { override get secret(): string { return process.env.JWT_SECRET!; } override get expiresIn(): string { return '7d'; // Token expiration (default: '1h') } override get resolveUser(): (payload: JwtPayload) => Promise { return async (payload) => { const user = await db.users.findById(payload.sub); return { user: { id: user.id, name: user.name, email: user.email }, roles: user.roles, }; }; } } ``` Register your custom config: ```typescript const app = createApp({ http: { controllers: [AuthController, UserController], middlewares: [JwtMiddleware], }, configs: [CustomJwtConfig], // Your config replaces JwtConfig }); ``` ### Configuration Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `secret` | `string` | `process.env.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 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 @Controller('/auth') class AuthController { constructor(private jwtService = inject(JwtService)) {} @Post('/refresh') async refresh(body = bodyParam(RefreshSchema)) { const payload = await this.jwtService.verify(body.refreshToken); const user = await db.users.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 --- ## 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 } from '@zeltjs/core'; import { JwtMiddleware, JwtConfig } from '@zeltjs/auth-jwt'; const app = createApp({ http: { controllers: [UserController], middlewares: [JwtMiddleware], }, configs: [JwtConfig], }); ``` ### 3. Protect routes ```typescript import { Controller, Get, Authorized, currentUser } from '@zeltjs/core'; @Controller('/dashboard') class DashboardController { @Authorized() @Get('/') index(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 --- ## 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 Create a custom config that provides a KV store for session data: ```typescript import { Config, inject } from '@zeltjs/core'; import { MemoryKVService } from '@zeltjs/kv'; import { SessionConfig } from '@zeltjs/auth-session'; @Config class MySessionConfig extends SessionConfig { private kv = inject(MemoryKVService); override get store() { return this.kv.namespace('sessions'); } } ``` ### 3. Register Middleware ```typescript import { createApp } from '@zeltjs/core'; import { MemoryKVService } from '@zeltjs/kv'; import { SessionMiddleware } from '@zeltjs/auth-session'; const app = createApp({ http: { controllers: [AuthController, UserController], middlewares: [SessionMiddleware], }, configs: [MySessionConfig], injectables: [MemoryKVService], }); ``` ### 4. Manage Sessions Use session functions in your handlers: ```typescript import { Controller, Post, Get, bodyParam } from '@zeltjs/core'; import { getSession, setSession, destroySession } from '@zeltjs/auth-session'; @Controller('/auth') class AuthController { @Post('/login') async login(body = bodyParam(LoginSchema)) { 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 setSession({ userId: '123', name: 'Alice', cart: [{ productId: 'abc', qty: 2 }], }); ``` ### updateSession Partially update the session: ```typescript updateSession((session) => ({ ...session, lastActivity: Date.now(), })); ``` ### destroySession Clear the session and cookie (for logout): ```typescript destroySession(); ``` ## Type-Safe Sessions Extend `SessionSchema` for type-safe session access: ```typescript declare module '@zeltjs/auth-session' { interface SessionSchema { userId?: string; name?: string; email?: string; cart?: CartItem[]; } } ``` Now all session functions are typed: ```typescript 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, inject } from '@zeltjs/core'; import { RedisKVService } from '@zeltjs/kv-redis'; import { SessionConfig } from '@zeltjs/auth-session'; @Config class MySessionConfig extends SessionConfig { private kv = inject(RedisKVService); override get store() { return this.kv.namespace('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 | |--------|------|---------|-------------| | `store` | `KVNamespace` | Required | KV namespace for session storage | | `secret` | `string` | `process.env.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 { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax', path: '/', } ``` ## Storage Backends ### Memory (Development) ```typescript import { MemoryKVService } from '@zeltjs/kv'; @Config class MySessionConfig extends SessionConfig { private kv = inject(MemoryKVService); override get store() { return this.kv.namespace('sessions'); } } ``` ### Redis (Production) ```typescript import { RedisKVService } from '@zeltjs/kv-redis'; @Config class MySessionConfig extends SessionConfig { private kv = inject(RedisKVService); override get store() { return this.kv.namespace('sessions'); } } ``` ### Cloudflare KV ```typescript import { CloudflareKVService } from '@zeltjs/kv-cloudflare'; @Config class MySessionConfig extends SessionConfig { private kv = inject(CloudflareKVService); override get store() { return this.kv.namespace('sessions'); } } ``` ## Integration with User Context Sessions don't automatically set the user context. Add middleware to bridge them: ```typescript import type { FunctionMiddleware } from '@zeltjs/core'; import { setUser } from '@zeltjs/core'; import { getSession } from '@zeltjs/auth-session'; export const sessionAuthMiddleware: FunctionMiddleware = async (c, next) => { const session = getSession(); if (session?.userId) { const user = await db.users.findById(session.userId); setUser( { id: user.id, name: user.name, email: user.email }, user.roles ); } await next(); }; ``` Register after `SessionMiddleware`: ```typescript const app = createApp({ http: { controllers: [UserController], middlewares: [SessionMiddleware, sessionAuthMiddleware], }, configs: [MySessionConfig], injectables: [MemoryKVService], }); ``` ## 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 @Post('/login') async login(body = bodyParam(LoginSchema)) { 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 override get cookieOptions() { return { httpOnly: true, secure: true, // HTTPS only sameSite: 'Strict' as const, path: '/', }; } ``` --- ## 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 type { FunctionMiddleware } from '@zeltjs/core'; import { setUser } from '@zeltjs/core'; export const authMiddleware: FunctionMiddleware = async (c, next) => { const token = c.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(); }; ``` ### 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'; @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 @Controller('/profile') class ProfileController { @Get('/me') me(user = currentUser()) { return user; } } ``` ## Type-Safe User Context By default, `currentUser()` returns `unknown`. Extend `RequestContextSchema` to get full type safety: ```typescript 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 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 // types/zelt.d.ts 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 // ✅ Good — minimal context interface RequestContextSchema { user: { id: string; name: string; }; } // ❌ Avoid — too much data interface RequestContextSchema { 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 @Controller('/settings') class SettingsController { @Authorized() @Get('/') async getSettings(user = currentUser()) { const fullUser = await db.users.findById(user.id); return { preferences: fullUser.preferences }; } } ``` ### Consider Role Granularity Roles should be simple strings. Complex permission logic belongs in services: ```typescript // ✅ Good — simple roles authRoles: ('admin' | 'editor' | 'viewer')[]; // ❌ Avoid — overly specific roles authRoles: ('can_edit_posts' | 'can_delete_posts' | 'can_view_analytics' | ...)[]; ``` For fine-grained permissions, check roles in your service layer: ```typescript class PostService { canEdit(post: Post, user = currentUser(), roles = currentRoles()): boolean { if (roles.includes('admin')) return true; if (roles.includes('editor') && post.authorId === user.id) return true; return false; } } ``` --- ## 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'; @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 @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 @Authorized(['admin', 'moderator']) @Delete('/posts/:id') removePost() { // User needs 'admin' OR 'moderator' } ``` ### AND Logic (All Roles) For AND logic, use multiple `@Authorized` decorators: ```typescript @Authorized(['verified']) @Authorized(['premium']) @Get('/exclusive-content') exclusiveContent() { // User needs 'verified' AND 'premium' } ``` Or check in the handler: ```typescript @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 @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 @Authorized() @UseMiddleware(rateLimitMiddleware) @Post('/posts') create(body = bodyParam(CreatePostSchema)) { 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, isHttpException } from '@zeltjs/core'; const app = createApp({ http: { controllers: [...], onError: (error, c) => { if (isHttpException(error)) { 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 @Get('/posts/:id') getPost(id = pathParam('id'), user = currentUser()) { const post = await db.posts.findById(id); return { ...post, canEdit: user?.id === post.authorId, }; } ``` ### Owner-Only Access Combine `@Authorized` with ownership checks: ```typescript @Authorized() @Put('/posts/:id') async updatePost(id = pathParam('id'), body = bodyParam(UpdateSchema)) { const user = currentUser(); const post = await db.posts.findById(id); if (post.authorId !== user.id && !currentRoles().includes('admin')) { throw new HTTPException(403, { message: 'Not your post' }); } return db.posts.update(id, body); } ``` ### Role Hierarchy Check for any role in a hierarchy: ```typescript const isEditor = (roles: string[]) => roles.some(r => ['admin', 'editor'].includes(r)); @Authorized() @Put('/posts/: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 class PostAuthService { canView(post: Post): boolean { if (post.isPublic) return true; const user = currentUser(); return user?.id === post.authorId; } canEdit(post: Post): boolean { const user = currentUser(); const roles = currentRoles(); if (roles.includes('admin')) return true; return user?.id === post.authorId; } canDelete(post: Post): boolean { const roles = currentRoles(); return roles.includes('admin'); } } @Controller('/posts') class PostController { constructor(private auth = inject(PostAuthService)) {} @Authorized() @Delete('/:id') async delete(id = pathParam('id')) { const post = await db.posts.findById(id); if (!this.auth.canDelete(post)) { throw new HTTPException(403, { message: 'Cannot delete this post' }); } await db.posts.delete(id); return { deleted: true }; } } ``` ## Testing Protected Routes ### Without Authentication ```typescript it('returns 401 for unauthenticated requests', async () => { const client = createTestClient(app); const res = await client.get('/dashboard'); expect(res.status).toBe(401); }); ``` ### With Authentication ```typescript it('returns data for authenticated users', async () => { const client = createTestClient(app); // Set up authentication context setUser({ id: '123', name: 'Test' }, ['user']); const res = await client.get('/dashboard'); expect(res.status).toBe(200); }); ``` ### Testing Role Requirements ```typescript it('returns 403 for non-admin users', async () => { const client = createTestClient(app); setUser({ id: '123', name: 'Test' }, ['user']); // Not admin const res = await client.get('/admin/users'); expect(res.status).toBe(403); }); it('allows admin access', async () => { const client = createTestClient(app); setUser({ id: '123', name: 'Test' }, ['admin']); const res = await client.get('/admin/users'); 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 --- ## 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 ['admin', 'editor', 'viewer'] ['owner', 'member', 'guest'] ['read:users', 'write:users', 'delete:users'] ``` Roles are assigned during authentication via `setUser()`: ```typescript setUser( { id: user.id, name: user.name }, ['admin', 'user'] // ← roles ); ``` ## Defining Role Types Use `RequestContextSchema` to type your roles: ```typescript 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 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 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 // User table interface User { id: string; name: string; roles: string[]; // ['admin', 'user'] } // In authentication middleware const user = await db.users.findById(payload.sub); setUser( { id: user.id, name: user.name }, user.roles ); ``` ### JWT Claims Include roles in the JWT payload: ```typescript // When signing const token = await jwtService.sign({ sub: user.id, roles: user.roles, }); // When verifying (in JwtConfig.resolveUser) override get resolveUser() { return async (payload: JwtPayload) => ({ user: { id: payload.sub }, roles: payload.roles as string[], }); } ``` ### Session Data Store roles in the session: ```typescript // At login setSession({ userId: user.id, roles: user.roles }); // In auth middleware const session = getSession(); if (session) { const user = await db.users.findById(session.userId); setUser(user, session.roles); } ``` ### External Service Fetch roles from an identity provider: ```typescript const userInfo = await identityProvider.getUserInfo(token); const roles = await identityProvider.getRoles(userInfo.sub); setUser( { id: userInfo.sub, name: userInfo.name }, roles ); ``` ## Role Assignment Strategies ### Static Assignment Roles are set once and rarely change: ```typescript // Admin assigns roles via API @Authorized(['admin']) @Post('/users/:id/roles') async assignRoles(id = pathParam('id'), body = bodyParam(RolesSchema)) { await db.users.update(id, { roles: body.roles }); return { success: true }; } ``` ### Dynamic Assignment Roles are computed based on context: ```typescript // Roles depend on resource ownership const project = await db.projects.findById(projectId); const roles = []; if (project.ownerId === user.id) { roles.push('project:owner'); } if (project.memberIds.includes(user.id)) { roles.push('project:member'); } setUser(user, roles); ``` ### Time-Based Roles Roles expire or activate based on time: ```typescript 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 { currentRoles } from '@zeltjs/core'; @Get('/dashboard') dashboard() { const roles = currentRoles(); return { canManageUsers: roles.includes('admin'), canEditContent: roles.includes('editor') || roles.includes('admin'), }; } ``` ### In Services ```typescript class PostService { canDelete(post: Post): boolean { const roles = currentRoles(); const user = currentUser(); 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 ['admin', 'editor', 'viewer'] // ❌ Avoid [{ 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 // ✅ Role-based: "Can access admin section" @Authorized(['admin']) @Get('/admin/dashboard') // ❌ 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 ['can_view_users', 'can_create_users', 'can_edit_users', 'can_delete_users', ...] // ✅ Group into meaningful roles ['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'; ``` --- ## 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, LifecycleManager, type Lifecycle } from '@zeltjs/core'; import { Redis } from 'ioredis'; import { Queue, Worker, type Processor, type ConnectionOptions } from 'bullmq'; @Config class BullMQConfig { get connection(): ConnectionOptions { return { host: process.env['REDIS_HOST'] ?? 'localhost', port: Number(process.env['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 { Queue } from 'bullmq'; @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 { Worker, Job } from 'bullmq'; @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 { validated } from '@zeltjs/validate-valibot'; import * as v from 'valibot'; @Controller('/users') class UserController { constructor(private emailService = inject(EmailService)) {} @Post('/register') async register() { const body = validated(v.object({ email: v.string() })); // ... create user await this.emailService.sendWelcomeEmail(body.email); return { message: 'User registered' }; } } ``` ## App Configuration Register your services in the app: ```typescript import { createHttpApp } from '@zeltjs/core'; const app = createHttpApp({ controllers: [UserController], configs: [BullMQConfig], }); export default app; ``` To start workers, ensure they are instantiated at startup: ```typescript import { createHttpApp, inject } from '@zeltjs/core'; const app = createHttpApp({ controllers: [UserController], configs: [BullMQConfig], }); // Instantiate worker to start processing app.ready().then(() => { inject(EmailWorker); }); ``` ## Custom Configuration Extend `BullMQConfig` for different environments: ```typescript @Config class ProductionBullMQConfig extends BullMQConfig { override get connection(): ConnectionOptions { return { host: process.env['REDIS_HOST']!, port: Number(process.env['REDIS_PORT'] ?? 6379), password: process.env['REDIS_PASSWORD'], tls: process.env['REDIS_TLS'] === 'true' ? {} : undefined, }; } } ``` ## Job Options BullMQ supports many job options. Use them directly: ```typescript 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 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 } from 'vitest'; describe('EmailService', () => { it('enqueues welcome email', async () => { const mockQueue = { add: vi.fn() }; const service = new EmailService({ client: {} } as any); (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: ```typescript import { GenericContainer } from 'testcontainers'; const redis = await new GenericContainer('redis:7').withExposedPorts(6379).start(); process.env['REDIS_HOST'] = redis.getHost(); process.env['REDIS_PORT'] = String(redis.getMappedPort(6379)); ``` --- ## 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 } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; import { GreetCommand } from './commands/greet.command'; const app = createApp({ commands: [GreetCommand] }); const nodeApp = await onNode(app); await nodeApp.exec(nodeApp.args); ``` Then configure `cli.entry` in your `zelt.config.ts`: ```typescript import { defineConfig } from '@zeltjs/cli'; export default defineConfig({ controllers: 'src/controllers/**/*.ts', 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 @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 @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 @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 static 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 static schema = cliSchema({ options: [ { name: 'port', type: 'number', default: 3000 }, { name: 'verbose', type: 'boolean' }, // defaults to false ], }); ``` ## Dependency Injection Commands support dependency injection: ```typescript import { Command, cliSchema, args, inject } from '@zeltjs/core'; import { DatabaseService } from '../services/database.service'; @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 } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; import { MigrateCommand } from './commands/migrate.command'; const app = createApp({ commands: [MigrateCommand] }); const nodeApp = await onNode(app); const result = await nodeApp.exec(['migrate', '--force']); console.log(`Exit code: ${result.exitCode}`); ``` ## Async Commands Commands can be async: ```typescript @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() { // ... } } ``` --- ## Configuration Zelt provides a type-safe configuration system using the `@Config` decorator and `injectConfig()` helper. ## Defining Configuration Use the `@Config` decorator to define a configuration class. Each config class must have a static `Token` property: ```typescript import { Config } from '@zeltjs/core'; @Config export class DatabaseConfig { static readonly Token = DatabaseConfig; get host() { return process.env.DATABASE_HOST ?? 'localhost'; } get port() { return Number(process.env.DATABASE_PORT ?? 5432); } get connectionString() { return `postgres://${this.host}:${this.port}/mydb`; } } ``` ## Using Configuration Inject configuration into services or controllers using `injectConfig()`: ```typescript import { Injectable, injectConfig } from '@zeltjs/core'; import { DatabaseConfig } from './database.config'; @Injectable() export class DatabaseService { constructor(private config = injectConfig(DatabaseConfig)) {} connect() { return this.config.connectionString; } } ``` ## Registering Configuration Register config classes when creating the app: ```typescript import { createApp } from '@zeltjs/core'; import { DatabaseConfig } from './database.config'; import { AppController } from './app.controller'; const app = createApp({ http: { controllers: [AppController], }, configs: [DatabaseConfig], }); ``` ## Overriding Configuration Override configuration values for testing by extending the config class: ```typescript import { Config } from '@zeltjs/core'; import { DatabaseConfig } from './database.config'; @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 `injectConfig(DatabaseConfig)` will receive the overridden `TestDatabaseConfig` instance. ## Environment-Based Configuration Zelt provides environment configuration through platform-specific adapters. ### Node.js Environment For Node.js applications, use the configs from `@zeltjs/adapter-node`: #### ProcessEnvConfig Reads from `process.env` directly: ```typescript import { Config, injectConfig } from '@zeltjs/core'; import { ProcessEnvConfig } from '@zeltjs/adapter-node'; @Config export class DatabaseConfig { static readonly Token = DatabaseConfig; constructor(private env = injectConfig(ProcessEnvConfig)) {} get host() { return this.env.get('DATABASE_HOST') ?? 'localhost'; } get port() { return Number(this.env.get('DATABASE_PORT') ?? 5432); } get connectionString() { return `postgres://${this.host}:${this.port}/mydb`; } } // Register both configs const app = createApp({ http: { controllers: [AppController], }, configs: [ProcessEnvConfig, DatabaseConfig], }); ``` #### DotEnvConfig Loads `.env` files using [dotenv](https://github.com/motdotla/dotenv), then reads from `process.env`: ```typescript import { Config, injectConfig } from '@zeltjs/core'; import { DotEnvConfig } from '@zeltjs/adapter-node'; @Config export class DatabaseConfig { static readonly Token = DatabaseConfig; constructor(private env = injectConfig(DotEnvConfig)) {} get host() { return this.env.get('DATABASE_HOST') ?? 'localhost'; } } // DotEnvConfig loads .env on construction const app = createApp({ http: { controllers: [AppController], }, configs: [DotEnvConfig, DatabaseConfig], }); ``` #### Custom Env Paths Extend `DotEnvConfig` to load from custom paths: ```typescript import { Config } from '@zeltjs/core'; import { DotEnvConfig } from '@zeltjs/adapter-node'; @Config export class MyEnvConfig extends DotEnvConfig { protected override readonly paths = ['.env', '.env.local']; } ``` ### 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. --- ## 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, pathParam, validated, response } 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(id = pathParam('id')) { return { id, name: 'John Doe' }; } @Post('/') create(body = validated(CreateUserBody), res = response()) { return res.json({ id: '1', ...body }, 201); } } ``` ## 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 @Controller('/items') export class ItemController { @Get('/') findAll() { /* ... */ } @Get('/:id') findOne(id = pathParam('id')) { /* ... */ } @Post('/') create(body = validated(schema)) { /* ... */ } @Put('/:id') update(id = pathParam('id'), body = validated(schema)) { /* ... */ } @Patch('/:id') patch(id = pathParam('id'), body = validated(schema)) { /* ... */ } @Delete('/:id') remove(id = pathParam('id')) { /* ... */ } } ``` ## Route Parameters Use `pathParam()` to extract route parameters: ```typescript @Get('/:category/:id') findOne( category = pathParam('category'), id = pathParam('id') ) { return { category, id }; } ``` ## Request Body Validation Use `validated()` with a Valibot schema to validate and type the request body: ```typescript import * as v from 'valibot'; 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())), }); @Post('/') create(body = validated(CreatePostBody)) { // 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. ## Custom Response Status Use `response()` to control the HTTP status code: ```typescript @Post('/') create(body = validated(schema), res = response()) { const created = { id: '1', ...body }; return res.json(created, 201); // Returns 201 Created } @Delete('/:id') remove(id = pathParam('id'), res = response()) { return res.json(null, 204); // Returns 204 No Content } ``` ## Registering Controllers Controllers must be registered in `createHttpApp()`: ```typescript import { createHttpApp } from '@zeltjs/core'; import { UserController } from './controllers/user.controller'; import { PostController } from './controllers/post.controller'; export const app = createHttpApp({ controllers: [UserController, PostController], }); ``` ## Next Steps - Learn about [Middleware](./middleware.md) for request/response processing --- ## 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'; @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. --- ## 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 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 try { await authorize(c); } catch (cause) { throw new HTTPException(401, { message: 'Authorization failed', cause }); } ``` ## 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 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 Schema for OpenAPI Use the built-in error schemas to document error responses in your OpenAPI spec: ```typescript import { errorBodySchema, validationErrorBodySchema } from '@zeltjs/core'; ``` These schemas define the structure of error responses: - `errorBodySchema` — Union of all error types (VALIDATION_FAILED | INTERNAL_ERROR) - `validationErrorBodySchema` — 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 `createHttpApp` via the `errorHandlers` option: ```typescript import { createHttpApp } from '@zeltjs/core'; const app = createHttpApp({ controllers: [UserController], middlewares: [LoggingMiddleware], errorHandlers: [DatabaseErrorHandler, ValidationErrorHandler], }); ``` ### Handler Chain Error handlers execute in the order they are registered: 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 @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; } } createHttpApp({ controllers: [MyController], errorHandlers: [FirstHandler, FallbackHandler], }); ``` ### Dependency Injection Error handlers support dependency injection. Use constructor injection to access services: ```typescript @ErrorHandler class LoggingErrorHandler { constructor(private logger: LoggerService) {} onError(error: Error, c: RequestContext) { this.logger.error('Request failed', { error, path: c.req.path }); return undefined; } } ``` ## 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 --- ## First Steps import {Redirect} from '@docusaurus/router'; --- ## Getting Started Zelt is a fast, type-safe application framework for TypeScript. Built on [Hono](https://hono.dev/), it provides dependency injection, validation, and a decorator-based API for building web applications and CLI tools. ## Choose Your Environment Select your target runtime to get started: - **[Node.js](./node.md)** — Traditional server environment with full Node.js API access - **[Cloudflare Workers](./cloudflare-workers.md)** — Edge runtime with global distribution and low latency ## Core Concepts Zelt applications are built around these primitives: | Concept | Description | |---------|-------------| | **Controller** | Handles HTTP requests using decorators like `@Get`, `@Post` | | **Service** | Contains business logic, injectable via `@Injectable` | | **Config** | Manages configuration values, injectable via `@Config` | Each environment guide covers these concepts with complete, runnable examples. --- ## Getting Started with Cloudflare Workers This guide walks you through building a Zelt application on Cloudflare Workers from scratch. ## Prerequisites - [Node.js](https://nodejs.org/) v20 or higher - [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) - A [Cloudflare account](https://dash.cloudflare.com/sign-up) (free tier available) ## Installation ```bash pnpm add @zeltjs/core @zeltjs/adapter-cloudflare-workers pnpm add -D wrangler @cloudflare/workers-types ``` ## Project Structure ``` my-worker/ ├── src/ │ ├── app.ts │ ├── index.ts │ └── controllers/ │ └── hello.controller.ts ├── package.json ├── tsconfig.json └── wrangler.toml ``` ## Hello World ### Step 1: Create the Controller Controllers handle incoming HTTP requests and return responses. Each controller is a class decorated with `@Controller` that defines a route prefix. Create `src/controllers/hello.controller.ts`: ```typescript import { Controller, Get, pathParam } from '@zeltjs/core'; @Controller('/hello') export class HelloController { @Get('/:name') greet(name = pathParam('name')) { return { message: `Hello, ${name}!` }; } } ``` - `@Controller('/hello')` — Sets the base path for all routes in this controller - `@Get('/:name')` — Handles GET requests to `/hello/:name` - `pathParam('name')` — Extracts the `name` parameter from the URL path ### Step 2: Create the Application Create `src/app.ts` to wire up your controllers: ```typescript import { createHttpApp } from '@zeltjs/core'; import { HelloController } from './controllers/hello.controller'; export const app = createHttpApp({ controllers: [HelloController], }); ``` ### Step 3: Create the Worker Entry Point Create `src/index.ts` as the Cloudflare Workers entry point: ```typescript import { onCloudflareWorkers } from '@zeltjs/adapter-cloudflare-workers'; import { app } from './app'; export default await onCloudflareWorkers(app); ``` The `onCloudflareWorkers()` function is async and prepares your app for the Workers runtime. By default, it uses **lazy initialization** (`warmup: false`) — controllers are resolved on the first request rather than at startup. This optimizes cold start times in serverless environments. ### Step 4: Configure Wrangler Create `wrangler.toml`: ```toml name = "my-zelt-worker" main = "src/index.ts" compatibility_date = "2024-01-01" compatibility_flags = ["nodejs_compat"] [vars] API_HOST = "https://api.example.com" ``` ### Step 5: Configure TypeScript Create `tsconfig.json`: ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "experimentalDecorators": true, "skipLibCheck": true, "types": ["@cloudflare/workers-types"] }, "include": ["src"] } ``` ### Step 6: Run Locally ```bash npx wrangler dev ``` Visit `http://localhost:8787/hello/world` to see: ```json { "message": "Hello, world!" } ``` ## Configuration ### Environment Variables In Cloudflare Workers, environment variables are configured in `wrangler.toml` and accessed via `EnvService`. ```typescript import { Controller, Get, inject, EnvService } from '@zeltjs/core'; @Controller('/config') export class ConfigController { constructor(private env = inject(EnvService)) {} @Get('/api-host') getApiHost() { return { apiHost: this.env.get('API_HOST') ?? 'localhost' }; } } ``` Register `EnvConfig` in your app: ```typescript import { createHttpApp, EnvConfig } from '@zeltjs/core'; export const app = createHttpApp({ controllers: [ConfigController], configs: [EnvConfig], }); ``` **Important:** When you register `EnvConfig` and use `onCloudflareWorkers()`, the adapter automatically replaces it with `CloudflareWorkersEnvConfig`. This reads environment variables from the Workers runtime (`cloudflare:workers` module) instead of `process.env`. ### Secrets For sensitive values, use Wrangler secrets instead of `[vars]`: ```bash npx wrangler secret put DATABASE_URL ``` Access them the same way via `EnvService`: ```typescript const dbUrl = this.env.get('DATABASE_URL'); ``` ## Services Services work identically to Node.js. Use `@Injectable` to mark a class as a service. ```typescript import { Injectable } from '@zeltjs/core'; @Injectable() export class GreetingService { greet(name: string): string { return `Hello, ${name}!`; } } ``` Inject into controllers: ```typescript import { Controller, Get, pathParam, inject } from '@zeltjs/core'; import { GreetingService } from '../services/greeting.service'; @Controller('/hello') export class HelloController { constructor(private greetingService = inject(GreetingService)) {} @Get('/:name') greet(name = pathParam('name')) { return { message: this.greetingService.greet(name) }; } } ``` ## Deploy Deploy your worker to Cloudflare's global network: ```bash npx wrangler deploy ``` Your worker will be available at `https://my-zelt-worker..workers.dev`. ## Advanced: Warmup Option By default, `onCloudflareWorkers()` uses lazy initialization (`warmup: false`) to minimize cold start time. Controllers are resolved on the first request. If you prefer to resolve all controllers at initialization (useful for debugging or when cold start time is less critical), set `warmup: true`: ```typescript export default await onCloudflareWorkers(app, { warmup: true }); ``` | Option | Behavior | Use Case | |--------|----------|----------| | `warmup: false` (default) | Controllers resolved on first request | Optimized cold starts | | `warmup: true` | All controllers resolved at initialization | Debugging, warm environments | ## What's Next? Now that you have a basic worker running, explore more features: - [Controllers](/controllers) — Route handling and HTTP methods - [Services](/services) — Business logic and dependency injection - [Validation](/validation) — Request body validation with Valibot - [Middleware](/middleware) — Request/response interceptors - [Configuration](/configuration) — Advanced configuration patterns --- ## Getting Started with Node.js This guide walks you through building a Zelt application on Node.js from scratch. ## Prerequisites - [Node.js](https://nodejs.org/) v20 or higher - A package manager: [pnpm](https://pnpm.io/) (recommended), npm, or bun ## Installation ```bash pnpm add @zeltjs/core @zeltjs/adapter-node ``` ## Project Structure ``` my-app/ ├── src/ │ ├── app.ts │ ├── main.ts │ └── controllers/ │ └── hello.controller.ts ├── package.json └── tsconfig.json ``` ## Hello World ### Step 1: Create the Controller Controllers handle incoming HTTP requests and return responses. Each controller is a class decorated with `@Controller` that defines a route prefix. Create `src/controllers/hello.controller.ts`: ```typescript import { Controller, Get, pathParam } from '@zeltjs/core'; @Controller('/hello') export class HelloController { @Get('/:name') greet(name = pathParam('name')) { return { message: `Hello, ${name}!` }; } } ``` - `@Controller('/hello')` — Sets the base path for all routes in this controller - `@Get('/:name')` — Handles GET requests to `/hello/:name` - `pathParam('name')` — Extracts the `name` parameter from the URL path ### Step 2: Create the Application Create `src/app.ts` to wire up your controllers and prepare for the Node.js runtime: ```typescript import { createApp } from '@zeltjs/core'; import { onNode } from '@zeltjs/adapter-node'; import { HelloController } from './controllers/hello.controller'; export const app = createApp({ http: { controllers: [HelloController], }, }); export default await onNode(app); ``` The `onNode()` function prepares your app for the Node.js runtime, returning a `NodeApp` with `listen()`, `get()`, and `args` properties. ### Step 3: Start the Server Create `src/main.ts` to start the server: ```typescript import nodeApp from './app'; const server = await nodeApp.listen({ port: 3000 }); console.log(`Server running at http://localhost:${server.address.port}`); ``` ### Step 4: Configure TypeScript Create `tsconfig.json`: ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "experimentalDecorators": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src"] } ``` ### Step 5: Run the Application ```bash npx tsx src/main.ts ``` Visit `http://localhost:3000/hello/world` to see: ```json { "message": "Hello, world!" } ``` ## Adding Services Services contain business logic and can be injected into controllers. Use `@Injectable` to mark a class as a service. Create `src/services/greeting.service.ts`: ```typescript import { Injectable } from '@zeltjs/core'; @Injectable() export class GreetingService { greet(name: string): string { return `Hello, ${name}!`; } } ``` Update your controller to use the service: ```typescript import { Controller, Get, pathParam, inject } from '@zeltjs/core'; import { GreetingService } from '../services/greeting.service'; @Controller('/hello') export class HelloController { constructor(private greetingService = inject(GreetingService)) {} @Get('/:name') greet(name = pathParam('name')) { return { message: this.greetingService.greet(name) }; } } ``` ## Configuration Zelt provides configuration classes for managing environment variables. ### Using Environment Variables ```typescript import { Controller, Get, inject } from '@zeltjs/core'; import { EnvService } from '@zeltjs/core'; @Controller('/config') export class ConfigController { constructor(private env = inject(EnvService)) {} @Get('/api-host') getApiHost() { return { apiHost: this.env.get('API_HOST') ?? 'localhost' }; } } ``` Register the config in your app: ```typescript import { createApp, EnvConfig } from '@zeltjs/core'; export const app = createApp({ http: { controllers: [ConfigController], }, configs: [EnvConfig], }); ``` ### Node.js-Specific Configs The `@zeltjs/adapter-node` package provides additional configuration options: ```typescript import { ProcessEnvConfig, DotEnvConfig } from '@zeltjs/adapter-node'; // ProcessEnvConfig: Reads from process.env (default behavior) // DotEnvConfig: Reads from .env file ``` ## What's Next? Now that you have a basic application running, explore more features: - [Controllers](/controllers) — Route handling and HTTP methods - [Services](/services) — Business logic and dependency injection - [Validation](/validation) — Request body validation with Valibot - [Middleware](/middleware) — Request/response interceptors - [Configuration](/configuration) — Advanced configuration patterns --- ## 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/openapi` 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/openapi ``` ## Configuration Create a `zelt.config.ts` file in your project root: ```typescript import { defineConfig } from '@zeltjs/openapi'; export default defineConfig({ controllers: ['./src/**/*.controller.ts'], dist: './generated', tsconfig: './tsconfig.json', }); ``` ## Generating AppType ```bash pnpm zelt-openapi build ``` This generates `/app.gen.ts` containing the `AppType`. ### Generated app.gen.ts ```typescript // THIS FILE IS GENERATED BY @zeltjs/openapi. DO NOT EDIT. import type { Route, BuildAppType } from '@zeltjs/openapi'; import type { HelloController } from '../src/controllers/hello.controller'; export type AppType = BuildAppType<[ Route<'GET', '/hello/:name', typeof HelloController.prototype.greet>, Route<'POST', '/hello', typeof HelloController.prototype.create>, ]>; ``` ## Using AppType ### Type-Safe API Client ```typescript import { hc } from 'hono/client'; import type { AppType } from './generated/app.gen'; 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 { hc } from 'hono/client'; import { describe, it, expect } from 'vitest'; import { app } from './app'; import type { AppType } from './generated/app.gen'; describe('Hello API', () => { const client = hc('http://localhost', { fetch: (input, init) => app.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. **Static Analysis** — Analyzes controller method signatures at build time 2. **Type Extraction** — Extracts request/response types from TypeScript types 3. **Code Generation** — Generates `AppType` using type-level computation The generated `AppType` maps your controller methods to Hono's route types, enabling the `hc` client to infer parameter and response types automatically. --- ## Introduction Zelt is a fast, type-safe application framework for TypeScript, bringing Laravel/FuelPHP-like productivity to edge and serverless runtimes. ## Philosophy Zelt provides a complete application skeleton that integrates controllers, services, configuration, lifecycle management, error handling, testing, and more — all connected through a unified type contract. ### Core Values - **Fast** — Practical startup and execution speed for Cloudflare Workers and serverless cold starts - **Type-safe** — Schema → request → controller → response → DI → test double are all connected through the same type contract - **Application-oriented** — Provides the "application backbone" that integrates controller / service / repository / config / lifecycle / error handling / testing ## Why Zelt? Modern TypeScript backend development often requires piecing together multiple libraries for routing, validation, dependency injection, and testing. Zelt provides these out of the box with a cohesive, type-safe API. Unlike traditional frameworks, Zelt is designed from the ground up for edge and serverless environments where cold start performance matters. ## Packages | Package | Description | |---------|-------------| | `@zeltjs/core` | DI, lifecycle, validation, error handling, and HTTP core | | `@zeltjs/adapter-node` | Node.js server adapter | | `@zeltjs/adapter-cloudflare-workers` | Cloudflare Workers adapter | | `@zeltjs/openapi` | Type generation and OpenAPI output | | `@zeltjs/testing` | Test utilities | ## Status **pre-alpha** — Breaking changes may occur in minor versions during 0.x. --- ## Redis KV Driver `@zeltjs/kv-driver-redis` provides a Redis backend for the KV abstraction. It implements `AtomicKVDriver` using [ioredis](https://github.com/redis/ioredis), supporting atomic operations like `incr` and `setnx`. ## Installation ```bash pnpm add @zeltjs/kv-driver-redis ``` Peer dependencies: ```bash pnpm add @zeltjs/core @zeltjs/kv ``` ## Basic Setup Register `RedisConfig` and inject `RedisKV` into your services: ```typescript import { createHttpApp, Injectable, inject } from '@zeltjs/core'; import { RedisConfig, RedisKV } from '@zeltjs/kv-driver-redis'; @Injectable() class CacheService { private store = inject(RedisKV).namespace('cache:').unwrapOr(null); async get(key: string): Promise { if (!this.store) return undefined; const result = await this.store.get(key); return result.unwrapOr(undefined); } async set(key: string, value: T, ttlSec?: number): Promise { if (!this.store) return; await this.store.set(key, value, { ttlSec }); } } const app = createHttpApp({ 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: ```typescript import { Config } from '@zeltjs/core'; import { RedisConfig } from '@zeltjs/kv-driver-redis'; import type { RedisOptions } from 'ioredis'; @Config class CustomRedisConfig extends RedisConfig { override get url(): string { return process.env['REDIS_URL'] ?? 'redis://localhost:6379'; } override get options(): RedisOptions { return { maxRetriesPerRequest: 3, retryStrategy: (times) => Math.min(times * 100, 3000), }; } } ``` Register your custom config instead of the default: ```typescript const app = createHttpApp({ controllers: [AppController], configs: [CustomRedisConfig], }); ``` ## API Reference ### RedisKV | Method | Description | |--------|-------------| | `namespace(prefix)` | Returns a namespaced `AtomicKVStore` | | `shutdown()` | Disconnects from Redis | ### AtomicKVStore Methods | Method | Description | |--------|-------------| | `get(key)` | Retrieve a value | | `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 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 @Config class ProductionRedisConfig extends RedisConfig { override get options(): RedisOptions { return { maxRetriesPerRequest: 3, enableReadyCheck: true, retryStrategy: (times) => { if (times > 10) return null; return Math.min(times * 200, 5000); }, }; } } ``` ### Graceful Shutdown Call `shutdown()` when your application terminates: ```typescript import { listen } from '@zeltjs/adapter-node'; listen(app, { port: 3000 }); process.on('SIGTERM', async () => { await container.get(RedisKV).shutdown(); process.exit(0); }); ``` --- ## Key-Value Store Zelt provides `@zeltjs/kv` for namespace-based key-value storage with TTL support and atomic operations. ## Overview The KV module provides: - **`KVDriver` / `AtomicKVDriver`** — Top-level drivers 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'; @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 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 @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 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 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 try { await store.set('key', value, { ttlSec: -1 }); console.log('Success'); } catch (error) { console.error(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 { createHttpApp } from '@zeltjs/core'; import { MemoryKV } from '@zeltjs/kv'; const app = createHttpApp({ controllers: [AppController], injectables: [MemoryKV], }); ``` --- ## 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 } from '@zeltjs/core'; import { Logger } from '@zeltjs/core/modules/logger'; @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 this.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 @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 } from '@zeltjs/core/modules/logger'; 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, inject } from '@zeltjs/core'; import { LoggerConfig, ConsoleTransport, JsonlFormatter, type TransportBinding, type LogLevel, } from '@zeltjs/core/modules/logger'; @Config export class AppLoggerConfig extends LoggerConfig { constructor( private console = inject(ConsoleTransport), private jsonl = inject(JsonlFormatter), ) { super(); } override get level(): LogLevel { return (process.env.LOG_LEVEL as LogLevel) ?? 'info'; } override get transports(): readonly TransportBinding[] { return [{ transport: this.console, formatter: this.jsonl }]; } } ``` ### Using PrettyFormatter For human-readable output in development, use `PrettyFormatter`: ```typescript import { Config, inject } from '@zeltjs/core'; import { LoggerConfig, ConsoleTransport, PrettyFormatter, type TransportBinding, } from '@zeltjs/core/modules/logger'; @Config export class DevLoggerConfig extends LoggerConfig { constructor( private console = inject(ConsoleTransport), private pretty = inject(PrettyFormatter), ) { super(); } override get level() { return 'debug' as const; } override get transports(): readonly TransportBinding[] { return [{ transport: this.console, formatter: this.pretty }]; } } ``` `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 { createHttpApp } from '@zeltjs/core'; import { AppLoggerConfig } from './logger.config'; import { AppController } from './app.controller'; const app = createHttpApp({ 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/modules/logger'; 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/modules/logger'; 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` --- ## Middleware Middleware functions execute before the route handler and can modify requests, responses, or context. ## Function Middleware The simplest form of middleware is a function that receives the context and next function: ```typescript import type { FunctionMiddleware } from '@zeltjs/core'; export const loggingMiddleware: FunctionMiddleware = async (c, next) => { const start = Date.now(); await next(); const duration = Date.now() - start; console.log(`[${c.req.method}] ${c.req.path} ${c.res.status} ${duration}ms`); }; ``` ## 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 } from '@zeltjs/core'; import { loggingMiddleware } from './middlewares/logging'; export const app = createApp({ http: { controllers: [UserController], middlewares: [loggingMiddleware], }, }); ``` ### Controller Middleware Apply to all methods in a controller with `@UseMiddleware`: ```typescript import { Controller, Get, UseMiddleware } from '@zeltjs/core'; @UseMiddleware(authMiddleware) @Controller('/admin') export class AdminController { @Get('/dashboard') dashboard() { return { stats: [] }; } } ``` ### Method Middleware Apply to specific methods: ```typescript @Controller('/posts') export class PostController { @Get('/') findAll() { return { posts: [] }; } @UseMiddleware(adminOnlyMiddleware) @Delete('/:id') remove(id = pathParam('id')) { return { deleted: id }; } } ``` ## Skipping Middleware Use `@SkipMiddleware` to exclude specific middleware from a method: ```typescript import { Controller, Get, SkipMiddleware } from '@zeltjs/core'; @Controller('/api') export class ApiController { @Get('/protected') protected() { return { secret: 'data' }; } @SkipMiddleware(authMiddleware) @Get('/health') health() { return { status: 'ok' }; } } ``` ## Context Sharing Middleware can share data with handlers via `setContext()` and `getContext()`. ### Type-Safe Context Define your context shape using module augmentation: ```typescript declare module '@zeltjs/core' { interface RequestContextSchema { user: { id: number; name: string }; } } ``` ### Setting Context in Middleware ```typescript import type { FunctionMiddleware } from '@zeltjs/core'; export const authMiddleware: FunctionMiddleware = async (c, next) => { const token = c.req.header('Authorization'); const user = await verifyToken(token); c.set('user', user); await next(); }; ``` ### Reading Context in Handlers ```typescript import { Controller, Get, getContext } from '@zeltjs/core'; @Controller('/profile') export class ProfileController { @Get('/') getProfile(user = getContext('user')) { return { id: user?.id, name: user?.name }; } } ``` ## Class Middleware For middleware that requires dependency injection, use `@Middleware`: ```typescript import { Middleware, inject, Injectable } from '@zeltjs/core'; import type { RequestContext, Next } from '@zeltjs/core'; @Injectable() class ConfigService { getSecret() { return process.env.SECRET; } } @Middleware export class AuthMiddleware { constructor(private config = inject(ConfigService)) {} async use(c: RequestContext, next: Next): Promise { const secret = this.config.getSecret(); // ... authentication logic await next(); return undefined; } } ``` Use class middleware the same way as function middleware: ```typescript @UseMiddleware(AuthMiddleware) @Controller('/admin') export class AdminController { // ... } ``` ## Parameterized Middleware For middleware that requires configuration options, use the tuple syntax `[MiddlewareClass, options]`: ```typescript @Middleware export class RateLimitMiddleware { async use(c: RequestContext, next: Next, options?: { limit: number; windowSec: number }) { const limit = options?.limit ?? 100; const windowSec = options?.windowSec ?? 60; // ... 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 const globalMw: FunctionMiddleware = async (c, next) => { console.log('1. global before'); await next(); console.log('6. global after'); }; const controllerMw: FunctionMiddleware = async (c, next) => { console.log('2. controller before'); await next(); console.log('5. controller after'); }; const methodMw: FunctionMiddleware = async (c, next) => { console.log('3. method before'); await next(); console.log('4. method after'); }; ``` ## Common Patterns You can write middleware as functions or classes. Use functions for simple cases, and classes when you need dependency injection or state. ### Restrict Access Use class middleware when you need to inject services: ```typescript @Middleware export class RequireAdmin { constructor(private authService = inject(AuthService)) {} async use(c: RequestContext, next: Next): Promise { const user = c.get('user'); if (!this.authService.isAdmin(user)) { return c.json({ error: 'Forbidden' }, 403); } await next(); return undefined; } } ``` ### Transform Response Function middleware works well for simple transformations: ```typescript const wrapResponse: FunctionMiddleware = async (c, next) => { await next(); const body = await c.res.json(); c.res = c.json({ success: true, data: body }); }; ``` ### Measure Response Time ```typescript const timing: FunctionMiddleware = async (c, next) => { const start = Date.now(); await next(); c.res.headers.set('X-Response-Time', `${Date.now() - start}ms`); }; ``` ### Cache Response Use class middleware when you need to maintain state: ```typescript @Middleware export class CacheResponse { private cache = new Map(); async use(c: RequestContext, next: Next): Promise { const key = c.req.url; const cached = this.cache.get(key); if (cached) return cached.clone(); await next(); this.cache.set(key, c.res.clone()); return undefined; } } ``` --- ## 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 ```bash pnpm add @zeltjs/openapi ``` ## Configuration Create a `zelt.config.ts` file in your project root: ```typescript import { defineConfig } from '@zeltjs/openapi'; 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. --- ## Request & Response Primitives Zelt provides functional primitives for accessing request data and building responses. These primitives can be used as default parameters in controller methods. ## Request Primitives ### Query Parameters ```typescript import { Controller, Get, queryParam, queryParams, response } from '@zeltjs/core'; @Controller('/search') export class SearchController { @Get('/') search( q = queryParam('q'), tags = queryParams('tag'), res = response(), ) { // q: string | undefined // tags: string[] (empty array if not provided) return res.json({ query: q, tags }); } } ``` | Function | Return Type | Description | |----------|-------------|-------------| | `queryParam(name)` | `string \| undefined` | Get a single query parameter | | `queryParams(name)` | `string[]` | Get all values for a query parameter | ### Headers ```typescript import { Controller, Get, header, response } from '@zeltjs/core'; @Controller('/api') export class ApiController { @Get('/info') info( userAgent = header('User-Agent'), acceptLanguage = header('Accept-Language'), res = response(), ) { return res.json({ userAgent, acceptLanguage }); } } ``` | Function | Return Type | Description | |----------|-------------|-------------| | `header(name)` | `string \| undefined` | Get a request header value | ### Cookies ```typescript import { Controller, Get, cookie, response } from '@zeltjs/core'; @Controller('/session') export class SessionController { @Get('/') getSession( sessionId = cookie('session_id'), res = response(), ) { return res.json({ sessionId }); } } ``` | Function | Return Type | Description | |----------|-------------|-------------| | `cookie(name)` | `string \| undefined` | Get a cookie value | ### URL & Path ```typescript import { Controller, Get, url, path, method, response } from '@zeltjs/core'; @Controller('/debug') export class DebugController { @Get('/request') requestInfo( fullUrl = url(), requestPath = path(), httpMethod = method(), res = response(), ) { return res.json({ url: fullUrl, // "http://localhost:3000/debug/request?foo=bar" path: requestPath, // "/debug/request" method: httpMethod // "GET" }); } } ``` | Function | Return Type | Description | |----------|-------------|-------------| | `url()` | `string` | Full request URL including query string | | `path()` | `string` | Request path without query string | | `method()` | `string` | HTTP method (GET, POST, etc.) | ### Request Body ```typescript import { Controller, Post, body, response } from '@zeltjs/core'; @Controller('/upload') export class UploadController { @Post('/text') async uploadText(res = response()) { const text = await body('text'); return res.json({ received: text }); } @Post('/json') async uploadJson(res = response()) { const data = await body('json'); return res.json({ received: data }); } @Post('/form') async uploadForm(res = response()) { const formData = await body('form'); return res.json({ fields: Object.fromEntries(formData) }); } } ``` | Type | Return Type | Description | |------|-------------|-------------| | `body('text')` | `Promise` | Raw text body | | `body('json')` | `Promise` | Parsed JSON body | | `body('form')` | `Promise` | Form data (multipart or urlencoded) | | `body('arrayBuffer')` | `Promise` | Raw binary data | | `body('blob')` | `Promise` | Blob data | :::tip For validated request bodies with automatic type inference, use [`validated()`](./validation.md) instead. ::: ### Path Parameters ```typescript import { Controller, Get, pathParam, response } from '@zeltjs/core'; @Controller('/users') export class UserController { @Get('/:id') getUser( id = pathParam('id'), res = response(), ) { // id: string (throws if not defined) return res.json({ userId: id }); } } ``` | Function | Return Type | Description | |----------|-------------|-------------| | `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) | ### 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 | ## Chaining Response Methods Response methods that modify state (`header`, `setCookie`, `deleteCookie`) return the builder, allowing method chaining: ```typescript @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'); } ``` --- ## 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 }) @Get('/data') getData() { return { items: [] }; } } ``` ## Dynamic Keys Rate limiting keys determine how requests are grouped. Use static strings or functions: ```typescript import { currentUser, headerParam } from '@zeltjs/core'; // By IP address @RateLimit({ limit: 100, windowSec: 60, key: 'ip' }) // By user ID @RateLimit({ limit: 1000, windowSec: 60, key: () => `user:${currentUser()?.id ?? 'anonymous'}`, }) // By API key @RateLimit({ limit: 500, windowSec: 60, key: () => `apikey:${headerParam('X-API-Key')}`, }) ``` ## Programmatic Usage Use `RateLimitService` for custom rate limiting logic: ```typescript import { Controller, Post, inject, response } from '@zeltjs/core'; import { RateLimitService } from '@zeltjs/rate-limit'; @Controller('/auth') export class AuthController { constructor(private rateLimiter = inject(RateLimitService)) {} @Post('/login') async login(body = validated(LoginSchema), res = response()) { 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: ```typescript import { Config } from '@zeltjs/core'; import { RateLimitConfig } from '@zeltjs/rate-limit'; import { createRedisKVStore } from '@zeltjs/kv-redis'; @Config class CustomRateLimitConfig extends RateLimitConfig { override readonly store = createRedisKVStore({ url: process.env.REDIS_URL, }); override readonly defaultLimit = 200; override readonly defaultWindowSec = 120; override readonly failureMode = 'closed' as const; } ``` ## 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 type RateLimiterHitResult = | { ok: true; value: RateLimitResult } | { ok: false; error: RateLimitError }; type RateLimitResult = { 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) }; ``` --- ## 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 } from '@zeltjs/core'; const app = createApp({ http: { controllers: [UserController] }, schedulers: [ReportScheduler], }); ``` ### Starting the Scheduler The scheduler requires explicit startup. After calling `onNode()` and `ready()`, call `startScheduler()` to begin executing scheduled tasks: ```typescript await nodeApp.startScheduler(); ``` To stop the scheduler gracefully: ```typescript await nodeApp.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 @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 @Cron('0 9 * * *', { tz: 'Asia/Tokyo' }) async morningTask() { // Runs at 9:00 AM JST } ``` ### @Daily Run once per day at specified hour: ```typescript @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 @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 @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 @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 } from '@zeltjs/core'; @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 { app } from './app'; const nodeApp = await onNode(app); const handle = await nodeApp.listen(3000); // Start scheduled tasks await nodeApp.startScheduler(); process.on('SIGTERM', async () => { await nodeApp.stopScheduler(); await handle.shutdown(); }); ``` You can conditionally enable the scheduler: ```typescript if (process.env.ENABLE_SCHEDULER !== 'false') { await nodeApp.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 | --- ## 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, pathParam, validated } from '@zeltjs/core'; import * as v from 'valibot'; import { UserService } from './user.service'; const CreateUserBody = v.object({ name: v.string(), }); @Controller('/users') export class UserController { constructor(private userService = inject(UserService)) {} @Get('/') findAll() { return { users: this.userService.findAll() }; } @Get('/:id') findOne(id = pathParam('id')) { const user = this.userService.findOne(id); if (!user) { throw new Error('User not found'); } return user; } @Post('/') create(body = validated(CreateUserBody)) { return this.userService.create(body.name); } } ``` ## Service-to-Service Injection Services can inject other services: ```typescript import { Injectable, inject } from '@zeltjs/core'; import { DatabaseService } from './database.service'; import { LoggerService } from './logger.service'; @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 @Injectable() export class ConfigService { private config: Record; constructor() { this.config = { DATABASE_URL: process.env.DATABASE_URL ?? '', API_KEY: process.env.API_KEY ?? '', }; } get(key: string): string { return this.config[key] ?? ''; } } ``` ## Testing with Mock Services The singleton pattern makes testing straightforward — you can provide mock implementations: ```typescript import { describe, it, expect, vi } from 'vitest'; import { createTestContainer } from '@zeltjs/testing'; import { UserController } from './user.controller'; import { UserService } from './user.service'; describe('UserController', () => { it('should return all users', async () => { const mockUsers = [{ id: '1', name: 'John' }]; const container = createTestContainer() .override(UserService, { findAll: () => mockUsers, }); const controller = container.resolve(UserController); const result = controller.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 --- ## 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 { describe, it, expect } from 'vitest'; import { app } from './app'; describe('Hello API', () => { it('should return greeting', async () => { const res = await app.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 { hc } from 'hono/client'; import { describe, it, expect } from 'vitest'; import { app } from './app'; import type { AppType } from './generated/app.gen'; describe('Hello API', () => { const client = hc('http://localhost', { fetch: (input, init) => app.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, combine with [Integration Testing](./integration.md): ```typescript import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { hc } from 'hono/client'; import { createApp } from './app'; import { RedisTestContainerConfig } from '@zeltjs/testing/redis'; import type { AppType } from './generated/app.gen'; describe('API E2E', () => { let app: ReturnType; let client: ReturnType>; beforeAll(async () => { app = await createApp({ configs: [RedisTestContainerConfig], }); client = hc('http://localhost', { fetch: (input, init) => app.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'); }); }); ``` --- ## 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 { describe, it, expect } from 'vitest'; import { createTestTarget } from '@zeltjs/testing/vitest'; import { RedisTestContainerConfig } from '@zeltjs/testing/redis'; import { CacheService } from './cache.service'; 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'; import { GenericContainer, type StartedTestContainer } from 'testcontainers'; @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 { describe, it, expect } from 'vitest'; import { createTestTarget } from '@zeltjs/testing/vitest'; import { RedisTestContainerConfig } from '@zeltjs/testing/redis'; import { SessionService } from './session.service'; import { UserService } from './user.service'; 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'); }); }); ``` --- ## 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 import { onTest, createTestTarget } from '@zeltjs/testing/vitest'; ``` ### Jest ```typescript import { onTest, createTestTarget } from '@zeltjs/testing/jest'; ``` ### Bun ```typescript import { onTest, createTestTarget } from '@zeltjs/testing/bun'; ``` ### Node.js Test Runner ```typescript 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 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/vitest'; import { UserService } from './user.service'; import { ProcessEnvConfig } from '@zeltjs/core'; describe('UserService', () => { it('should create user', async () => { const { target, shutdown } = await createTestTarget(UserService, { configs: [ProcessEnvConfig], }); 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 { createTestTarget } from '@zeltjs/testing/vitest'; import { UserService } from './user.service'; import { EmailService } from './email.service'; 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. --- ## Validation Zelt uses [Valibot](https://valibot.dev/) for request validation, providing a type-safe and lightweight validation solution. ## Installation ```bash pnpm add @zeltjs/validate-valibot valibot ``` ## Basic Usage Use `validated()` with a Valibot schema to validate request bodies: ```typescript import { Controller, Post, response } from '@zeltjs/core'; import { validated } from '@zeltjs/validate-valibot'; 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('/') create(body = validated(CreateUserSchema), res = response()) { // body is fully typed: { name: string; email: string; age?: number } return res.json({ id: '1', ...body }, 201); } } ``` ## Form Data and File Uploads Use `validated(schema, 'form')` to validate `multipart/form-data` requests, including file uploads: ```typescript import { Controller, Post, response } from '@zeltjs/core'; import { validated } from '@zeltjs/validate-valibot'; import * as v from 'valibot'; const UploadSchema = v.object({ file: v.instance(File), description: v.optional(v.string()), }); @Controller('/upload') export class UploadController { @Post('/') upload(body = validated(UploadSchema, 'form'), res = response()) { // 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 second argument to `validated()` 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 const MultiUploadSchema = v.object({ files: v.array(v.instance(File)), category: v.string(), }); @Post('/bulk') bulkUpload(body = validated(MultiUploadSchema, 'form')) { 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 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 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 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 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 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 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