api-framework-elysia
Bun-native HTTP framework — routing, TypeBox validation, Eden Treaty, plugins, lifecycle hooks
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-framework-elysia/skills/api-framework-elysia
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
API Development with Elysia
Quick Guide: Elysia is a Bun-native HTTP framework with end-to-end type safety. Use method chaining (not separate statements) so TypeScript infers the full route tree. Import
tfromelysiafor TypeBox validation. Export the app type (export type App = typeof app) for Eden Treaty clients. Usestatus()(not the deprecatederror()function) for error responses with type narrowing.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use method chaining on the Elysia instance -- separate .get() calls break type inference for Eden Treaty)
(You MUST use status() for error responses -- error() is deprecated since 1.3, prefer status())
(You MUST export the app type (export type App = typeof app) for Eden Treaty client generation)
</critical_requirements>
Auto-detection: Elysia, elysia, ElysiaJS, Eden Treaty, @elysiajs/eden, @elysiajs/openapi, t.Object, t.String, t.Number, t.File, TypeBox, .derive(), .decorate(), .guard(), .macro(), .ws(), onBeforeHandle, onAfterHandle, onRequest, treaty, bun:test
When to use:
- Building APIs on Bun runtime with end-to-end type safety
- Need RPC-style client with zero code generation (Eden Treaty)
- TypeBox validation with AOT compilation (~18x faster than Zod on Bun)
- Plugin-based architecture with automatic type propagation
- WebSocket support with schema validation
When NOT to use:
- Deploying to Node.js-only environments without Bun (use a Node-first framework)
- Need OpenAPI-first design with
createRoutepatterns (other frameworks with Zod-OpenAPI integration are more mature for this) - Team already committed to Express/Fastify ecosystem
Key patterns covered:
- Route definitions with method chaining and TypeBox validation
- Plugin architecture with
.use(),.derive(),.decorate(),.macro() - Scoping rules (local, scoped, global) and
.guard() - End-to-end type safety with Eden Treaty
- Lifecycle hooks (onRequest, onBeforeHandle, onAfterHandle, onError)
- Error handling with custom error classes and
status() - WebSocket with schema validation
- Testing with
bun:testand.handle()or Eden Treaty
Detailed Resources:
- examples/core.md - Route setup, method chaining, validation, plugins
- examples/eden-treaty.md - End-to-end type-safe client
- examples/lifecycle-errors.md - Lifecycle hooks, error handling, custom errors
- examples/websocket-testing.md - WebSocket patterns and unit testing
- reference.md - Decision frameworks, anti-patterns, production checklist
<red_flags>
RED FLAGS
High Priority:
- Separate
app.get()/app.post()calls instead of chaining -- breaks Eden Treaty type inference entirely - Using deprecated
error()function instead ofstatus()-- deprecated since Elysia 1.3 - Not exporting
type App = typeof app-- Eden Treaty client has no type information - Using
as('plugin')-- removed in 1.3+, useas('scoped')instead
Medium Priority:
- Importing patterns from other HTTP frameworks -- Elysia has its own routing API and conventions
- Using
t.Object()without named constants for limits/lengths -- magic numbers in validation schemas - Not providing
nameon plugin instances -- causes duplicate registration in complex app trees .derive()or lifecycle hooks without{ as: 'scoped' }or{ as: 'global' }when parent routes need them -- hooks are local-scoped by default
Gotchas & Edge Cases:
paramsare strings by default -- uset.Number()in the schema to coerce path params to numbers.handle()in tests requires a fully qualified URL (http://localhost/path), NOT a path fragment (/path).guard()group standalone mode is default in 1.4+ -- guard schemas merge with route schemas instead of overwriting- TypeBox
t.File()auto-detectsmultipart/form-datacontent type -- no need to set headers manually t.Files()(plural) for multiple file uploads,t.File()for single- Eden Treaty dynamic path params use function syntax:
api.user({ id: 1 }).get()notapi.user[1].get() - When Eden Treaty response has status >= 300,
datais alwaysnullanderrorhas the value - Lifecycle hooks only apply to routes registered AFTER the hook -- order of
.on*()and route definitions matters onErrorreceives acodestring, not a status number -- switch oncodefor type narrowing- Cookies parse as JSON automatically if the value looks like JSON (1.3+ behavior)
await app.modulesis required in tests when using lazy-loaded plugins (import('./plugin'))
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST use method chaining on the Elysia instance -- separate .get() calls break type inference for Eden Treaty)
(You MUST use status() for error responses -- error() is deprecated since 1.3, prefer status())
(You MUST export the app type (export type App = typeof app) for Eden Treaty client generation)
Failure to follow these rules will break end-to-end type safety and Eden Treaty client generation.
</critical_reminders>
Files (skills)
-
examples
-
core.md 9.2 KB
# Elysia - Core Examples > Essential patterns for Elysia route setup, validation, and plugin composition. See [SKILL.md](../SKILL.md) for core concepts and [reference.md](../reference.md) for decision frameworks. **Additional Examples:** - [eden-treaty.md](eden-treaty.md) - End-to-end type-safe client - [lifecycle-errors.md](lifecycle-errors.md) - Lifecycle hooks, error handling, custom errors - [websocket-testing.md](websocket-testing.md) - WebSocket patterns and unit testing --- ## Pattern 1: Modular App with Plugins ### Good Example - Method-Chained Modular Setup ```typescript import { Elysia, t } from "elysia"; // Each module is its own Elysia instance with a name and prefix const userRoutes = new Elysia({ name: "user", prefix: "/user" }) .get("/", () => ({ users: [] })) .get("/:id", ({ params: { id } }) => ({ id }), { params: t.Object({ id: t.Number(), }), }) .post("/", ({ body }) => ({ created: body }), { body: t.Object({ name: t.String(), email: t.String({ format: "email" }), }), }); const healthRoutes = new Elysia({ name: "health", prefix: "/health" }).get( "/", () => ({ status: "ok" }), ); // Main app composes plugins via .use() const app = new Elysia().use(userRoutes).use(healthRoutes).listen(3000); // REQUIRED: Export type for Eden Treaty clients export type App = typeof app; ``` **Why good:** `name` prevents duplicate registration, `prefix` scopes routes, method chaining preserves type inference, `export type App` enables Eden Treaty ### Bad Example - Separate Calls and No Plugin Names ```typescript // BAD: Separate calls break type inference import { Elysia } from "elysia"; const app = new Elysia(); // BAD: Each call is separate -- types don't chain app.get("/user", () => ({ users: [] })); app.post("/user", ({ body }) => body); // BAD: No name -- duplicate registration possible const auth = new Elysia(); auth.derive(({ headers }) => ({ user: decodeToken(headers.authorization) })); app.use(auth); // BAD: Default export export default app; ``` **Why bad:** separate route calls break Eden Treaty type inference, unnamed plugins can register multiple times, default export violates project conventions --- ## Pattern 2: TypeBox Validation ### Good Example - Complete Request/Response Validation ```typescript import { Elysia, t } from "elysia"; const MIN_NAME_LENGTH = 1; const MAX_NAME_LENGTH = 100; const MIN_AGE = 0; const MAX_AGE = 150; const DEFAULT_PAGE = 1; const DEFAULT_LIMIT = 20; const app = new Elysia() .post( "/user", ({ body }) => ({ id: crypto.randomUUID(), ...body, }), { body: t.Object({ name: t.String({ minLength: MIN_NAME_LENGTH, maxLength: MAX_NAME_LENGTH, error: "Name must be 1-100 characters", }), email: t.String({ format: "email", error: "Invalid email format" }), age: t.Optional( t.Number({ minimum: MIN_AGE, maximum: MAX_AGE, }), ), }), response: { 200: t.Object({ id: t.String({ format: "uuid" }), name: t.String(), email: t.String(), age: t.Optional(t.Number()), }), 400: t.Object({ error: t.String(), }), }, }, ) .get("/user", ({ query }) => ({ users: [], page: query.page }), { query: t.Object({ page: t.Optional(t.Number({ default: DEFAULT_PAGE })), limit: t.Optional(t.Number({ default: DEFAULT_LIMIT })), search: t.Optional(t.String()), }), }); ``` **Why good:** named constants for all limits, `error` property on schema fields gives custom validation messages, response schemas per status code enable type narrowing, `t.Optional()` for non-required fields ### Bad Example - No Validation ```typescript // BAD: No validation at all new Elysia().post("/user", ({ body }) => { // body is unknown -- runtime crashes on bad input const name = body.name; // TypeError if body is not an object return { id: 1, name }; }); ``` **Why bad:** no TypeBox schema means no validation, no type inference, crashes on malformed input --- ## Pattern 3: .derive() and .decorate() ### Good Example - Per-Request vs Singleton Context ```typescript import { Elysia } from "elysia"; // Singleton service -- same instance for all requests class DatabaseClient { query(sql: string) { return []; } } const db = new DatabaseClient(); const app = new Elysia({ name: "app" }) // .decorate() for singletons (same for all requests) .decorate("db", db) // .derive() for per-request computed values .derive(({ headers }) => { const token = headers.authorization?.replace("Bearer ", ""); return { userId: token ? decodeToken(token) : null, }; }) .get("/profile", ({ userId, db }) => { if (!userId) return { error: "Unauthorized" }; return db.query(`SELECT * FROM users WHERE id = ${userId}`); }); ``` **Why good:** `.decorate()` for the DB client (shared singleton), `.derive()` for userId (computed per request from headers), both are type-safe in route handlers ### Bad Example - Using .decorate() for Per-Request Data ```typescript // BAD: decorate runs once, not per request new Elysia() .decorate("currentUser", null) // Same null for ALL requests .get("/profile", ({ currentUser }) => currentUser); // Always null ``` **Why bad:** `.decorate()` evaluates once at setup time, all requests share the same value --- ## Pattern 4: Guard for Route Groups ### Good Example - Scoped Validation with Guard ```typescript import { Elysia, t } from "elysia"; const app = new Elysia() // Public routes -- no auth required .get("/", () => "welcome") .get("/health", () => ({ status: "ok" })) // Protected routes -- all require auth header .guard( { headers: t.Object({ authorization: t.TemplateLiteral("Bearer ${string}"), }), }, (app) => app .get("/dashboard", ({ headers }) => ({ message: `Authenticated: ${headers.authorization}`, })) .get("/settings", () => ({ theme: "dark" })) .post("/settings", ({ body }) => body, { body: t.Object({ theme: t.Union([t.Literal("dark"), t.Literal("light")]), }), }), ); ``` **Why good:** auth validation defined once for the group, public routes outside guard are unaffected, route-specific body schema merges with guard schema (standalone mode in 1.4+) --- ## Pattern 5: .group() with Prefix ### Good Example - Versioned API Groups ```typescript import { Elysia, t } from "elysia"; const app = new Elysia() .group("/api/v1", (app) => app .get("/users", () => ({ version: 1, users: [] })) .post("/users", ({ body }) => body, { body: t.Object({ name: t.String() }), }), ) .group( "/api/v2", { // Guard schema applied to all routes in this group headers: t.Object({ "x-api-key": t.String(), }), }, (app) => app .get("/users", () => ({ version: 2, users: [] })) .post("/users", ({ body }) => body, { body: t.Object({ name: t.String(), metadata: t.Optional(t.Record(t.String(), t.String())), }), }), ); ``` **Why good:** `.group()` with guard schema combines prefix + validation in one call, v2 group requires API key while v1 does not --- ## Pattern 6: Reference Models ### Good Example - Reusable Named Schemas ```typescript import { Elysia, t } from "elysia"; const MIN_PASSWORD_LENGTH = 8; // Define reusable models once const app = new Elysia() .model({ signIn: t.Object({ username: t.String(), password: t.String({ minLength: MIN_PASSWORD_LENGTH }), }), user: t.Object({ id: t.String(), username: t.String(), createdAt: t.String({ format: "date-time" }), }), }) // Reference by name -- string key maps to registered model .post("/sign-in", ({ body }) => authenticate(body), { body: "signIn", response: "user", }) .post("/sign-up", ({ body }) => createUser(body), { body: "signIn", response: "user", }); ``` **Why good:** `.model()` registers reusable schemas, reference by string key in route options, single source of truth for shared shapes, eliminates schema duplication across routes --- ## Pattern 7: Macro for Cross-Cutting Concerns ### Good Example - Auth Macro with Schema ```typescript import { Elysia, t } from "elysia"; const HTTP_UNAUTHORIZED = 401; const authPlugin = new Elysia({ name: "auth" }).macro({ isSignedIn: { // Macro adds cookie validation automatically cookie: t.Object({ session: t.String(), }), // resolve runs before handler, can short-circuit resolve({ cookie: { session }, status }) { if (!session.value) { return status(HTTP_UNAUTHORIZED, "Unauthorized"); } return { userId: decodeSession(session.value) }; }, }, }); const app = new Elysia() .use(authPlugin) .get("/public", () => "hello") .get( "/profile", ({ userId }) => ({ userId }), // userId injected by macro { isSignedIn: true }, // Enable the macro for this route ); ``` **Why good:** macros encapsulate cross-cutting concerns (auth, rate limiting) as declarative flags on routes, schema and resolve are bundled together, `isSignedIn: true` is self-documenting in route options -
eden-treaty.md 5.5 KB
# Elysia - Eden Treaty Examples > End-to-end type-safe client patterns. See [core.md](core.md) for route setup patterns. **Prerequisites**: Understand method chaining and `export type App` from core examples first. --- ## Pattern 1: Basic Eden Treaty Setup ### Good Example - Type-Safe Client ```typescript // server.ts import { Elysia, t } from "elysia"; const app = new Elysia() .get( "/user/:id", ({ params: { id } }) => ({ id, name: "Alice", email: "alice@example.com", }), { params: t.Object({ id: t.Number() }), }, ) .post( "/user", ({ body }) => ({ id: crypto.randomUUID(), ...body, }), { body: t.Object({ name: t.String(), email: t.String({ format: "email" }), }), }, ) .listen(3000); export type App = typeof app; ``` ```typescript // client.ts import { treaty } from "@elysiajs/eden"; import type { App } from "./server"; const api = treaty<App>("localhost:3000"); // GET /user/:id -- dynamic params use function syntax const { data, error } = await api.user({ id: 42 }).get(); if (error) { // error is typed based on route's error responses console.error(error); return; } // data is typed as { id: number; name: string; email: string } console.log(data.name); // POST /user -- body passed as argument const { data: created } = await api.user.post({ name: "Bob", email: "bob@example.com", }); ``` **Why good:** zero code generation, paths and methods are fully typed via dot notation, dynamic params use function syntax (not bracket notation), `data`/`error` destructuring provides type narrowing ### Bad Example - Losing Type Safety ```typescript // BAD: Using fetch instead of treaty const response = await fetch("http://localhost:3000/user/42"); const data = await response.json(); // data is 'any' // BAD: Wrong dynamic param syntax const { data } = await api.user[42].get(); // This syntax doesn't work! ``` **Why bad:** raw `fetch` loses all type information, bracket notation doesn't work for dynamic params in Eden Treaty --- ## Pattern 2: Response Handling ### Good Example - Error/Data Type Narrowing ```typescript import { treaty } from "@elysiajs/eden"; import type { App } from "./server"; const api = treaty<App>("localhost:3000"); async function getUser(id: number) { const { data, error, status } = await api.user({ id }).get(); // When status >= 300: data is null, error has the value // When status < 300: error is null, data has the value if (error) { switch (error.status) { case 404: console.log("User not found"); break; case 500: console.log("Server error:", error.value); break; } return null; } // TypeScript knows data is non-null here return data; } ``` **Why good:** Eden Treaty splits responses into `data` (success) and `error` (failure) with proper type narrowing, `error.status` enables exhaustive handling by status code --- ## Pattern 3: File Upload via Treaty ### Good Example - Multipart Upload ```typescript // server.ts import { Elysia, t } from "elysia"; const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB const app = new Elysia().post( "/upload", ({ body }) => ({ name: body.file.name, size: body.file.size, }), { body: t.Object({ file: t.File({ maxSize: MAX_FILE_SIZE_BYTES }), description: t.Optional(t.String()), }), }, ); export type App = typeof app; ``` ```typescript // client.ts import { treaty } from "@elysiajs/eden"; import type { App } from "./server"; const api = treaty<App>("localhost:3000"); // Eden Treaty handles FormData conversion automatically const { data } = await api.upload.post({ file: new File(["content"], "readme.txt"), description: "A text file", }); ``` **Why good:** `t.File()` auto-detects multipart content type, Eden Treaty converts the object to FormData automatically, file validation (max size) runs server-side --- ## Pattern 4: Treaty with Headers and Auth ### Good Example - Custom Headers per Request ```typescript import { treaty } from "@elysiajs/eden"; import type { App } from "./server"; // Set default headers for all requests const api = treaty<App>("localhost:3000", { headers: { "x-api-version": "1", }, }); // Override headers per request using $headers const { data } = await api.profile.get({ $headers: { authorization: `Bearer ${token}`, }, }); ``` **Why good:** treaty config sets baseline headers, `$headers` in request options overrides per-call, headers are type-checked if the route has a `headers` schema --- ## Pattern 5: Treaty Unit Testing (No Network) ### Good Example - Direct Instance Testing ```typescript import { describe, expect, it } from "bun:test"; import { Elysia, t } from "elysia"; import { treaty } from "@elysiajs/eden"; const app = new Elysia() .get("/hello", () => "hi") .get("/user/:id", ({ params: { id } }) => ({ id, name: "Test" }), { params: t.Object({ id: t.Number() }), }); // Pass Elysia instance directly -- no HTTP server started const api = treaty(app); describe("User API", () => { it("returns greeting", async () => { const { data } = await api.hello.get(); expect(data).toBe("hi"); }); it("returns user by id", async () => { const { data } = await api.user({ id: 1 }).get(); expect(data?.name).toBe("Test"); }); }); ``` **Why good:** passing the Elysia instance directly to `treaty()` bypasses HTTP -- tests run without starting a server, full type safety and lifecycle hooks still execute, faster than network-based tests -
lifecycle-errors.md 9 KB
# Elysia - Lifecycle Hooks & Error Handling Examples > Lifecycle hooks, custom errors, and error handling patterns. See [core.md](core.md) for route setup patterns. **Prerequisites**: Understand route definitions and `.derive()` from core examples first. --- ## Lifecycle Hook Order Elysia processes requests through these phases in order: 1. **onRequest** -- First hook, runs for every request (rate limiting, CORS) 2. **onParse** -- Body parsing 3. **onTransform** -- Mutate context before validation (coerce types) 4. **onBeforeHandle** -- After validation, before handler (auth checks) 5. **Route Handler** -- Your business logic 6. **onAfterHandle** -- After handler, before response (add headers) 7. **onMapResponse** -- Transform response shape 8. **onError** -- Runs ONLY when an error is thrown anywhere 9. **onAfterResponse** -- After response sent to client (cleanup, analytics) **Critical:** Hooks only apply to routes registered AFTER the hook. Order matters. --- ## Pattern 1: onBeforeHandle for Auth ### Good Example - Auth Guard via Lifecycle Hook ```typescript import { Elysia, t } from "elysia"; const HTTP_UNAUTHORIZED = 401; const authPlugin = new Elysia({ name: "auth" }) .derive({ as: "scoped" }, ({ headers }) => ({ bearerToken: headers.authorization?.replace("Bearer ", "") ?? null, })) .onBeforeHandle({ as: "scoped" }, ({ bearerToken, status }) => { if (!bearerToken) { // Returning a value short-circuits the handler return status(HTTP_UNAUTHORIZED, { error: "Missing Authorization header", }); } }); const app = new Elysia() .get("/public", () => "no auth needed") .use(authPlugin) // Auth applies to routes registered AFTER this .get("/protected", ({ bearerToken }) => ({ message: `Authenticated with token: ${bearerToken}`, })); ``` **Why good:** `{ as: "scoped" }` propagates the hook to the parent (not just within the plugin), returning a value from `onBeforeHandle` short-circuits the handler, public routes defined before `.use(authPlugin)` are unaffected ### Bad Example - Local Scope Doesn't Propagate ```typescript // BAD: Hook stays local -- parent routes never see it const HTTP_UNAUTHORIZED = 401; const authPlugin = new Elysia({ name: "auth" }).onBeforeHandle( ({ headers, status }) => { // This ONLY runs for routes inside authPlugin, not parent routes if (!headers.authorization) return status(HTTP_UNAUTHORIZED, "Unauthorized"); }, ); ``` **Why bad:** default `local` scope means the hook only applies inside the plugin -- routes in the parent that `.use(authPlugin)` will NOT run this hook --- ## Pattern 2: onRequest for Rate Limiting ### Good Example - Early Request Interception ```typescript import { Elysia } from "elysia"; const RATE_LIMIT_WINDOW_MS = 60_000; const MAX_REQUESTS_PER_WINDOW = 100; const HTTP_TOO_MANY_REQUESTS = 429; const rateLimiter = new Map<string, { count: number; resetAt: number }>(); const rateLimitPlugin = new Elysia({ name: "rate-limit" }).onRequest( { as: "global" }, ({ request, server, status }) => { const ip = server?.requestIP(request)?.address ?? "unknown"; const now = Date.now(); const entry = rateLimiter.get(ip); if (entry && now < entry.resetAt) { if (entry.count >= MAX_REQUESTS_PER_WINDOW) { return status(HTTP_TOO_MANY_REQUESTS, "Rate limit exceeded"); } entry.count++; } else { rateLimiter.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS, }); } }, ); ``` **Why good:** `onRequest` is the first lifecycle hook (runs before parsing), `{ as: "global" }` applies to ALL routes in the entire app, rate limiting at the earliest possible point avoids unnecessary work --- ## Pattern 3: onAfterHandle for Response Headers ### Good Example - Add Custom Headers to Responses ```typescript import { Elysia } from "elysia"; const CACHE_MAX_AGE_SECONDS = 3600; const app = new Elysia() .onAfterHandle({ as: "global" }, ({ set }) => { set.headers["x-powered-by"] = "Elysia"; set.headers["x-request-id"] = crypto.randomUUID(); }) .get("/api/data", ({ set }) => { // Route-specific headers set.headers["cache-control"] = `public, max-age=${CACHE_MAX_AGE_SECONDS}`; return { data: "cached content" }; }); ``` **Why good:** `onAfterHandle` runs after the handler completes, ideal for response decoration, `set.headers` is the mutable response header object --- ## Pattern 4: Custom Error Classes ### Good Example - Typed Custom Errors ```typescript import { Elysia } from "elysia"; const HTTP_BAD_REQUEST = 400; const HTTP_NOT_FOUND = 404; const HTTP_FORBIDDEN = 403; class ValidationError extends Error { status = HTTP_BAD_REQUEST; constructor( public field: string, message: string, ) { super(message); } } class NotFoundError extends Error { status = HTTP_NOT_FOUND; constructor(public resource: string) { super(`${resource} not found`); } } class ForbiddenError extends Error { status = HTTP_FORBIDDEN; constructor() { super("Insufficient permissions"); } } const app = new Elysia() .error({ ValidationError, NotFoundError, ForbiddenError, }) .onError(({ code, error, status }) => { // code is narrowed to string union of registered error names switch (code) { case "ValidationError": // error is typed as ValidationError here return status(HTTP_BAD_REQUEST, { error: "validation_error", field: error.field, message: error.message, }); case "NotFoundError": return status(HTTP_NOT_FOUND, { error: "not_found", resource: error.resource, }); case "ForbiddenError": return status(HTTP_FORBIDDEN, { error: "forbidden", message: error.message, }); case "VALIDATION": // Built-in Elysia validation error return status(HTTP_BAD_REQUEST, { error: "validation_error", message: "Request validation failed", }); case "NOT_FOUND": // Built-in Elysia 404 return status(HTTP_NOT_FOUND, { error: "not_found", message: "Route not found", }); } }) .get("/user/:id", ({ params: { id } }) => { const user = findUser(id); if (!user) throw new NotFoundError("User"); return user; }); ``` **Why good:** `.error()` registers custom error classes for type-safe `code` narrowing in `onError`, `status` property on error class sets the default HTTP status, built-in codes (`VALIDATION`, `NOT_FOUND`, `PARSE`, `INTERNAL_SERVER_ERROR`) coexist with custom ones --- ## Pattern 5: onTransform for Input Coercion ### Good Example - Coerce Before Validation ```typescript import { Elysia, t } from "elysia"; const app = new Elysia() .onTransform(({ params }) => { // Coerce string params to numbers before validation runs if (params && "id" in params) { const parsed = Number(params.id); if (!Number.isNaN(parsed)) { params.id = parsed; } } }) .get("/user/:id", ({ params: { id } }) => ({ id }), { params: t.Object({ id: t.Number() }), }); ``` **Why good:** `onTransform` runs BEFORE validation, so coerced values pass the `t.Number()` check, avoids validation failures for URL params that are always strings > **Note:** Elysia 1.3+ has automatic type coercion for query/params when using TypeBox schemas, so manual `onTransform` coercion is often unnecessary. Use it for custom coercion logic only. --- ## Pattern 6: onAfterResponse for Cleanup ### Good Example - Logging and Analytics ```typescript import { Elysia } from "elysia"; const app = new Elysia() .onAfterResponse({ as: "global" }, ({ request, set }) => { // Runs AFTER the response is sent to the client // Safe for slow operations -- doesn't affect response time console.log( JSON.stringify({ method: request.method, path: new URL(request.url).pathname, status: set.status, timestamp: new Date().toISOString(), }), ); }) .get("/", () => "hello"); ``` **Why good:** `onAfterResponse` runs after the client receives the response, expensive logging or analytics don't add latency, `{ as: "global" }` captures all routes --- ## Pattern 7: Throwing vs Returning Status ### Important Distinction ```typescript import { Elysia } from "elysia"; const HTTP_UNAUTHORIZED = 401; const HTTP_TEAPOT = 418; const app = new Elysia() .onError(({ code }) => { console.log("onError triggered:", code); }) // THROWING status triggers onError .get("/throw", ({ status }) => { throw status(HTTP_TEAPOT, "I'm a teapot"); // onError WILL run with code "UNKNOWN" }) // RETURNING status does NOT trigger onError .get("/return", ({ status }) => { return status(HTTP_UNAUTHORIZED, "Not authorized"); // onError will NOT run -- response sent directly }); ``` **Why important:** throwing `status()` routes through `onError` for centralized handling, returning `status()` bypasses `onError` entirely and sends the response directly -- choose based on whether you want centralized error handling for this case -
websocket-testing.md 7.2 KB
# Elysia - WebSocket & Testing Examples > WebSocket patterns and unit testing approaches. See [core.md](core.md) for route setup patterns. **Prerequisites**: Understand route definitions and validation from core examples first. --- ## Pattern 1: Basic WebSocket ### Good Example - Echo Server with Validation ```typescript import { Elysia, t } from "elysia"; const app = new Elysia().ws("/ws", { // Validate incoming messages with TypeBox body: t.Object({ type: t.Union([t.Literal("chat"), t.Literal("ping")]), content: t.String(), }), // Validate query params on initial connection query: t.Object({ room: t.String(), }), open(ws) { const { room } = ws.data.query; ws.subscribe(room); ws.send({ event: "joined", room }); }, message(ws, { type, content }) { // message is already validated and typed if (type === "ping") { ws.send({ event: "pong" }); return; } // Publish to all subscribers in the room const { room } = ws.data.query; ws.publish(room, { event: "message", content, room }); }, close(ws) { const { room } = ws.data.query; ws.unsubscribe(room); }, }); ``` **Why good:** `body` schema validates every incoming message (invalid messages are rejected), `query` validates connection params, `ws.subscribe`/`ws.publish` for pub/sub channels, destructured `message` params are fully typed ### Bad Example - No Validation ```typescript // BAD: No message validation new Elysia().ws("/ws", { message(ws, message) { // message is unknown -- could be anything ws.send(JSON.stringify({ echo: message })); }, }); ``` **Why bad:** no TypeBox schema means unvalidated input, `message` type is unknown, crashes on unexpected payloads --- ## Pattern 2: WebSocket with Derive ### Good Example - Auth in WebSocket ```typescript import { Elysia, t } from "elysia"; const HTTP_UNAUTHORIZED = 401; const wsApp = new Elysia() .derive(({ headers, status }) => { const token = headers.authorization?.replace("Bearer ", ""); if (!token) return status(HTTP_UNAUTHORIZED, "Missing auth"); return { userId: decodeToken(token) }; }) .ws("/notifications", { body: t.Object({ type: t.Literal("subscribe"), channel: t.String(), }), open(ws) { // userId is available from derive const { userId } = ws.data; ws.subscribe(`user:${userId}`); }, message(ws, { channel }) { const { userId } = ws.data; ws.subscribe(`${channel}:${userId}`); }, }); ``` **Why good:** `.derive()` runs before the WebSocket upgrade, auth happens at connection time (not per message), `ws.data` carries the derived context --- ## Pattern 3: Unit Testing with .handle() ### Good Example - Testing Without Network ```typescript import { describe, expect, it } from "bun:test"; import { Elysia, t } from "elysia"; const HTTP_NOT_FOUND = 404; const HTTP_UNPROCESSABLE_ENTITY = 422; // Create app instance (no .listen() needed for tests) const app = new Elysia() .get("/hello", () => "world") .post("/user", ({ body }) => ({ id: 1, ...body }), { body: t.Object({ name: t.String(), email: t.String({ format: "email" }), }), }) .get( "/user/:id", ({ params: { id }, status }) => { if (id === 0) return status(HTTP_NOT_FOUND, { error: "Not found" }); return { id, name: "Alice" }; }, { params: t.Object({ id: t.Number() }), }, ); describe("API Routes", () => { it("GET /hello returns world", async () => { // IMPORTANT: Must use fully qualified URL const response = await app.handle(new Request("http://localhost/hello")); const text = await response.text(); expect(text).toBe("world"); }); it("POST /user creates user", async () => { const response = await app.handle( new Request("http://localhost/user", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Alice", email: "alice@example.com", }), }), ); const data = await response.json(); expect(data.id).toBe(1); expect(data.name).toBe("Alice"); }); it("POST /user rejects invalid email", async () => { const response = await app.handle( new Request("http://localhost/user", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Alice", email: "not-an-email", }), }), ); expect(response.status).toBe(HTTP_UNPROCESSABLE_ENTITY); }); it("GET /user/0 returns 404", async () => { const response = await app.handle(new Request("http://localhost/user/0")); expect(response.status).toBe(HTTP_NOT_FOUND); }); }); ``` **Why good:** `.handle()` processes the full lifecycle (validation, hooks, handler) without starting an HTTP server, fully qualified URLs are required (not path fragments), tests run fast with no network overhead > **Note:** `.handle()` requires a fully qualified URL (`http://localhost/path`), not a path fragment (`/path`). See [reference.md](../reference.md) for the anti-pattern. For type-safe testing with Eden Treaty (no HTTP server needed), see [eden-treaty.md Pattern 5](eden-treaty.md#pattern-5-treaty-unit-testing-no-network). --- ## Pattern 4: Testing with Async Plugins ### Good Example - Awaiting Lazy-Loaded Modules ```typescript import { describe, expect, it, beforeAll } from "bun:test"; import { Elysia } from "elysia"; // App with lazy-loaded plugin const app = new Elysia() .use(import("./user-plugin")) .use(import("./health-plugin")); describe("App with lazy plugins", () => { beforeAll(async () => { // REQUIRED: Wait for all async plugins to resolve await app.modules; }); it("responds to lazy-loaded routes", async () => { const response = await app.handle(new Request("http://localhost/user/1")); expect(response.status).toBe(200); }); }); ``` **Why good:** `await app.modules` resolves all lazy-loaded plugins before tests run, without this line, routes from async plugins may not be registered yet --- ## Pattern 5: Testing Lifecycle Hooks ### Good Example - Verifying Hook Behavior ```typescript import { describe, expect, it } from "bun:test"; import { Elysia, t } from "elysia"; const HTTP_UNAUTHORIZED = 401; const app = new Elysia() .derive(({ headers }) => ({ apiKey: headers["x-api-key"] ?? null, })) .onBeforeHandle(({ apiKey, status }) => { if (!apiKey) return status(HTTP_UNAUTHORIZED, "Missing API key"); }) .get("/protected", ({ apiKey }) => ({ key: apiKey })); describe("Auth lifecycle", () => { it("rejects requests without API key", async () => { const response = await app.handle( new Request("http://localhost/protected"), ); expect(response.status).toBe(HTTP_UNAUTHORIZED); }); it("accepts requests with API key", async () => { const response = await app.handle( new Request("http://localhost/protected", { headers: { "x-api-key": "valid-key" }, }), ); expect(response.status).toBe(200); const data = await response.json(); expect(data.key).toBe("valid-key"); }); }); ``` **Why good:** `.handle()` executes the full lifecycle including `.derive()` and `onBeforeHandle`, tests verify both the auth rejection and success paths, named constant for status code
-
-
reference.md 8 KB
# Elysia Quick Reference > Decision frameworks, anti-patterns, and production checklist for Elysia. Referenced from [SKILL.md](SKILL.md). --- <decision_framework> ## Decision Framework ### When to Use Elysia - Building on Bun runtime with type safety as the top priority - Need RPC-style client with zero code generation (Eden Treaty) - High-performance validation with TypeBox AOT compilation - WebSocket support with schema validation baked in - Plugin-based architecture with automatic type propagation ### When NOT to Use Elysia - Node.js-only deployment (Elysia is Bun-first; Node adapter exists but is secondary) - OpenAPI-first design workflow (Zod-OpenAPI integration is more mature in other frameworks) - Team already invested in Express/Fastify middleware ecosystem - Need broad community plugin ecosystem (Express/Fastify have more third-party middleware) ### TypeBox vs Standard Schema (Zod, Valibot, etc.) **Use TypeBox (`t` from `elysia`):** - Default choice for Bun projects -- AOT compilation is ~18x faster than Zod - Full OpenAPI generation support with `@elysiajs/openapi` - Best type inference with Elysia's internal types **Use Standard Schema (Zod, Valibot, ArkType):** - Sharing schemas between Bun backend and non-Bun frontend - Team already has Zod schemas and doesn't want to rewrite - Need Zod ecosystem (zod-to-json-schema, shared schema libraries) **Mix validators in same route (1.4+):** - Elysia 1.4+ supports different validators per field (params: Zod, body: Valibot) - Standalone mode merges schemas rather than overwriting ### Scope Decision | Scope | Child | Current | Parent | Main | Use When | | -------- | ----- | ------- | ------ | ---- | -------------------------------------------- | | `local` | yes | yes | no | no | Default. Plugin-internal hooks and state. | | `scoped` | yes | yes | yes | no | Hooks that parent plugins need but not main. | | `global` | yes | yes | yes | yes | Hooks that EVERY route in the app must run. | ### State vs Decorate vs Derive **`.state(key, value)`** -- Global mutable store. Shared across all requests. Access via `store`. **`.decorate(key, value)`** -- Singleton value on context. Should NOT be mutated. Use for services, utilities, DB clients. **`.derive(handler)`** -- Per-request computed value. Runs at transform phase (before validation). Can access `headers`, `query`, `body`. **`.resolve(handler)`** -- Like `.derive()` but runs after validation. Access validated request data. **Rule of thumb:** If it's the same for every request, use `.decorate()`. If it changes per request, use `.derive()` or `.resolve()`. ### Eden Treaty vs REST Client **Use Eden Treaty:** - Full-stack TypeScript monorepo - Same Elysia version on client and server - Want end-to-end type safety without code generation - Internal APIs consumed by your own frontend **Use REST/OpenAPI Client:** - Multi-language clients (Python, Go, etc.) - External consumers need generated SDKs - Different teams own server and client - Need formal OpenAPI documentation for third parties ### Guard vs Inline Validation **Use `.guard()`:** - Multiple routes share the same validation (e.g., auth headers) - Want to encapsulate a group of protected routes - Cleaner than repeating schemas on every route **Use inline validation:** - Route has unique schema not shared with others - Simple one-off validation - Schema is small (1-2 fields) </decision_framework> --- <anti_patterns> ## Anti-Patterns to Avoid ### Breaking the Chain ```typescript // ANTI-PATTERN: Separate calls break type inference const app = new Elysia(); app.get("/users", () => "list"); app.post("/users", ({ body }) => body); export type App = typeof app; // Types won't include route details! ``` **Why it's wrong:** Eden Treaty client will have no type information for routes. Type inference only flows through chained calls. **What to do instead:** Chain all route definitions: `new Elysia().get(...).post(...)`. --- ### Using Deprecated error() Function ```typescript // ANTI-PATTERN: error() is deprecated since 1.3 import { Elysia } from "elysia"; new Elysia().get("/", ({ error }) => { return error(404, "Not found"); // Deprecated! }); ``` **Why it's wrong:** `error()` was deprecated in 1.3 in favor of `status()`. Use `status()` for all new code. **What to do instead:** Use `status()`: ```typescript const HTTP_NOT_FOUND = 404; new Elysia().get("/", ({ status }) => { return status(HTTP_NOT_FOUND, "Not found"); }); ``` --- ### Controller Classes with Context Parameters ```typescript // ANTI-PATTERN: Passing Context to class methods abstract class Controller { static root(context: Context) { // Hard to type, loses type integrity } } new Elysia().get("/", (ctx) => Controller.root(ctx)); ``` **Why it's wrong:** Creates "hard to type" situations, loses type safety. Context types are complex and change per route. **What to do instead:** Destructure what you need and call service functions with plain values: ```typescript new Elysia().get("/", ({ query }) => UserService.list(query)); ``` --- ### Overusing .decorate() for Request-Dependent Data ```typescript // ANTI-PATTERN: Decorating with per-request data new Elysia() .decorate("currentUser", null) // Will be same for ALL requests! .get("/profile", ({ currentUser }) => currentUser); ``` **Why it's wrong:** `.decorate()` runs once at setup, not per request. All requests share the same value. **What to do instead:** Use `.derive()` for per-request computed values: ```typescript new Elysia() .derive(({ headers }) => ({ currentUser: getUserFromToken(headers.authorization), })) .get("/profile", ({ currentUser }) => currentUser); ``` --- ### Not Naming Plugins ```typescript // ANTI-PATTERN: Unnamed plugins get re-registered const auth = new Elysia() // No name! .derive(({ headers }) => ({ user: decodeJwt(headers.authorization) })); new Elysia() .use(auth) // Registered once .use(somePluginThatAlsoUsesAuth); // auth registered AGAIN ``` **Why it's wrong:** Without `name`, Elysia cannot deduplicate. The derive handler runs twice per request. **What to do instead:** ```typescript const auth = new Elysia({ name: "auth" }).derive(({ headers }) => ({ user: decodeJwt(headers.authorization), })); ``` --- ### Forgetting Scope on Shared Hooks ```typescript // ANTI-PATTERN: Hook only applies locally const logger = new Elysia({ name: "logger" }).onBeforeHandle(({ request }) => { console.log(request.url); }); new Elysia().use(logger).get("/", () => "hi"); // Logger does NOT run for this route! ``` **Why it's wrong:** Lifecycle hooks default to `local` scope -- they only apply to routes inside the plugin, not to routes in the parent. **What to do instead:** ```typescript const logger = new Elysia({ name: "logger" }).onBeforeHandle( { as: "scoped" }, ({ request }) => { console.log(request.url); }, ); ``` --- ### Wrong URL in .handle() Tests ```typescript // ANTI-PATTERN: Partial URL path const response = await app.handle(new Request("/users")); // Throws! ``` **Why it's wrong:** `.handle()` requires a fully qualified URL with protocol and host. **What to do instead:** ```typescript const response = await app.handle(new Request("http://localhost/users")); ``` </anti_patterns> --- ## Production Checklist ### Before Deploying - [ ] All route definitions are method-chained (not separate statements) - [ ] `export type App = typeof app` present if using Eden Treaty - [ ] Using `status()` not `error()` for error responses - [ ] No magic numbers in validation schemas or status codes - [ ] Plugin instances have `name` property for deduplication - [ ] `.derive()` / `.resolve()` used for per-request data (not `.decorate()`) - [ ] Lifecycle hooks have correct scope (`local` / `scoped` / `global`) - [ ] `onError` handles `VALIDATION`, `NOT_FOUND`, `PARSE` codes at minimum - [ ] WebSocket handlers have TypeBox schemas for message validation - [ ] Tests use fully qualified URLs in `.handle()` calls - [ ] `await app.modules` called in tests if using lazy-loaded plugins -
SKILL.md 10.5 KB
--- name: api-framework-elysia description: Bun-native HTTP framework — routing, TypeBox validation, Eden Treaty, plugins, lifecycle hooks --- # API Development with Elysia > **Quick Guide:** Elysia is a Bun-native HTTP framework with end-to-end type safety. Use method chaining (not separate statements) so TypeScript infers the full route tree. Import `t` from `elysia` for TypeBox validation. Export the app type (`export type App = typeof app`) for Eden Treaty clients. Use `status()` (not the deprecated `error()` function) for error responses with type narrowing. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST use method chaining on the Elysia instance -- separate `.get()` calls break type inference for Eden Treaty)** **(You MUST use `status()` for error responses -- `error()` is deprecated since 1.3, prefer `status()`)** **(You MUST export the app type (`export type App = typeof app`) for Eden Treaty client generation)** </critical_requirements> --- **Auto-detection:** Elysia, elysia, ElysiaJS, Eden Treaty, @elysiajs/eden, @elysiajs/openapi, t.Object, t.String, t.Number, t.File, TypeBox, .derive(), .decorate(), .guard(), .macro(), .ws(), onBeforeHandle, onAfterHandle, onRequest, treaty, bun:test **When to use:** - Building APIs on Bun runtime with end-to-end type safety - Need RPC-style client with zero code generation (Eden Treaty) - TypeBox validation with AOT compilation (~18x faster than Zod on Bun) - Plugin-based architecture with automatic type propagation - WebSocket support with schema validation **When NOT to use:** - Deploying to Node.js-only environments without Bun (use a Node-first framework) - Need OpenAPI-first design with `createRoute` patterns (other frameworks with Zod-OpenAPI integration are more mature for this) - Team already committed to Express/Fastify ecosystem **Key patterns covered:** - Route definitions with method chaining and TypeBox validation - Plugin architecture with `.use()`, `.derive()`, `.decorate()`, `.macro()` - Scoping rules (local, scoped, global) and `.guard()` - End-to-end type safety with Eden Treaty - Lifecycle hooks (onRequest, onBeforeHandle, onAfterHandle, onError) - Error handling with custom error classes and `status()` - WebSocket with schema validation - Testing with `bun:test` and `.handle()` or Eden Treaty **Detailed Resources:** - [examples/core.md](examples/core.md) - Route setup, method chaining, validation, plugins - [examples/eden-treaty.md](examples/eden-treaty.md) - End-to-end type-safe client - [examples/lifecycle-errors.md](examples/lifecycle-errors.md) - Lifecycle hooks, error handling, custom errors - [examples/websocket-testing.md](examples/websocket-testing.md) - WebSocket patterns and unit testing - [reference.md](reference.md) - Decision frameworks, anti-patterns, production checklist --- <philosophy> ## Philosophy **Method chaining IS the type system.** Elysia infers the entire route tree through chained calls. Breaking the chain (separate `app.get()` statements) loses type information for Eden Treaty clients. This is the single most important architectural constraint. **TypeBox over Zod for Bun.** While Elysia 1.4+ supports Standard Schema (Zod, Valibot, etc.), TypeBox (`t` from `elysia`) uses AOT compilation inside Bun for ~18x faster validation. Use TypeBox as default; use Zod only when sharing schemas with a non-Bun codebase. **Plugins are Elysia instances.** Every `new Elysia()` is a plugin. There is no separate plugin API -- you compose by chaining `.use()`. The `name` property deduplicates plugins across the tree. </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Route Setup with Method Chaining Chain route definitions on the Elysia instance. Each `.get()`, `.post()`, etc. returns the instance with updated type information. ```typescript import { Elysia, t } from "elysia"; const app = new Elysia() .get("/", () => "hello") .get("/user/:id", ({ params: { id } }) => id, { params: t.Object({ id: t.Number(), }), }) .post("/user", ({ body }) => body, { body: t.Object({ name: t.String(), email: t.String({ format: "email" }), }), }) .listen(3000); export type App = typeof app; ``` **Why good:** method chaining preserves type inference across the entire route tree, `export type App` enables Eden Treaty, TypeBox validates at runtime with AOT compilation See [examples/core.md](examples/core.md) for complete route setup with modular plugins. --- ### Pattern 2: Plugin Architecture Every Elysia instance is a plugin. Use `.use()` to compose, `name` to deduplicate. ```typescript import { Elysia } from "elysia"; const userPlugin = new Elysia({ name: "user", prefix: "/user" }) .get("/", () => "list users") .get("/:id", ({ params: { id } }) => `user ${id}`); const app = new Elysia().use(userPlugin).listen(3000); ``` **Why good:** `name` prevents duplicate registration when a plugin is `.use()`-d multiple times, `prefix` scopes routes cleanly See [examples/core.md](examples/core.md) for `.derive()`, `.decorate()`, and `.macro()` patterns. --- ### Pattern 3: Scoping with Guard Apply validation schemas and lifecycle hooks to groups of routes. ```typescript import { Elysia, t } from "elysia"; const app = new Elysia() .guard( { headers: t.Object({ authorization: t.String(), }), }, (app) => app .get("/protected", ({ headers }) => headers.authorization) .post("/admin", ({ body }) => body, { body: t.Object({ action: t.String() }), }), ) .get("/public", () => "no auth needed"); ``` **Why good:** guard encapsulates validation for route groups without repeating schema definitions, public routes outside the guard are unaffected --- ### Pattern 4: Error Handling with status() Use `status()` for typed error responses. Register custom error classes with `.error()`. ```typescript import { Elysia } from "elysia"; const HTTP_UNAUTHORIZED = 401; const HTTP_NOT_FOUND = 404; class NotFoundError extends Error { status = HTTP_NOT_FOUND; constructor(public resource: string) { super(`${resource} not found`); } } const app = new Elysia() .error({ NotFoundError }) .onError(({ code, error, status }) => { if (code === "NotFoundError") { return status(HTTP_NOT_FOUND, { error: error.message }); } if (code === "VALIDATION") { return status(HTTP_UNAUTHORIZED, { error: "Validation failed" }); } }) .get("/user/:id", ({ params: { id }, status }) => { const user = findUser(id); if (!user) throw new NotFoundError("User"); return user; }); ``` **Why good:** `status()` provides type narrowing for error responses, custom error classes with `.error()` enable `code`-based type narrowing in `onError`, named constants avoid magic status numbers See [examples/lifecycle-errors.md](examples/lifecycle-errors.md) for all lifecycle hooks and error patterns. --- ### Pattern 5: Eden Treaty Client Export the app type and use `treaty()` for a fully type-safe client with no code generation. ```typescript // server.ts import { Elysia, t } from "elysia"; const app = new Elysia() .get("/user/:id", ({ params: { id } }) => ({ id, name: "Alice" }), { params: t.Object({ id: t.Number() }), }) .post("/user", ({ body }) => body, { body: t.Object({ name: t.String() }), }); export type App = typeof app; ``` ```typescript // client.ts import { treaty } from "@elysiajs/eden"; import type { App } from "./server"; const api = treaty<App>("localhost:3000"); const { data, error } = await api.user({ id: 1 }).get(); // data is typed as { id: number; name: string } | null // error is typed based on error responses ``` **Why good:** zero code generation, full autocomplete on paths and methods, error/data destructuring with type narrowing See [examples/eden-treaty.md](examples/eden-treaty.md) for response handling, file uploads, and WebSocket via Treaty. </patterns> --- <red_flags> ## RED FLAGS **High Priority:** - Separate `app.get()` / `app.post()` calls instead of chaining -- breaks Eden Treaty type inference entirely - Using deprecated `error()` function instead of `status()` -- deprecated since Elysia 1.3 - Not exporting `type App = typeof app` -- Eden Treaty client has no type information - Using `as('plugin')` -- removed in 1.3+, use `as('scoped')` instead **Medium Priority:** - Importing patterns from other HTTP frameworks -- Elysia has its own routing API and conventions - Using `t.Object()` without named constants for limits/lengths -- magic numbers in validation schemas - Not providing `name` on plugin instances -- causes duplicate registration in complex app trees - `.derive()` or lifecycle hooks without `{ as: 'scoped' }` or `{ as: 'global' }` when parent routes need them -- hooks are local-scoped by default **Gotchas & Edge Cases:** - `params` are strings by default -- use `t.Number()` in the schema to coerce path params to numbers - `.handle()` in tests requires a fully qualified URL (`http://localhost/path`), NOT a path fragment (`/path`) - `.guard()` group standalone mode is default in 1.4+ -- guard schemas merge with route schemas instead of overwriting - TypeBox `t.File()` auto-detects `multipart/form-data` content type -- no need to set headers manually - `t.Files()` (plural) for multiple file uploads, `t.File()` for single - Eden Treaty dynamic path params use function syntax: `api.user({ id: 1 }).get()` not `api.user[1].get()` - When Eden Treaty response has status >= 300, `data` is always `null` and `error` has the value - Lifecycle hooks only apply to routes registered AFTER the hook -- order of `.on*()` and route definitions matters - `onError` receives a `code` string, not a status number -- switch on `code` for type narrowing - Cookies parse as JSON automatically if the value looks like JSON (1.3+ behavior) - `await app.modules` is required in tests when using lazy-loaded plugins (`import('./plugin')`) </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** **(You MUST use method chaining on the Elysia instance -- separate `.get()` calls break type inference for Eden Treaty)** **(You MUST use `status()` for error responses -- `error()` is deprecated since 1.3, prefer `status()`)** **(You MUST export the app type (`export type App = typeof app`) for Eden Treaty client generation)** **Failure to follow these rules will break end-to-end type safety and Eden Treaty client generation.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.