diff --git a/.agents/skills/elysiajs/SKILL.md b/.agents/skills/elysiajs/SKILL.md new file mode 100644 index 0000000..3199f11 --- /dev/null +++ b/.agents/skills/elysiajs/SKILL.md @@ -0,0 +1,515 @@ +--- +name: elysiajs +description: Create backend with ElysiaJS, a type-safe, high-performance framework. +--- + +# ElysiaJS Development Skill + +Always consult [elysiajs.com/llms.txt](https://elysiajs.com/llms.txt) for code examples and latest API. + +## Overview + +ElysiaJS is a TypeScript framework for building Bun-first (but not limited to Bun) type-safe, high-performance backend servers. This skill provides comprehensive guidance for developing with Elysia, including routing, validation, authentication, plugins, integrations, and deployment. + +## When to Use This Skill + +Trigger this skill when the user asks to: + +- Create or modify ElysiaJS routes, handlers, or servers +- Setup validation with TypeBox or other schema libraries (Zod, Valibot) +- Implement authentication (JWT, session-based, macros, guards) +- Add plugins (CORS, OpenAPI, Static files, JWT) +- Integrate with external services (Drizzle ORM, Better Auth, Next.js, Eden Treaty) +- Setup WebSocket endpoints for real-time features +- Create unit tests for Elysia instances +- Deploy Elysia servers to production + +## Quick Start + +Quick scaffold: + +```bash +bun create elysia app +``` + +### Basic Server + +```typescript +import { Elysia, status, t } from "elysia"; + +const app = new Elysia() + .get("/", () => "Hello World") + .post("/user", ({ body }) => body, { + body: t.Object({ + name: t.String(), + age: t.Number(), + }), + }) + .get( + "/id/:id", + ({ params: { id } }) => { + if (id > 1_000_000) return status(404, "Not Found"); + + return id; + }, + { + params: t.Object({ + id: t.Number({ + minimum: 1, + }), + }), + response: { + 200: t.Number(), + 404: t.Literal("Not Found"), + }, + }, + ) + .listen(3000); +``` + +## Basic Usage + +### HTTP Methods + +```typescript +import { Elysia } from "elysia"; + +new Elysia() + .get("/", "GET") + .post("/", "POST") + .put("/", "PUT") + .patch("/", "PATCH") + .delete("/", "DELETE") + .options("/", "OPTIONS") + .head("/", "HEAD"); +``` + +### Path Parameters + +```typescript +.get('/user/:id', ({ params: { id } }) => id) +.get('/post/:id/:slug', ({ params }) => params) +``` + +### Query Parameters + +```typescript +.get('/search', ({ query }) => query.q) +// GET /search?q=elysia → "elysia" +``` + +### Request Body + +```typescript +.post('/user', ({ body }) => body) +``` + +### Headers + +```typescript +.get('/', ({ headers }) => headers.authorization) +``` + +## TypeBox Validation + +### Basic Types + +```typescript +import { Elysia, t } from 'elysia' + +.post('/user', ({ body }) => body, { + body: t.Object({ + name: t.String(), + age: t.Number(), + email: t.String({ format: 'email' }), + website: t.Optional(t.String({ format: 'uri' })) + }) +}) +``` + +### Nested Objects + +```typescript +body: t.Object({ + user: t.Object({ + name: t.String(), + address: t.Object({ + street: t.String(), + city: t.String(), + }), + }), +}); +``` + +### Arrays + +```typescript +body: t.Object({ + tags: t.Array(t.String()), + users: t.Array( + t.Object({ + id: t.String(), + name: t.String(), + }), + ), +}); +``` + +### File Upload + +```typescript +.post('/upload', ({ body }) => body.file, { + body: t.Object({ + file: t.File({ + type: 'image', // image/* mime types + maxSize: '5m' // 5 megabytes + }), + files: t.Files({ // Multiple files + type: ['image/png', 'image/jpeg'] + }) + }) +}) +``` + +### Response Validation + +```typescript +.get('/user/:id', ({ params: { id } }) => ({ + id, + name: 'John', + email: 'john@example.com' +}), { + params: t.Object({ + id: t.Number() + }), + response: { + 200: t.Object({ + id: t.Number(), + name: t.String(), + email: t.String() + }), + 404: t.String() + } +}) +``` + +## Standard Schema (Zod, Valibot, ArkType) + +### Zod + +```typescript +import { z } from 'zod' + +.post('/user', ({ body }) => body, { + body: z.object({ + name: z.string(), + age: z.number().min(0), + email: z.string().email() + }) +}) +``` + +## Error Handling + +```typescript +.get('/user/:id', ({ params: { id }, status }) => { + const user = findUser(id) + + if (!user) { + return status(404, 'User not found') + } + + return user +}) +``` + +## Guards (Apply to Multiple Routes) + +```typescript +.guard({ + params: t.Object({ + id: t.Number() + }) +}, app => app + .get('/user/:id', ({ params: { id } }) => id) + .delete('/user/:id', ({ params: { id } }) => id) +) +``` + +## Macro + +```typescript +.macro({ + hi: (word: string) => ({ + beforeHandle() { console.log(word) } + }) +}) +.get('/', () => 'hi', { hi: 'Elysia' }) +``` + +### Project Structure (Recommended) + +Elysia takes an unopinionated approach but based on user request. But without any specific preference, we recommend a feature-based and domain driven folder structure where each feature has its own folder containing controllers, services, and models. + +``` +src/ +├── index.ts # Main server entry +├── modules/ +│ ├── auth/ +│ │ ├── index.ts # Auth routes (Elysia instance) +│ │ ├── service.ts # Business logic +│ │ └── model.ts # TypeBox schemas/DTOs +│ └── user/ +│ ├── index.ts +│ ├── service.ts +│ └── model.ts +└── plugins/ + └── custom.ts + +public/ # Static files (if using static plugin) +test/ # Unit tests +``` + +Each file has its own responsibility as follows: + +- **Controller (index.ts)**: Handle HTTP routing, request validation, and cookie. +- **Service (service.ts)**: Handle business logic, decoupled from Elysia controller if possible. +- **Model (model.ts)**: Define the data structure and validation for the request and response. + +## Best Practice + +Elysia is unopinionated on design pattern, but if not provided, we can relies on MVC pattern pair with feature based folder structure. + +- Controller: + - Prefers Elysia as a controller for HTTP dependant controller + - For non HTTP dependent, prefers service instead unless explicitly asked + - Use `onError` to handle local custom errors + - Register Model to Elysia instance via `Elysia.models({ ...models })` and prefix model by namespace `Elysia.prefix('model', 'Namespace.') + - Prefers Reference Model by name provided by Elysia instead of using an actual `Model.name` +- Service: + - Prefers class (or abstract class if possible) + - Prefers interface/type derive from `Model` + - Return `status` (`import { status } from 'elysia'`) for error + - Prefers `return Error` instead of `throw Error` +- Models: + - Always export validation model and type of validation model + - Custom Error should be in contains in Model + +## Elysia Key Concept + +Elysia has a every important concepts/rules to understand before use. + +## Encapsulation - Isolates by Default + +Lifecycles (hooks, middleware) **don't leak** between instances unless scoped. + +**Scope levels:** + +- `local` (default) - current instance + descendants +- `scoped` - parent + current + descendants +- `global` - all instances + +```ts +.onBeforeHandle(() => {}) // only local instance +.onBeforeHandle({ as: 'global' }, () => {}) // exports to all +``` + +## Method Chaining - Required for Types + +**Must chain**. Each method returns new type reference. + +❌ Don't: + +```ts +const app = new Elysia(); +app.state("build", 1); // loses type +app.get("/", ({ store }) => store.build); // build doesn't exists +``` + +✅ Do: + +```ts +new Elysia().state("build", 1).get("/", ({ store }) => store.build); +``` + +## Explicit Dependencies + +Each instance independent. **Declare what you use.** + +```ts +const auth = new Elysia().decorate("Auth", Auth).model(Auth.models); + +new Elysia().get("/", ({ Auth }) => Auth.getProfile()); // Auth doesn't exists + +new Elysia() + .use(auth) // must declare + .get("/", ({ Auth }) => Auth.getProfile()); +``` + +**Global scope when:** + +- No types added (cors, helmet) +- Global lifecycle (logging, tracing) + +**Explicit when:** + +- Adds types (state, models) +- Business logic (auth, db) + +## Deduplication + +Plugins re-execute unless named: + +```ts +new Elysia(); // rerun on `.use` +new Elysia({ name: "ip" }); // runs once across all instances +``` + +## Order Matters + +Events apply to routes **registered after** them. + +```ts +.onBeforeHandle(() => console.log('1')) +.get('/', () => 'hi') // has hook +.onBeforeHandle(() => console.log('2')) // doesn't affect '/' +``` + +## Type Inference + +**Inline functions only** for accurate types. + +For controllers, destructure in inline wrapper: + +```ts +.post('/', ({ body }) => Controller.greet(body), { + body: t.Object({ name: t.String() }) +}) +``` + +Get type from schema: + +```ts +type MyType = typeof MyType.static; +``` + +## Reference Model + +Model can be reference by name, especially great for documenting an API + +```ts +new Elysia() + .model({ + book: t.Object({ + name: t.String(), + }), + }) + .post("/", ({ body }) => body.name, { + body: "book", + }); +``` + +Model can be renamed by using `.prefix` / `.suffix` + +```ts +new Elysia() + .model({ + book: t.Object({ + name: t.String(), + }), + }) + .prefix("model", "Namespace") + .post("/", ({ body }) => body.name, { + body: "Namespace.Book", + }); +``` + +Once `prefix`, model name will be capitalized by default. + +## Technical Terms + +The following are technical terms that is use for Elysia: + +- `OpenAPI Type Gen` - function name `fromTypes` from `@elysiajs/openapi` for generating OpenAPI from types, see `plugins/openapi.md` +- `Eden`, `Eden Treaty` - e2e type safe RPC client for share type from backend to frontend + +## Resources + +Use the following references as needed. + +It's recommended to checkout `route.md` for as it contains the most important foundation building blocks with examples. + +`plugin.md` and `validation.md` is important as well but can be check as needed. + +### references/ + +Detailed documentation split by topic: + +- `bun-fullstack-dev-server.md` - Bun Fullstack Dev Server with HMR. React without bundler. +- `cookie.md` - Detailed documentation on cookie +- `deployment.md` - Production deployment guide / Docker +- `eden.md` - e2e type safe RPC client for share type from backend to frontend +- `guard.md` - Setting validation/lifecycle all at once +- `macro.md` - Compose multiple schema/lifecycle as a reusable Elysia via key-value (recommended for complex setup, eg. authentication, authorization, Role-based Access Check) +- `plugin.md` - Decouple part of Elysia into a standalone component +- `route.md` - Elysia foundation building block: Routing, Handler and Context +- `testing.md` - Unit tests with examples +- `validation.md` - Setup input/output validation and list of all custom validation rules +- `websocket.md` - Real-time features + +### plugins/ + +Detailed documentation, usage and configuration reference for official Elysia plugin: + +- `bearer.md` - Add bearer capability to Elysia (`@elysiajs/bearer`) +- `cors.md` - Out of box configuration for CORS (`@elysiajs/cors`) +- `cron.md` - Run cron job with access to Elysia context (`@elysiajs/cron`) +- `graphql-apollo.md` - Integration GraphQL Apollo (`@elysiajs/graphql-apollo`) +- `graphql-yoga.md` - Integration with GraphQL Yoga (`@elysiajs/graphql-yoga`) +- `html.md` - HTML and JSX plugin setup and usage (`@elysiajs/html`) +- `jwt.md` - JWT / JWK plugin (`@elysiajs/jwt`) +- `openapi.md` - OpenAPI documentation and OpenAPI Type Gen / OpenAPI from types (`@elysiajs/openapi`) +- `opentelemetry.md` - OpenTelemetry, instrumentation, and record span utilities (`@elysiajs/opentelemetry`) +- `server-timing.md` - Server Timing metric for debug (`@elysiajs/server-timing`) +- `static.md` - Serve static files/folders for Elysia Server (`@elysiajs/static`) + +### integrations/ + +Guide to integrate Elysia with external library/runtime: + +- `ai-sdk.md` - Using Vercel AI SDK with Elysia +- `astro.md` - Elysia in Astro API route +- `better-auth.md` - Integrate Elysia with better-auth +- `cloudflare-worker.md` - Elysia on Cloudflare Worker adapter +- `deno.md` - Elysia on Deno +- `drizzle.md` - Integrate Elysia with Drizzle ORM +- `expo.md` - Elysia in Expo API route +- `nextjs.md` - Elysia in Nextjs API route +- `nodejs.md` - Run Elysia on Node.js +- `nuxt.md` - Elysia on API route +- `prisma.md` - Integrate Elysia with Prisma +- `react-email.d` - Create and Send Email with React and Elysia +- `sveltekit.md` - Run Elysia on Svelte Kit API route +- `tanstack-start.md` - Run Elysia on Tanstack Start / React Query +- `vercel.md` - Deploy Elysia to Vercel + +### examples/ (optional) + +- `basic.ts` - Basic Elysia example +- `body-parser.ts` - Custom body parser example via `.onParse` +- `complex.ts` - Comprehensive usage of Elysia server +- `cookie.ts` - Setting cookie +- `error.ts` - Error handling +- `file.ts` - Returning local file from server +- `guard.ts` - Setting mulitple validation schema and lifecycle +- `map-response.ts` - Custom response mapper +- `redirect.ts` - Redirect response +- `rename.ts` - Rename context's property +- `schema.ts` - Setup validation +- `state.ts` - Setup global state +- `upload-file.ts` - File upload with validation +- `websocket.ts` - Web Socket for realtime communication + +### patterns/ (optional) + +- `patterns/mvc.md` - Detail guideline for using Elysia with MVC patterns diff --git a/.agents/skills/elysiajs/examples/basic.ts b/.agents/skills/elysiajs/examples/basic.ts new file mode 100644 index 0000000..ca2952d --- /dev/null +++ b/.agents/skills/elysiajs/examples/basic.ts @@ -0,0 +1,7 @@ +import { Elysia, t } from "elysia"; + +new Elysia().get("/", "Hello Elysia").post("/", ({ body: { name } }) => name, { + body: t.Object({ + name: t.String(), + }), +}); diff --git a/.agents/skills/elysiajs/examples/body-parser.ts b/.agents/skills/elysiajs/examples/body-parser.ts new file mode 100644 index 0000000..ff9c1df --- /dev/null +++ b/.agents/skills/elysiajs/examples/body-parser.ts @@ -0,0 +1,33 @@ +import { Elysia, t } from "elysia"; + +const app = new Elysia() + // Add custom body parser + .onParse(async ({ request, contentType }) => { + switch (contentType) { + case "application/Elysia": + return request.text(); + } + }) + .post("/", ({ body: { username } }) => `Hi ${username}`, { + body: t.Object({ + id: t.Number(), + username: t.String(), + }), + }) + // Increase id by 1 from body before main handler + .post("/transform", ({ body }) => body, { + transform: ({ body }) => { + body.id = body.id + 1; + }, + body: t.Object({ + id: t.Number(), + username: t.String(), + }), + detail: { + summary: "A", + }, + }) + .post("/mirror", ({ body }) => body) + .listen(3000); + +console.log("🦊 Elysia is running at :8080"); diff --git a/.agents/skills/elysiajs/examples/complex.ts b/.agents/skills/elysiajs/examples/complex.ts new file mode 100644 index 0000000..49aaa30 --- /dev/null +++ b/.agents/skills/elysiajs/examples/complex.ts @@ -0,0 +1,111 @@ +import { Elysia, file, t } from "elysia"; + +const loggerPlugin = new Elysia() + .get("/hi", () => "Hi") + .decorate("log", () => "A") + .decorate("date", () => new Date()) + .state("fromPlugin", "From Logger") + .use((app) => app.state("abc", "abc")); + +const app = new Elysia() + .onRequest(({ set }) => { + set.headers = { + "Access-Control-Allow-Origin": "*", + }; + }) + .onError(({ code }) => { + if (code === "NOT_FOUND") return "Not Found :("; + }) + .use(loggerPlugin) + .state("build", Date.now()) + .get("/", "Elysia") + .get("/tako", file("./example/takodachi.png")) + .get("/json", () => ({ + hi: "world", + })) + .get("/root/plugin/log", ({ log, store: { build } }) => { + log(); + + return build; + }) + .get("/wildcard/*", () => "Hi Wildcard") + .get("/query", () => "Elysia", { + beforeHandle: ({ query }) => { + console.log("Name:", query?.name); + + if (query?.name === "aom") return "Hi saltyaom"; + }, + query: t.Object({ + name: t.String(), + }), + }) + .post("/json", async ({ body }) => body, { + body: t.Object({ + name: t.String(), + additional: t.String(), + }), + }) + .post("/transform-body", async ({ body }) => body, { + beforeHandle: (ctx) => { + ctx.body = { + ...ctx.body, + additional: "Elysia", + }; + }, + body: t.Object({ + name: t.String(), + additional: t.String(), + }), + }) + .get("/id/:id", ({ params: { id } }) => id, { + transform({ params }) { + params.id = +params.id; + }, + params: t.Object({ + id: t.Number(), + }), + }) + .post("/new/:id", async ({ body, params }) => body, { + params: t.Object({ + id: t.Number(), + }), + body: t.Object({ + username: t.String(), + }), + }) + .get("/trailing-slash", () => "A") + .group("/group", (app) => + app + .onBeforeHandle(({ query }) => { + if (query?.name === "aom") return "Hi saltyaom"; + }) + .get("/", () => "From Group") + .get("/hi", () => "HI GROUP") + .get("/elysia", () => "Welcome to Elysian Realm") + .get("/fbk", () => "FuBuKing"), + ) + .get("/response-header", ({ set }) => { + set.status = 404; + set.headers["a"] = "b"; + + return "A"; + }) + .get("/this/is/my/deep/nested/root", () => "Hi") + .get("/build", ({ store: { build } }) => build) + .get("/ref", ({ date }) => date()) + .get("/response", () => new Response("Hi")) + .get("/error", () => new Error("Something went wrong")) + .get("/401", ({ set }) => { + set.status = 401; + + return "Status should be 401"; + }) + .get("/timeout", async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + + return "A"; + }) + .all("/all", () => "hi") + .listen(8080, ({ hostname, port }) => { + console.log(`🦊 Elysia is running at http://${hostname}:${port}`); + }); diff --git a/.agents/skills/elysiajs/examples/cookie.ts b/.agents/skills/elysiajs/examples/cookie.ts new file mode 100644 index 0000000..75c7661 --- /dev/null +++ b/.agents/skills/elysiajs/examples/cookie.ts @@ -0,0 +1,45 @@ +import { Elysia, t } from "elysia"; + +const app = new Elysia({ + cookie: { + secrets: "Fischl von Luftschloss Narfidort", + sign: ["name"], + }, +}) + .get( + "/council", + ({ cookie: { council } }) => + (council.value = [ + { + name: "Rin", + affilation: "Administration", + }, + ]), + { + cookie: t.Cookie({ + council: t.Array( + t.Object({ + name: t.String(), + affilation: t.String(), + }), + ), + }), + }, + ) + .get("/create", ({ cookie: { name } }) => (name.value = "Himari")) + .get( + "/update", + ({ cookie: { name } }) => { + name.value = "seminar: Rio"; + name.value = "seminar: Himari"; + name.maxAge = 86400; + + return name.value; + }, + { + cookie: t.Cookie({ + name: t.Optional(t.String()), + }), + }, + ) + .listen(3000); diff --git a/.agents/skills/elysiajs/examples/error.ts b/.agents/skills/elysiajs/examples/error.ts new file mode 100644 index 0000000..a8de663 --- /dev/null +++ b/.agents/skills/elysiajs/examples/error.ts @@ -0,0 +1,38 @@ +import { Elysia, t } from "elysia"; + +class CustomError extends Error { + constructor(public name: string) { + super(name); + } +} + +new Elysia() + .error({ + CUSTOM_ERROR: CustomError, + }) + // global handler + .onError(({ code, error, status }) => { + switch (code) { + case "CUSTOM_ERROR": + return status(401, { message: error.message }); + + case "NOT_FOUND": + return "Not found :("; + } + }) + .post("/", ({ body }) => body, { + body: t.Object({ + username: t.String(), + password: t.String(), + nested: t.Optional( + t.Object({ + hi: t.String(), + }), + ), + }), + // local handler + error({ error }) { + console.log(error); + }, + }) + .listen(3000); diff --git a/.agents/skills/elysiajs/examples/file.ts b/.agents/skills/elysiajs/examples/file.ts new file mode 100644 index 0000000..6dde9c9 --- /dev/null +++ b/.agents/skills/elysiajs/examples/file.ts @@ -0,0 +1,8 @@ +import { Elysia, file } from "elysia"; + +/** + * Example of handle single static file + * + * @see https://github.com/elysiajs/elysia-static + */ +new Elysia().get("/tako", file("./example/takodachi.png")).listen(3000); diff --git a/.agents/skills/elysiajs/examples/guard.ts b/.agents/skills/elysiajs/examples/guard.ts new file mode 100644 index 0000000..d0d2982 --- /dev/null +++ b/.agents/skills/elysiajs/examples/guard.ts @@ -0,0 +1,34 @@ +import { Elysia, t } from "elysia"; + +new Elysia() + .state("name", "salt") + .get("/", ({ store: { name } }) => `Hi ${name}`, { + query: t.Object({ + name: t.String(), + }), + }) + // If query 'name' is not preset, skip the whole handler + .guard( + { + query: t.Object({ + name: t.String(), + }), + }, + (app) => + app + // Query type is inherited from guard + .get("/profile", ({ query }) => `Hi`) + // Store is inherited + .post("/name", ({ store: { name }, body, query }) => name, { + body: t.Object({ + id: t.Number({ + minimum: 5, + }), + username: t.String(), + profile: t.Object({ + name: t.String(), + }), + }), + }), + ) + .listen(3000); diff --git a/.agents/skills/elysiajs/examples/map-response.ts b/.agents/skills/elysiajs/examples/map-response.ts new file mode 100644 index 0000000..cb3d841 --- /dev/null +++ b/.agents/skills/elysiajs/examples/map-response.ts @@ -0,0 +1,14 @@ +import { Elysia } from "elysia"; + +const prettyJson = new Elysia() + .mapResponse(({ response }) => { + if (response instanceof Object) return new Response(JSON.stringify(response, null, 4)); + }) + .as("scoped"); + +new Elysia() + .use(prettyJson) + .get("/", () => ({ + hello: "world", + })) + .listen(3000); diff --git a/.agents/skills/elysiajs/examples/redirect.ts b/.agents/skills/elysiajs/examples/redirect.ts new file mode 100644 index 0000000..037d858 --- /dev/null +++ b/.agents/skills/elysiajs/examples/redirect.ts @@ -0,0 +1,6 @@ +import { Elysia } from "elysia"; + +new Elysia() + .get("/", () => "Hi") + .get("/redirect", ({ redirect }) => redirect("/")) + .listen(3000); diff --git a/.agents/skills/elysiajs/examples/rename.ts b/.agents/skills/elysiajs/examples/rename.ts new file mode 100644 index 0000000..cbcdba8 --- /dev/null +++ b/.agents/skills/elysiajs/examples/rename.ts @@ -0,0 +1,27 @@ +import { Elysia, t } from "elysia"; + +// ? Elysia#83 | Proposal: Standardized way of renaming third party plugin-scoped stuff +// this would be a plugin provided by a third party +const myPlugin = new Elysia().decorate("myProperty", 42).model("salt", t.String()); + +new Elysia() + .use( + myPlugin + // map decorator, rename "myProperty" to "renamedProperty" + .decorate(({ myProperty, ...decorators }) => ({ + renamedProperty: myProperty, + ...decorators, + })) + // map model, rename "salt" to "pepper" + .model(({ salt, ...models }) => ({ + ...models, + pepper: t.String(), + })) + // Add prefix + .prefix("decorator", "unstable"), + ) + .get("/mapped", ({ unstableRenamedProperty }) => unstableRenamedProperty) + .post("/pepper", ({ body }) => body, { + body: "pepper", + // response: t.String() + }); diff --git a/.agents/skills/elysiajs/examples/schema.ts b/.agents/skills/elysiajs/examples/schema.ts new file mode 100644 index 0000000..2d116e6 --- /dev/null +++ b/.agents/skills/elysiajs/examples/schema.ts @@ -0,0 +1,61 @@ +import { Elysia, t } from "elysia"; + +const app = new Elysia() + .model({ + name: t.Object({ + name: t.String(), + }), + b: t.Object({ + response: t.Number(), + }), + authorization: t.Object({ + authorization: t.String(), + }), + }) + // Strictly validate response + .get("/", () => "hi") + // Strictly validate body and response + .post("/", ({ body, query }) => body.id, { + body: t.Object({ + id: t.Number(), + username: t.String(), + profile: t.Object({ + name: t.String(), + }), + }), + }) + // Strictly validate query, params, and body + .get("/query/:id", ({ query: { name }, params }) => name, { + query: t.Object({ + name: t.String(), + }), + params: t.Object({ + id: t.String(), + }), + response: { + 200: t.String(), + 300: t.Object({ + error: t.String(), + }), + }, + }) + .guard( + { + headers: "authorization", + }, + (app) => + app + .derive(({ headers }) => ({ + userId: headers.authorization, + })) + .get("/", ({ userId }) => "A") + .post("/id/:id", ({ query, body, params, userId }) => body, { + params: t.Object({ + id: t.Number(), + }), + transform({ params }) { + params.id = +params.id; + }, + }), + ) + .listen(3000); diff --git a/.agents/skills/elysiajs/examples/state.ts b/.agents/skills/elysiajs/examples/state.ts new file mode 100644 index 0000000..1ffcafb --- /dev/null +++ b/.agents/skills/elysiajs/examples/state.ts @@ -0,0 +1,6 @@ +import { Elysia } from "elysia"; + +new Elysia() + .state("counter", 0) + .get("/", ({ store }) => store.counter++) + .listen(3000); diff --git a/.agents/skills/elysiajs/examples/upload-file.ts b/.agents/skills/elysiajs/examples/upload-file.ts new file mode 100644 index 0000000..693691d --- /dev/null +++ b/.agents/skills/elysiajs/examples/upload-file.ts @@ -0,0 +1,16 @@ +import { Elysia, t } from "elysia"; + +const app = new Elysia() + .post("/single", ({ body: { file } }) => file, { + body: t.Object({ + file: t.File({ + maxSize: "1m", + }), + }), + }) + .post("/multiple", ({ body: { files } }) => files.reduce((a, b) => a + b.size, 0), { + body: t.Object({ + files: t.Files(), + }), + }) + .listen(3000); diff --git a/.agents/skills/elysiajs/examples/websocket.ts b/.agents/skills/elysiajs/examples/websocket.ts new file mode 100644 index 0000000..9d74988 --- /dev/null +++ b/.agents/skills/elysiajs/examples/websocket.ts @@ -0,0 +1,25 @@ +import { Elysia } from "elysia"; + +const app = new Elysia() + .state("start", "here") + .ws("/ws", { + open(ws) { + ws.subscribe("asdf"); + console.log("Open Connection:", ws.id); + }, + close(ws) { + console.log("Closed Connection:", ws.id); + }, + message(ws, message) { + ws.publish("asdf", message); + ws.send(message); + }, + }) + .get("/publish/:publish", ({ params: { publish: text } }) => { + app.server!.publish("asdf", text); + + return text; + }) + .listen(3000, (server) => { + console.log(`http://${server.hostname}:${server.port}`); + }); diff --git a/.agents/skills/elysiajs/integrations/ai-sdk.md b/.agents/skills/elysiajs/integrations/ai-sdk.md new file mode 100644 index 0000000..3413c3f --- /dev/null +++ b/.agents/skills/elysiajs/integrations/ai-sdk.md @@ -0,0 +1,103 @@ +# AI SDK Integration + +## What It Is + +Seamless integration with Vercel AI SDK via response streaming. + +## Response Streaming + +Return `ReadableStream` or `Response` directly: + +```typescript +import { openai } from "@ai-sdk/openai"; +import { streamText } from "ai"; + +new Elysia().get("/", () => { + const stream = streamText({ + model: openai("gpt-5"), + system: "You are Yae Miko from Genshin Impact", + prompt: "Hi! How are you doing?", + }); + + return stream.textStream; // ReadableStream + // or + return stream.toUIMessageStream(); // UI Message Stream +}); +``` + +Elysia auto-handles stream. + +## Server-Sent Events + +Wrap `ReadableStream` with `sse`: + +```typescript +import { sse } from 'elysia' + +.get('/', () => { + const stream = streamText({ /* ... */ }) + + return sse(stream.textStream) + // or + return sse(stream.toUIMessageStream()) +}) +``` + +Each chunk → SSE. + +## As Response + +Return stream directly (no Eden type safety): + +```typescript +.get('/', () => { + const stream = streamText({ /* ... */ }) + + return stream.toTextStreamResponse() + // or + return stream.toUIMessageStreamResponse() // Uses SSE +}) +``` + +## Manual Streaming + +Generator function for control: + +```typescript +import { sse } from 'elysia' + +.get('/', async function* () { + const stream = streamText({ /* ... */ }) + + for await (const data of stream.textStream) + yield sse({ data, event: 'message' }) + + yield sse({ event: 'done' }) +}) +``` + +## Fetch for Unsupported Models + +Direct fetch with streaming proxy: + +```typescript +.get('/', () => { + return fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.OPENAI_API_KEY}` + }, + body: JSON.stringify({ + model: 'gpt-5', + stream: true, + messages: [ + { role: 'system', content: 'You are Yae Miko' }, + { role: 'user', content: 'Hi! How are you doing?' } + ] + }) + }) +}) +``` + +Elysia auto-proxies fetch response with streaming. diff --git a/.agents/skills/elysiajs/integrations/astro.md b/.agents/skills/elysiajs/integrations/astro.md new file mode 100644 index 0000000..331594b --- /dev/null +++ b/.agents/skills/elysiajs/integrations/astro.md @@ -0,0 +1,67 @@ +# Astro Integration - SKILLS.md + +## What It Is + +Run Elysia on Astro via Astro Endpoint. + +## Setup + +1. Set output to server: + +```javascript +// astro.config.mjs +export default defineConfig({ + output: "server", +}); +``` + +2. Create `pages/[...slugs].ts` +3. Define Elysia server + export handlers: + +```typescript +// pages/[...slugs].ts +import { Elysia, t } from "elysia"; + +const app = new Elysia() + .get("/api", () => "hi") + .post("/api", ({ body }) => body, { + body: t.Object({ name: t.String() }), + }); + +const handle = ({ request }: { request: Request }) => app.handle(request); + +export const GET = handle; +export const POST = handle; +``` + +WinterCG compliance - works normally. + +Recommended: Run Astro on Bun (Elysia designed for Bun). + +## Prefix for Non-Root + +If placed in `pages/api/[...slugs].ts`, set prefix: + +```typescript +// pages/api/[...slugs].ts +const app = new Elysia({ prefix: "/api" }).get("/", () => "hi"); + +const handle = ({ request }: { request: Request }) => app.handle(request); + +export const GET = handle; +export const POST = handle; +``` + +Ensures routing works in any location. + +## Benefits + +Co-location of frontend + backend. End-to-end type safety with Eden. + +## pnpm + +Manual install: + +```bash +pnpm add @sinclair/typebox openapi-types +``` diff --git a/.agents/skills/elysiajs/integrations/better-auth.md b/.agents/skills/elysiajs/integrations/better-auth.md new file mode 100644 index 0000000..d49b44b --- /dev/null +++ b/.agents/skills/elysiajs/integrations/better-auth.md @@ -0,0 +1,128 @@ +# Better Auth Integration + +Elysia + Better Auth integration guide + +## What It Is + +Framework-agnostic TypeScript auth/authz. Comprehensive features + plugin ecosystem. + +## Setup + +```typescript +import { betterAuth } from "better-auth"; +import { Pool } from "pg"; + +export const auth = betterAuth({ + database: new Pool(), +}); +``` + +## Handler Mounting + +```typescript +import { auth } from "./auth"; + +new Elysia() + .mount(auth.handler) // http://localhost:3000/api/auth + .listen(3000); +``` + +### Custom Endpoint + +```typescript +// Mount with prefix +.mount('/auth', auth.handler) // http://localhost:3000/auth/api/auth + +// Customize basePath +export const auth = betterAuth({ + basePath: '/api' // http://localhost:3000/auth/api +}) +``` + +Cannot set `basePath` to empty or `/`. + +## OpenAPI Integration + +Extract docs from Better Auth: + +```typescript +import { openAPI } from "better-auth/plugins"; + +let _schema: ReturnType; +const getSchema = async () => (_schema ??= auth.api.generateOpenAPISchema()); + +export const OpenAPI = { + getPaths: (prefix = "/auth/api") => + getSchema().then(({ paths }) => { + const reference: typeof paths = Object.create(null); + + for (const path of Object.keys(paths)) { + const key = prefix + path; + reference[key] = paths[path]; + + for (const method of Object.keys(paths[path])) { + const operation = (reference[key] as any)[method]; + operation.tags = ["Better Auth"]; + } + } + + return reference; + }) as Promise, + components: getSchema().then(({ components }) => components) as Promise, +} as const; +``` + +Apply to Elysia: + +```typescript +new Elysia().use( + openapi({ + documentation: { + components: await OpenAPI.components, + paths: await OpenAPI.getPaths(), + }, + }), +); +``` + +## CORS + +```typescript +import { cors } from "@elysiajs/cors"; + +new Elysia() + .use( + cors({ + origin: "http://localhost:3001", + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + credentials: true, + allowedHeaders: ["Content-Type", "Authorization"], + }), + ) + .mount(auth.handler); +``` + +## Macro for Auth + +Use macro + resolve for session/user: + +```typescript +const betterAuth = new Elysia({ name: "better-auth" }).mount(auth.handler).macro({ + auth: { + async resolve({ status, request: { headers } }) { + const session = await auth.api.getSession({ headers }); + + if (!session) return status(401); + + return { + user: session.user, + session: session.session, + }; + }, + }, +}); + +new Elysia().use(betterAuth).get("/user", ({ user }) => user, { auth: true }); +``` + +Access `user` and `session` in all routes. diff --git a/.agents/skills/elysiajs/integrations/cloudflare-worker.md b/.agents/skills/elysiajs/integrations/cloudflare-worker.md new file mode 100644 index 0000000..5d732a2 --- /dev/null +++ b/.agents/skills/elysiajs/integrations/cloudflare-worker.md @@ -0,0 +1,111 @@ +# Cloudflare Worker Integration + +## What It Is + +**Experimental** Cloudflare Worker adapter for Elysia. + +## Setup + +1. Install Wrangler: + +```bash +wrangler init elysia-on-cloudflare +``` + +2. Apply adapter + compile: + +```typescript +import { Elysia } from "elysia"; +import { CloudflareAdapter } from "elysia/adapter/cloudflare-worker"; + +export default new Elysia({ + adapter: CloudflareAdapter, +}) + .get("/", () => "Hello Cloudflare Worker!") + .compile(); // Required +``` + +3. Set compatibility date (min `2025-06-01`): + +```json +// wrangler.json +{ + "name": "elysia-on-cloudflare", + "main": "src/index.ts", + "compatibility_date": "2025-06-01" +} +``` + +4. Dev server: + +```bash +wrangler dev +# http://localhost:8787 +``` + +No `nodejs_compat` flag needed. + +## Limitations + +1. `Elysia.file` + Static Plugin don't work (no `fs` module) +2. OpenAPI Type Gen doesn't work (no `fs` module) +3. Cannot define Response before server start +4. Cannot inline values: + +```typescript +// ❌ Throws error +.get('/', 'Hello Elysia') + +// ✅ Works +.get('/', () => 'Hello Elysia') +``` + +## Static Files + +Use Cloudflare's built-in static serving: + +```json +// wrangler.json +{ + "assets": { "directory": "public" } +} +``` + +Structure: + +``` +├─ public +│ ├─ kyuukurarin.mp4 +│ └─ static/mika.webp +``` + +Access: + +- `http://localhost:8787/kyuukurarin.mp4` +- `http://localhost:8787/static/mika.webp` + +## Binding + +Import env from `cloudflare:workers`: + +```typescript +import { env } from "cloudflare:workers"; + +export default new Elysia({ adapter: CloudflareAdapter }) + .get("/", () => `Hello ${await env.KV.get("my-key")}`) + .compile(); +``` + +## AoT Compilation + +As of Elysia 1.4.7, AoT works with Cloudflare Worker. Drop `aot: false` flag. + +Cloudflare now supports Function compilation during startup. + +## pnpm + +Manual install: + +```bash +pnpm add @sinclair/typebox openapi-types +``` diff --git a/.agents/skills/elysiajs/integrations/deno.md b/.agents/skills/elysiajs/integrations/deno.md new file mode 100644 index 0000000..70db646 --- /dev/null +++ b/.agents/skills/elysiajs/integrations/deno.md @@ -0,0 +1,40 @@ +# Deno Integration + +Run Elysia on Deno + +## What It Is + +Run Elysia on Deno via Web Standard Request/Response. + +## Setup + +Wrap `Elysia.fetch` in `Deno.serve`: + +```typescript +import { Elysia } from "elysia"; + +const app = new Elysia().get("/", () => "Hello Elysia").listen(3000); + +Deno.serve(app.fetch); +``` + +Run: + +```bash +deno serve --watch src/index.ts +``` + +## Port Config + +```typescript +Deno.serve(app.fetch); // Default +Deno.serve({ port: 8787 }, app.fetch); // Custom port +``` + +## pnpm + +[Inference] pnpm doesn't auto-install peer deps. Manual install required: + +```bash +pnpm add @sinclair/typebox openapi-types +``` diff --git a/.agents/skills/elysiajs/integrations/drizzle.md b/.agents/skills/elysiajs/integrations/drizzle.md new file mode 100644 index 0000000..1049016 --- /dev/null +++ b/.agents/skills/elysiajs/integrations/drizzle.md @@ -0,0 +1,260 @@ +# Drizzle Integration + +Elysia + Drizzle integration guide + +## What It Is + +Headless TypeScript ORM. Convert Drizzle schema → Elysia validation models via `drizzle-typebox`. + +## Flow + +``` +Drizzle → drizzle-typebox → Elysia validation → OpenAPI + Eden Treaty +``` + +## Installation + +```bash +bun add drizzle-orm drizzle-typebox +``` + +### Pin TypeBox Version + +Prevent Symbol conflicts: + +```bash +grep "@sinclair/typebox" node_modules/elysia/package.json +``` + +Add to `package.json`: + +```json +{ + "overrides": { + "@sinclair/typebox": "0.32.4" + } +} +``` + +## Drizzle Schema + +```typescript +// src/database/schema.ts +import { createId } from "@paralleldrive/cuid2"; +import { pgTable, timestamp, varchar } from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: varchar("id") + .$defaultFn(() => createId()) + .primaryKey(), + username: varchar("username").notNull().unique(), + password: varchar("password").notNull(), + email: varchar("email").notNull().unique(), + salt: varchar("salt", { length: 64 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const table = { user } as const; +export type Table = typeof table; +``` + +## drizzle-typebox + +```typescript +import { createInsertSchema } from "drizzle-typebox"; +import { t } from "elysia"; + +import { table } from "./database/schema"; + +const _createUser = createInsertSchema(table.user, { + email: t.String({ format: "email" }), // Replace with Elysia type +}); + +new Elysia().post("/sign-up", ({ body }) => {}, { + body: t.Omit(_createUser, ["id", "salt", "createdAt"]), +}); +``` + +## Type Instantiation Error + +**Error**: "Type instantiation is possibly infinite" + +**Cause**: Circular reference when nesting drizzle-typebox into Elysia schema. + +**Fix**: Explicitly define type between them: + +```typescript +// ✅ Works +const _createUser = createInsertSchema(table.user, { + email: t.String({ format: "email" }), +}); +const createUser = t.Omit(_createUser, ["id", "salt", "createdAt"]); + +// ❌ Infinite loop +const createUser = t.Omit(createInsertSchema(table.user, { email: t.String({ format: "email" }) }), [ + "id", + "salt", + "createdAt", +]); +``` + +Always declare variable for drizzle-typebox then reference it. + +## Utility Functions + +Copy as-is for simplified usage: + +```typescript +import type { TObject } from "@sinclair/typebox"; +import type { Table } from "drizzle-orm"; +import { Kind } from "@sinclair/typebox"; +import { BuildSchema, createInsertSchema, createSelectSchema } from "drizzle-typebox"; + +import { table } from "./schema"; + +// src/database/utils.ts +/** + * @lastModified 2025-02-04 + * @see https://elysiajs.com/recipe/drizzle.html#utility + */ + +type Spread = + T extends TObject + ? { + [K in keyof Fields]: Fields[K]; + } + : T extends Table + ? Mode extends "select" + ? BuildSchema<"select", T["_"]["columns"], undefined>["properties"] + : Mode extends "insert" + ? BuildSchema<"insert", T["_"]["columns"], undefined>["properties"] + : {} + : {}; + +/** + * Spread a Drizzle schema into a plain object + */ +export const spread = ( + schema: T, + mode?: Mode, +): Spread => { + const newSchema: Record = {}; + let table; + + switch (mode) { + case "insert": + case "select": + if (Kind in schema) { + table = schema; + break; + } + + table = mode === "insert" ? createInsertSchema(schema) : createSelectSchema(schema); + + break; + + default: + if (!(Kind in schema)) throw new Error("Expect a schema"); + table = schema; + } + + for (const key of Object.keys(table.properties)) newSchema[key] = table.properties[key]; + + return newSchema as any; +}; + +/** + * Spread a Drizzle Table into a plain object + * + * If `mode` is 'insert', the schema will be refined for insert + * If `mode` is 'select', the schema will be refined for select + * If `mode` is undefined, the schema will be spread as is, models will need to be refined manually + */ +export const spreads = , Mode extends "select" | "insert" | undefined>( + models: T, + mode?: Mode, +): { + [K in keyof T]: Spread; +} => { + const newSchema: Record = {}; + const keys = Object.keys(models); + + for (const key of keys) newSchema[key] = spread(models[key], mode); + + return newSchema as any; +}; +``` + +Usage: + +```typescript +// ✅ Using spread +const user = spread(table.user, "insert"); +const createUser = t.Object({ + id: user.id, + username: user.username, + password: user.password, +}); + +// ⚠️ Using t.Pick +const _createUser = createInsertSchema(table.user); +const createUser = t.Pick(_createUser, ["id", "username", "password"]); +``` + +## Table Singleton Pattern + +```typescript +// src/database/model.ts +import { table } from "./schema"; +import { spreads } from "./utils"; + +export const db = { + insert: spreads({ user: table.user }, "insert"), + select: spreads({ user: table.user }, "select"), +} as const; +``` + +Usage: + +```typescript +// src/index.ts +import { db } from "./database/model"; + +const { user } = db.insert; + +new Elysia().post("/sign-up", ({ body }) => {}, { + body: t.Object({ + id: user.username, + username: user.username, + password: user.password, + }), +}); +``` + +## Refinement + +```typescript +// src/database/model.ts +import { createInsertSchema, createSelectSchema } from "drizzle-typebox"; + +export const db = { + insert: spreads( + { + user: createInsertSchema(table.user, { + email: t.String({ format: "email" }), + }), + }, + "insert", + ), + select: spreads( + { + user: createSelectSchema(table.user, { + email: t.String({ format: "email" }), + }), + }, + "select", + ), +} as const; +``` + +`spread` skips refined schemas. diff --git a/.agents/skills/elysiajs/integrations/expo.md b/.agents/skills/elysiajs/integrations/expo.md new file mode 100644 index 0000000..450cbd5 --- /dev/null +++ b/.agents/skills/elysiajs/integrations/expo.md @@ -0,0 +1,105 @@ +# Expo Integration + +Run Elysia on Expo (React Native) + +## What It Is + +Create API routes in Expo app (SDK 50+, App Router v3). + +## Setup + +1. Create `app/[...slugs]+api.ts` +2. Define Elysia server +3. Export `Elysia.fetch` as HTTP methods + +```typescript +// app/[...slugs]+api.ts +import { Elysia, t } from "elysia"; + +const app = new Elysia().get("/", "hello Expo").post("/", ({ body }) => body, { + body: t.Object({ name: t.String() }), +}); + +export const GET = app.fetch; +export const POST = app.fetch; +``` + +## Prefix for Non-Root + +If placed in `app/api/[...slugs]+api.ts`, set prefix: + +```typescript +const app = new Elysia({ prefix: "/api" }).get("/", "Hello Expo"); + +export const GET = app.fetch; +export const POST = app.fetch; +``` + +Ensures routing works in any location. + +## Eden (End-to-End Type Safety) + +1. Export type: + +```typescript +// app/[...slugs]+api.ts +const app = new Elysia().get("/", "Hello Nextjs").post("/user", ({ body }) => body, { + body: treaty.schema("User", { name: "string" }), +}); + +export type app = typeof app; + +export const GET = app.fetch; +export const POST = app.fetch; +``` + +2. Create client: + +```typescript +// lib/eden.ts +import { treaty } from "@elysiajs/eden"; + +import type { app } from "../app/[...slugs]+api"; + +export const api = treaty("localhost:3000/api"); +``` + +3. Use in components: + +```tsx +// app/page.tsx +import { api } from "../lib/eden"; + +export default async function Page() { + const message = await api.get(); + return

Hello, {message}

; +} +``` + +## Deployment + +- Deploy as normal Elysia app OR +- Use experimental Expo server runtime + +With Expo runtime: + +```bash +expo export +# Creates dist/server/_expo/functions/[...slugs]+api.js +``` + +Edge function, not normal server (no port allocation). + +### Adapters + +- Express +- Netlify +- Vercel + +## pnpm + +Manual install: + +```bash +pnpm add @sinclair/typebox openapi-types +``` diff --git a/.agents/skills/elysiajs/integrations/nextjs.md b/.agents/skills/elysiajs/integrations/nextjs.md new file mode 100644 index 0000000..7147778 --- /dev/null +++ b/.agents/skills/elysiajs/integrations/nextjs.md @@ -0,0 +1,108 @@ +# Next.js Integration + +## What It Is + +Run Elysia on Next.js App Router. + +## Setup + +1. Create `app/api/[[...slugs]]/route.ts` +2. Define Elysia + export handlers: + +```typescript +// app/api/[[...slugs]]/route.ts +import { Elysia, t } from "elysia"; + +const app = new Elysia({ prefix: "/api" }).get("/", "Hello Nextjs").post("/", ({ body }) => body, { + body: t.Object({ name: t.String() }), +}); + +export const GET = app.fetch; +export const POST = app.fetch; +``` + +WinterCG compliance - works as normal Next.js API route. + +## Prefix for Non-Root + +If placed in `app/user/[[...slugs]]/route.ts`, set prefix: + +```typescript +const app = new Elysia({ prefix: "/user" }).get("/", "Hello Nextjs"); + +export const GET = app.fetch; +export const POST = app.fetch; +``` + +## Eden (End-to-End Type Safety) + +Isomorphic fetch pattern: + +- Server: Direct calls (no network) +- Client: Network calls + +1. Export type: + +```typescript +// app/api/[[...slugs]]/route.ts +export const app = new Elysia({ prefix: "/api" }).get("/", "Hello Nextjs").post("/user", ({ body }) => body, { + body: treaty.schema("User", { name: "string" }), +}); + +export type app = typeof app; + +export const GET = app.fetch; +export const POST = app.fetch; +``` + +2. Create client: + +```typescript +// lib/eden.ts +import { treaty } from "@elysiajs/eden"; + +import type { app } from "../app/api/[[...slugs]]/route"; + +export const api = typeof process !== "undefined" ? treaty(app).api : treaty("localhost:3000").api; +``` + +Use `typeof process` not `typeof window` (window undefined at build time → hydration error). + +3. Use in components: + +```tsx +// app/page.tsx +import { api } from "../lib/eden"; + +export default async function Page() { + const message = await api.get(); + return

Hello, {message}

; +} +``` + +Works with server/client components + ISR. + +## React Query + +```tsx +import { useQuery } from "@tanstack/react-query"; + +function App() { + const { data: response } = useQuery({ + queryKey: ["get"], + queryFn: () => getTreaty().get(), + }); + + return response?.data; +} +``` + +Works with all React Query features. + +## pnpm + +Manual install: + +```bash +pnpm add @sinclair/typebox openapi-types +``` diff --git a/.agents/skills/elysiajs/integrations/nodejs.md b/.agents/skills/elysiajs/integrations/nodejs.md new file mode 100644 index 0000000..b39c92d --- /dev/null +++ b/.agents/skills/elysiajs/integrations/nodejs.md @@ -0,0 +1,74 @@ +# Node.js Integration + +Run Elysia on Node.js + +## What It Is + +Runtime adapter to run Elysia on Node.js. + +## Installation + +```bash +bun add elysia @elysiajs/node +``` + +## Setup + +Apply node adapter: + +```typescript +import { node } from "@elysiajs/node"; +import { Elysia } from "elysia"; + +const app = new Elysia({ adapter: node() }).get("/", () => "Hello Elysia").listen(3000); +``` + +## Additional Setup (Recommended) + +Install `tsx` for hot-reload: + +```bash +bun add -d tsx @types/node typescript +``` + +Scripts in `package.json`: + +```json +{ + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc src/index.ts --outDir dist", + "start": "NODE_ENV=production node dist/index.js" + } +} +``` + +- **dev**: Hot-reload dev mode +- **build**: Production build +- **start**: Production server + +Create `tsconfig.json`: + +```bash +tsc --init +``` + +Update strict mode: + +```json +{ + "compilerOptions": { + "strict": true + } +} +``` + +Provides hot-reload + JSX support similar to `bun dev`. + +## pnpm + +Manual install: + +```bash +pnpm add @sinclair/typebox openapi-types +``` diff --git a/.agents/skills/elysiajs/integrations/nuxt.md b/.agents/skills/elysiajs/integrations/nuxt.md new file mode 100644 index 0000000..662a936 --- /dev/null +++ b/.agents/skills/elysiajs/integrations/nuxt.md @@ -0,0 +1,76 @@ +# Nuxt Integration + +## What It Is + +Community plugin `nuxt-elysia` for Nuxt API routes with Eden Treaty. + +## Installation + +```bash +bun add elysia @elysiajs/eden +bun add -d nuxt-elysia +``` + +## Setup + +1. Add to Nuxt config: + +```typescript +export default defineNuxtConfig({ + modules: ["nuxt-elysia"], +}); +``` + +2. Create `api.ts` at project root: + +```typescript +// api.ts +export default () => new Elysia().get("/hello", () => ({ message: "Hello world!" })); +``` + +3. Use Eden Treaty: + +```vue + + +``` + +Auto-setup on Nuxt API route. + +## Prefix + +Default: `/_api`. Customize: + +```typescript +export default defineNuxtConfig({ + nuxtElysia: { + path: "/api", + }, +}); +``` + +Mounts on `/api` instead of `/_api`. + +See [nuxt-elysia](https://github.com/tkesgar/nuxt-elysia) for more config. + +## pnpm + +Manual install: + +```bash +pnpm add @sinclair/typebox openapi-types +``` diff --git a/.agents/skills/elysiajs/integrations/prisma.md b/.agents/skills/elysiajs/integrations/prisma.md new file mode 100644 index 0000000..02c6a30 --- /dev/null +++ b/.agents/skills/elysiajs/integrations/prisma.md @@ -0,0 +1,103 @@ +# Prisma Integration + +Elysia + Prisma integration guide + +## What It Is + +Type-safe ORM. Generate Elysia validation models from Prisma schema via `prismabox`. + +## Flow + +``` +Prisma → prismabox → Elysia validation → OpenAPI + Eden Treaty +``` + +## Installation + +```bash +bun add @prisma/client prismabox && \ +bun add -d prisma +``` + +## Prisma Schema + +Add `prismabox` generator: + +```prisma +// prisma/schema.prisma +generator client { + provider = "prisma-client" + output = "../generated/prisma" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +generator prismabox { + provider = "prismabox" + typeboxImportDependencyName = "elysia" + typeboxImportVariableName = "t" + inputModel = true + output = "../generated/prismabox" +} + +model User { + id String @id @default(cuid()) + email String @unique + name String? + posts Post[] +} + +model Post { + id String @id @default(cuid()) + title String + content String? + published Boolean @default(false) + author User @relation(fields: [authorId], references: [id]) + authorId String +} +``` + +Generates: + +- `User` → `generated/prismabox/User.ts` +- `Post` → `generated/prismabox/Post.ts` + +## Using Generated Models + +```typescript +// src/index.ts +import { Elysia, t } from "elysia"; + +import { PrismaClient } from "../generated/prisma"; +import { UserPlain, UserPlainInputCreate } from "../generated/prismabox/User"; + +const prisma = new PrismaClient(); + +new Elysia() + .put("/", async ({ body }) => prisma.user.create({ data: body }), { + body: UserPlainInputCreate, + response: UserPlain, + }) + .get( + "/id/:id", + async ({ params: { id }, status }) => { + const user = await prisma.user.findUnique({ where: { id } }); + + if (!user) return status(404, "User not found"); + + return user; + }, + { + response: { + 200: UserPlain, + 404: t.String(), + }, + }, + ) + .listen(3000); +``` + +Reuses DB schema in Elysia validation models. diff --git a/.agents/skills/elysiajs/integrations/react-email.md b/.agents/skills/elysiajs/integrations/react-email.md new file mode 100644 index 0000000..72e3bfb --- /dev/null +++ b/.agents/skills/elysiajs/integrations/react-email.md @@ -0,0 +1,136 @@ +# React Email Integration + +## What It Is + +Use React components to create emails. Direct JSX import via Bun. + +## Installation + +```bash +bun add -d react-email +bun add @react-email/components react react-dom +``` + +Script in `package.json`: + +```json +{ + "scripts": { + "email": "email dev --dir src/emails" + } +} +``` + +Email templates → `src/emails` directory. + +### TypeScript + +Add to `tsconfig.json`: + +```json +{ + "compilerOptions": { + "jsx": "react" + } +} +``` + +## Email Template + +```tsx +// src/emails/otp.tsx +import * as React from "react"; +import { Section, Tailwind, Text } from "@react-email/components"; + +export default function OTPEmail({ otp }: { otp: number }) { + return ( + +
+
+ Verify your Email Address + Use the following code to verify your email address + {otp} + This code is valid for 10 minutes + Thank you for joining us +
+
+
+ ); +} + +OTPEmail.PreviewProps = { otp: 123456 }; +``` + +`@react-email/components` → email-client compatible (Gmail, Outlook). Tailwind support. + +`PreviewProps` → playground only. + +## Preview + +```bash +bun email +``` + +Opens browser with preview. + +## Send Email + +Render with `react-dom/server`, submit via provider: + +### Nodemailer + +```typescript +import { renderToStaticMarkup } from 'react-dom/server' +import OTPEmail from './emails/otp' +import nodemailer from 'nodemailer' + +const transporter = nodemailer.createTransport({ + host: 'smtp.gehenna.sh', + port: 465, + auth: { user: 'makoto', pass: '12345678' } +}) + +.get('/otp', async ({ body }) => { + const otp = ~~(Math.random() * 900_000) + 100_000 + const html = renderToStaticMarkup() + + await transporter.sendMail({ + from: '[email protected]', + to: body, + subject: 'Verify your email address', + html + }) + + return { success: true } +}, { + body: t.String({ format: 'email' }) +}) +``` + +### Resend + +```typescript +import OTPEmail from './emails/otp' +import Resend from 'resend' + +const resend = new Resend('re_123456789') + +.get('/otp', ({ body }) => { + const otp = ~~(Math.random() * 900_000) + 100_000 + + await resend.emails.send({ + from: '[email protected]', + to: body, + subject: 'Verify your email address', + html: // Direct JSX + }) + + return { success: true } +}) +``` + +Direct JSX import thanks to Bun. + +Other providers: AWS SES, SendGrid. + +See [React Email Integrations](https://react.email/docs/integrations/overview). diff --git a/.agents/skills/elysiajs/integrations/sveltekit.md b/.agents/skills/elysiajs/integrations/sveltekit.md new file mode 100644 index 0000000..5f631b5 --- /dev/null +++ b/.agents/skills/elysiajs/integrations/sveltekit.md @@ -0,0 +1,57 @@ +# SvelteKit Integration + +## What It Is + +Run Elysia on SvelteKit server routes. + +## Setup + +1. Create `src/routes/[...slugs]/+server.ts` +2. Define Elysia server +3. Export fallback handler: + +```typescript +// src/routes/[...slugs]/+server.ts +import { Elysia, t } from "elysia"; + +const app = new Elysia().get("/", "hello SvelteKit").post("/", ({ body }) => body, { + body: t.Object({ name: t.String() }), +}); + +interface WithRequest { + request: Request; +} + +export const fallback = ({ request }: WithRequest) => app.handle(request); +``` + +Treat as normal SvelteKit server route. + +## Prefix for Non-Root + +If placed in `src/routes/api/[...slugs]/+server.ts`, set prefix: + +```typescript +// src/routes/api/[...slugs]/+server.ts +import { Elysia, t } from "elysia"; + +const app = new Elysia({ prefix: "/api" }) + .get("/", () => "hi") + .post("/", ({ body }) => body, { + body: t.Object({ name: t.String() }), + }); + +type RequestHandler = (v: { request: Request }) => Response | Promise; + +export const fallback: RequestHandler = ({ request }) => app.handle(request); +``` + +Ensures routing works in any location. + +## pnpm + +Manual install: + +```bash +pnpm add @sinclair/typebox openapi-types +``` diff --git a/.agents/skills/elysiajs/integrations/tanstack-start.md b/.agents/skills/elysiajs/integrations/tanstack-start.md new file mode 100644 index 0000000..5d1de01 --- /dev/null +++ b/.agents/skills/elysiajs/integrations/tanstack-start.md @@ -0,0 +1,102 @@ +# Tanstack Start Integration + +## What It Is + +Elysia runs inside Tanstack Start server routes. + +## Setup + +1. Create `src/routes/api.$.ts` +2. Define Elysia server +3. Export handlers in `server.handlers`: + +```typescript +// src/routes/api.$.ts +import { createFileRoute } from "@tanstack/react-router"; +import { createIsomorphicFn } from "@tanstack/react-start"; +import { Elysia } from "elysia"; + +const app = new Elysia({ + prefix: "/api", +}).get("/", "Hello Elysia!"); + +const handle = ({ request }: { request: Request }) => app.fetch(request); + +export const Route = createFileRoute("/api/$")({ + server: { + handlers: { + GET: handle, + POST: handle, + }, + }, +}); +``` + +Runs on `/api`. Add methods to `server.handlers` as needed. + +## Eden (End-to-End Type Safety) + +Isomorphic pattern with `createIsomorphicFn`: + +```typescript +// src/routes/api.$.ts +export const getTreaty = createIsomorphicFn() + .server(() => treaty(app).api) + .client(() => treaty("localhost:3000").api); +``` + +- Server: Direct call (no HTTP overhead) +- Client: HTTP call + +## Loader Data + +Fetch before render: + +```tsx +// src/routes/index.tsx +import { createFileRoute } from "@tanstack/react-router"; + +import { getTreaty } from "./api.$"; + +export const Route = createFileRoute("/a")({ + component: App, + loader: () => + getTreaty() + .get() + .then((res) => res.data), +}); + +function App() { + const data = Route.useLoaderData(); + return data; +} +``` + +Executed server-side during SSR. No HTTP overhead. Type-safe. + +## React Query + +```tsx +import { useQuery } from "@tanstack/react-query"; + +import { getTreaty } from "./api.$"; + +function App() { + const { data: response } = useQuery({ + queryKey: ["get"], + queryFn: () => getTreaty().get(), + }); + + return response?.data; +} +``` + +Works with all React Query features. + +## pnpm + +Manual install: + +```bash +pnpm add @sinclair/typebox openapi-types +``` diff --git a/.agents/skills/elysiajs/integrations/vercel.md b/.agents/skills/elysiajs/integrations/vercel.md new file mode 100644 index 0000000..e0b65de --- /dev/null +++ b/.agents/skills/elysiajs/integrations/vercel.md @@ -0,0 +1,68 @@ +# Vercel Integration + +Deploy Elysia on Vercel + +## What It Is + +Zero-config deployment on Vercel (Bun or Node runtime). + +## Setup + +1. Create/import Elysia server in `src/index.ts` +2. Export as default: + +```typescript +import { Elysia, t } from "elysia"; + +export default new Elysia() + .get("/", () => "Hello Vercel Function") + .post("/", ({ body }) => body, { + body: t.Object({ name: t.String() }), + }); +``` + +3. Develop locally: + +```bash +vc dev +``` + +4. Deploy: + +```bash +vc deploy +``` + +## Node.js Runtime + +Set in `package.json`: + +```json +{ + "name": "elysia-app", + "type": "module" +} +``` + +## Bun Runtime + +Set in `vercel.json`: + +```json +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "bunVersion": "1.x" +} +``` + +## pnpm + +Manual install: + +```bash +pnpm add @sinclair/typebox openapi-types +``` + +## Troubleshooting + +Vercel has zero config for Elysia. For additional config, see [Vercel docs](https://vercel.com/docs/frameworks/backend/elysia). diff --git a/.agents/skills/elysiajs/patterns/mvc.md b/.agents/skills/elysiajs/patterns/mvc.md new file mode 100644 index 0000000..c1de709 --- /dev/null +++ b/.agents/skills/elysiajs/patterns/mvc.md @@ -0,0 +1,401 @@ +# MVC pattern + +This file contains a guideline for using Elysia with MVC or Model View Controller patterns + +- Controller: + - Prefers Elysia as a controller for HTTP dependant + - For non HTTP dependent, prefers service instead unless explicitly asked + - Use `onError` to handle local custom errors + - Register Model to Elysia instance via `Elysia.models({ ...models })` and prefix model by namespace `Elysia.prefix('model', 'Namespace.') + - Prefers Reference Model by name provided by Elysia instead of using an actual `Model.name` +- Service: + - Prefers class (or abstract class if possible) + - Prefers interface/type derive from `Model` + - Return `status` (`import { status } from 'elysia'`) for error + - Prefers `return Error` instead of `throw Error` +- Models: + - Always export validation model and type of validation model + - Custom Error should be in contains in Model + +## Controller + +Due to type soundness of Elysia, it's not recommended to use a traditional controller class that is tightly coupled with Elysia's `Context` because: + +1. **Elysia type is complex** and heavily depends on plugin and multiple level of chaining. +2. **Hard to type**, Elysia type could change at anytime, especially with decorators, and store +3. **Loss of type integrity**, and inconsistency between types and runtime code. + +We recommended one of the following approach to implement a controller in Elysia. + +1. Use Elysia instance as a controller itself +2. Create a controller that is not tied with HTTP request or Elysia. + +--- + +### 1. Elysia instance as a controller + +> 1 Elysia instance = 1 controller + +Treat an Elysia instance as a controller, and define your routes directly on the Elysia instance. + +```typescript +// Do +import { Elysia } from "elysia"; + +import { Service } from "./service"; + +new Elysia().get("/", ({ stuff }) => { + Service.doStuff(stuff); +}); +``` + +This approach allows Elysia to infer the `Context` type automatically, ensuring type integrity and consistency between types and runtime code. + +```typescript +import type { Context } from "elysia"; +import { Elysia, t } from "elysia"; + +// Don't + +abstract class Controller { + static root(context: Context) { + return Service.doStuff(context.stuff); + } +} + +new Elysia().get("/", Controller.root); +``` + +This approach makes it hard to type `Context` properly, and may lead to loss of type integrity. + +### 2. Controller without HTTP request + +If you want to create a controller class, we recommend creating a class that is not tied to HTTP request or Elysia at all. + +This approach allows you to decouple the controller from Elysia, making it easier to test, reuse, and even swap a framework while still follows the MVC pattern. + +```typescript +import { Elysia } from "elysia"; + +abstract class Controller { + static doStuff(stuff: string) { + return Service.doStuff(stuff); + } +} + +new Elysia().get("/", ({ stuff }) => Controller.doStuff(stuff)); +``` + +Tying the controller to Elysia Context may lead to: + +1. Loss of type integrity +2. Make it harder to test and reuse +3. Lead to vendor lock-in + +We recommended to keep the controller decoupled from Elysia as much as possible. + +### Don't: Pass entire `Context` to a controller + +**Context is a highly dynamic type** that can be inferred from Elysia instance. + +Do not pass an entire `Context` to a controller, instead use object destructuring to extract what you need and pass it to the controller. + +```typescript +import type { Context } from "elysia"; + +abstract class Controller { + constructor() {} + + // Don't do this + static root(context: Context) { + return Service.doStuff(context.stuff); + } +} +``` + +This approach makes it hard to type `Context` properly, and may lead to loss of type integrity. + +### Testing + +If you're using Elysia as a controller, you can test your controller using `handle` to directly call a function (and it's lifecycle) + +```typescript +import { describe, expect, it } from "bun:test"; +import { Elysia } from "elysia"; + +import { Service } from "./service"; + +const app = new Elysia().get("/", ({ stuff }) => { + Service.doStuff(stuff); + + return "ok"; +}); + +describe("Controller", () => { + it("should work", async () => { + const response = await app.handle(new Request("http://localhost/")).then((x) => x.text()); + + expect(response).toBe("ok"); + }); +}); +``` + +You may find more information about testing in [Unit Test](/patterns/unit-test.html). + +## Service + +Service is a set of utility/helper functions decoupled as a business logic to use in a module/controller, in our case, an Elysia instance. + +Any technical logic that can be decoupled from controller may live inside a **Service**. + +There are 2 types of service in Elysia: + +1. Non-request dependent service +2. Request dependent service + +### 1. Abstract away Non-request dependent service + +We recommend abstracting a service class/function away from Elysia. + +If the service or function isn't tied to an HTTP request or doesn't access a `Context`, it's recommended to implement it as a static class or function. + +```typescript +import { Elysia, t } from "elysia"; + +abstract class Service { + static fibo(number: number): number { + if (number < 2) return number; + + return Service.fibo(number - 1) + Service.fibo(number - 2); + } +} + +new Elysia().get( + "/fibo", + ({ body }) => { + return Service.fibo(body); + }, + { + body: t.Numeric(), + }, +); +``` + +If your service doesn't need to store a property, you may use `abstract class` and `static` instead to avoid allocating class instance. + +### 2. Request dependent service as Elysia instance + +**If the service is a request-dependent service** or needs to process HTTP requests, we recommend abstracting it as an Elysia instance to ensure type integrity and inference: + +```typescript +import { Elysia } from "elysia"; + +// Do +const AuthService = new Elysia({ name: "Auth.Service" }).macro({ + isSignIn: { + resolve({ cookie, status }) { + if (!cookie.session.value) return status(401); + + return { + session: cookie.session.value, + }; + }, + }, +}); + +const UserController = new Elysia().use(AuthService).get("/profile", ({ Auth: { user } }) => user, { + isSignIn: true, +}); +``` + +### Do: Decorate only request dependent property + +It's recommended to `decorate` only request-dependent properties, such as `requestIP`, `requestTime`, or `session`. + +Overusing decorators may tie your code to Elysia, making it harder to test and reuse. + +```typescript +import { Elysia } from "elysia"; + +new Elysia() + .decorate("requestIP", ({ request }) => request.headers.get("x-forwarded-for") || request.ip) + .decorate("requestTime", () => Date.now()) + .decorate("session", ({ cookie }) => cookie.session.value) + .get("/", ({ requestIP, requestTime, session }) => { + return { requestIP, requestTime, session }; + }); +``` + +### Don't: Pass entire `Context` to a service + +**Context is a highly dynamic type** that can be inferred from Elysia instance. + +Do not pass an entire `Context` to a service, instead use object destructuring to extract what you need and pass it to the service. + +```typescript +import type { Context } from "elysia"; + +class AuthService { + constructor() {} + + // Don't do this + isSignIn({ status, cookie: { session } }: Context) { + if (session.value) return status(401); + } +} +``` + +As Elysia type is complex, and heavily depends on plugin and multiple level of chaining, it can be challenging to manually type as it's highly dynamic. + +## Model + +Model or [DTO (Data Transfer Object)](https://en.wikipedia.org/wiki/Data_transfer_object) is handle by [Elysia.t (Validation)](/essential/validation.html#elysia-type). + +Elysia has a validation system built-in which can infers type from your code and validate it at runtime. + +### Do: Use Elysia's validation system + +Elysia strength is prioritizing a single source of truth for both type and runtime validation. + +Instead of declaring an interface, reuse validation's model instead: + +```typescript twoslash +// Do +import { Elysia, t } from "elysia"; + +const customBody = t.Object({ + username: t.String(), + password: t.String(), +}); + +// Optional if you want to get the type of the model +// Usually if we didn't use the type, as it's already inferred by Elysia +type CustomBody = typeof customBody.static; + +export { customBody }; +``` + +We can get type of model by using `typeof` with `.static` property from the model. + +Then you can use the `CustomBody` type to infer the type of the request body. + +```typescript twoslash +// Do +new Elysia().post( + "/login", + ({ body }) => { + return body; + }, + { + body: customBody, + }, +); +``` + +### Don't: Declare a class instance as a model + +Do not declare a class instance as a model: + +```typescript +// Don't +class CustomBody { + username: string; + password: string; + + constructor(username: string, password: string) { + this.username = username; + this.password = password; + } +} + +// Don't +interface ICustomBody { + username: string; + password: string; +} +``` + +### Don't: Declare type separate from the model + +Do not declare a type separate from the model, instead use `typeof` with `.static` property to get the type of the model. + +```typescript +// Don't +import { Elysia, t } from "elysia"; + +const customBody = t.Object({ + username: t.String(), + password: t.String(), +}); + +type CustomBody = { + username: string; + password: string; +}; + +// Do +const customBody = t.Object({ + username: t.String(), + password: t.String(), +}); + +type CustomBody = typeof customBody.static; +``` + +### Group + +You can group multiple models into a single object to make it more organized. + +```typescript +import { Elysia, t } from "elysia"; + +export const AuthModel = { + sign: t.Object({ + username: t.String(), + password: t.String(), + }), +}; + +const models = AuthModel.models; +``` + +### Model Injection + +Though this is optional, if you are strictly following MVC pattern, you may want to inject like a service into a controller. We recommended using Elysia reference model + +Using Elysia's model reference + +```typescript twoslash +import { Elysia, t } from "elysia"; + +const customBody = t.Object({ + username: t.String(), + password: t.String(), +}); + +const AuthModel = new Elysia().model({ + sign: customBody, +}); + +const models = AuthModel.models; + +const UserController = new Elysia({ prefix: "/auth" }) + .use(AuthModel) + .prefix("model", "auth.") + .post( + "/sign-in", + async ({ body, cookie: { session } }) => { + return true; + }, + { + body: "auth.Sign", + }, + ); +``` + +This approach provide several benefits: + +1. Allow us to name a model and provide auto-completion. +2. Modify schema for later usage, or perform a [remap](/essential/handler.html#remap). +3. Show up as "models" in OpenAPI compliance client, eg. OpenAPI. +4. Improve TypeScript inference speed as model type will be cached during registration. diff --git a/.agents/skills/elysiajs/plugins/bearer.md b/.agents/skills/elysiajs/plugins/bearer.md new file mode 100644 index 0000000..6c35140 --- /dev/null +++ b/.agents/skills/elysiajs/plugins/bearer.md @@ -0,0 +1,31 @@ +# Bearer + +Plugin for Elysia for retrieving the Bearer token. + +## Installation + +```bash +bun add @elysiajs/bearer +``` + +## Basic Usage + +```typescript twoslash +import { bearer } from "@elysiajs/bearer"; +import { Elysia } from "elysia"; + +const app = new Elysia() + .use(bearer()) + .get("/sign", ({ bearer }) => bearer, { + beforeHandle({ bearer, set, status }) { + if (!bearer) { + set.headers["WWW-Authenticate"] = `Bearer realm='sign', error="invalid_request"`; + + return status(400, "Unauthorized"); + } + }, + }) + .listen(3000); +``` + +This plugin is for retrieving a Bearer token specified in RFC6750 diff --git a/.agents/skills/elysiajs/plugins/cors.md b/.agents/skills/elysiajs/plugins/cors.md new file mode 100644 index 0000000..e4fc655 --- /dev/null +++ b/.agents/skills/elysiajs/plugins/cors.md @@ -0,0 +1,147 @@ +# CORS + +Plugin for Elysia that adds support for customizing Cross-Origin Resource Sharing behavior. + +## Installation + +```bash +bun add @elysiajs/cors +``` + +## Basic Usage + +```typescript twoslash +import { cors } from "@elysiajs/cors"; +import { Elysia } from "elysia"; + +new Elysia().use(cors()).listen(3000); +``` + +This will set Elysia to accept requests from any origin. + +## Config + +Below is a config which is accepted by the plugin + +### origin + +@default `true` + +Indicates whether the response can be shared with the requesting code from the given origins. + +Value can be one of the following: + +- **string** - Name of origin which will directly assign to Access-Control-Allow-Origin header. +- **boolean** - If set to true, Access-Control-Allow-Origin will be set to `*` (any origins) +- **RegExp** - Pattern to match request's URL, allowed if matched. +- **Function** - Custom logic to allow resource sharing, allow if `true` is returned. + - Expected to have the type of: + ```typescript + cors(context: Context) => boolean | void + ``` +- **Array** - iterate through all cases above in order, allowed if any of the values are `true`. + +--- + +### methods + +@default `*` + +Allowed methods for cross-origin requests by assign `Access-Control-Allow-Methods` header. + +Value can be one of the following: + +- **undefined | null | ''** - Ignore all methods. +- **\*** - Allows all methods. +- **string** - Expects either a single method or a comma-delimited string + - (eg: `'GET, PUT, POST'`) +- **string[]** - Allow multiple HTTP methods. + - eg: `['GET', 'PUT', 'POST']` + +--- + +### allowedHeaders + +@default `*` + +Allowed headers for an incoming request by assign `Access-Control-Allow-Headers` header. + +Value can be one of the following: + +- **string** - Expects either a single header or a comma-delimited string + - eg: `'Content-Type, Authorization'`. +- **string[]** - Allow multiple HTTP headers. + - eg: `['Content-Type', 'Authorization']` + +--- + +### exposeHeaders + +@default `*` + +Response CORS with specified headers by sssign Access-Control-Expose-Headers header. + +Value can be one of the following: + +- **string** - Expects either a single header or a comma-delimited string. + - eg: `'Content-Type, X-Powered-By'`. +- **string[]** - Allow multiple HTTP headers. + - eg: `['Content-Type', 'X-Powered-By']` + +--- + +### credentials + +@default `true` + +The Access-Control-Allow-Credentials response header tells browsers whether to expose the response to the frontend JavaScript code when the request's credentials mode Request.credentials is `include`. + +Credentials are cookies, authorization headers, or TLS client certificates by assign `Access-Control-Allow-Credentials` header. + +--- + +### maxAge + +@default `5` + +Indicates how long the results of a preflight request that is the information contained in the `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` headers) can be cached. + +Assign `Access-Control-Max-Age` header. + +--- + +### preflight + +The preflight request is a request sent to check if the CORS protocol is understood and if a server is aware of using specific methods and headers. + +Response with **OPTIONS** request with 3 HTTP request headers: + +- **Access-Control-Request-Method** +- **Access-Control-Request-Headers** +- **Origin** + +This config indicates if the server should respond to preflight requests. + +--- + +## Pattern + +Below you can find the common patterns to use the plugin. + +## Allow CORS by top-level domain + +```typescript twoslash +import { cors } from "@elysiajs/cors"; +import { Elysia } from "elysia"; + +const app = new Elysia() + .use( + cors({ + origin: /.*\.saltyaom\.com$/, + }), + ) + .get("/", () => "Hi") + .listen(3000); +``` + +This will allow requests from top-level domains with `saltyaom.com` diff --git a/.agents/skills/elysiajs/plugins/cron.md b/.agents/skills/elysiajs/plugins/cron.md new file mode 100644 index 0000000..a95ffec --- /dev/null +++ b/.agents/skills/elysiajs/plugins/cron.md @@ -0,0 +1,274 @@ +# Cron Plugin + +This plugin adds support for running cronjob to Elysia server. + +## Installation + +```bash +bun add @elysiajs/cron +``` + +## Basic Usage + +```typescript twoslash +import { cron } from "@elysiajs/cron"; +import { Elysia } from "elysia"; + +new Elysia() + .use( + cron({ + name: "heartbeat", + pattern: "*/10 * * * * *", + run() { + console.log("Heartbeat"); + }, + }), + ) + .listen(3000); +``` + +The above code will log `heartbeat` every 10 seconds. + +## Config + +Below is a config which is accepted by the plugin + +### cron + +Create a cronjob for the Elysia server. + +``` +cron(config: CronConfig, callback: (Instance['store']) => void): this +``` + +`CronConfig` accepts the parameters specified below: + +--- + +### CronConfig.name + +Job name to register to `store`. + +This will register the cron instance to `store` with a specified name, which can be used to reference in later processes eg. stop the job. + +--- + +### CronConfig.pattern + +Time to run the job as specified by cron syntax. + +``` +┌────────────── second (optional) +│ ┌──────────── minute +│ │ ┌────────── hour +│ │ │ ┌──────── day of the month +│ │ │ │ ┌────── month +│ │ │ │ │ ┌──── day of week +│ │ │ │ │ │ +* * * * * * +``` + +--- + +### CronConfig.timezone + +Time zone in Europe/Stockholm format + +--- + +### CronConfig.startAt + +Schedule start time for the job + +--- + +### CronConfig.stopAt + +Schedule stop time for the job + +--- + +### CronConfig.maxRuns + +Maximum number of executions + +--- + +### CronConfig.catch + +Continue execution even if an unhandled error is thrown by a triggered function. + +### CronConfig.interval + +The minimum interval between executions, in seconds. + +--- + +## CronConfig.Pattern + +Below you can find the common patterns to use the plugin. + +--- + +## Pattern + +Below you can find the common patterns to use the plugin. + +## Stop cronjob + +You can stop cronjob manually by accessing the cronjob name registered to `store`. + +```typescript +import { cron } from "@elysiajs/cron"; +import { Elysia } from "elysia"; + +const app = new Elysia() + .use( + cron({ + name: "heartbeat", + pattern: "*/1 * * * * *", + run() { + console.log("Heartbeat"); + }, + }), + ) + .get( + "/stop", + ({ + store: { + cron: { heartbeat }, + }, + }) => { + heartbeat.stop(); + + return "Stop heartbeat"; + }, + ) + .listen(3000); +``` + +--- + +## Predefined patterns + +You can use predefined patterns from `@elysiajs/cron/schedule` + +```typescript +import { cron, Patterns } from "@elysiajs/cron"; +import { Elysia } from "elysia"; + +const app = new Elysia() + .use( + cron({ + name: "heartbeat", + pattern: Patterns.everySecond(), + run() { + console.log("Heartbeat"); + }, + }), + ) + .get( + "/stop", + ({ + store: { + cron: { heartbeat }, + }, + }) => { + heartbeat.stop(); + + return "Stop heartbeat"; + }, + ) + .listen(3000); +``` + +### Functions + +| Function | Description | +| ---------------------------------------- | ----------------------------------------------------- | +| `.everySeconds(2)` | Run the task every 2 seconds | +| `.everyMinutes(5)` | Run the task every 5 minutes | +| `.everyHours(3)` | Run the task every 3 hours | +| `.everyHoursAt(3, 15)` | Run the task every 3 hours at 15 minutes | +| `.everyDayAt('04:19')` | Run the task every day at 04:19 | +| `.everyWeekOn(Patterns.MONDAY, '19:30')` | Run the task every Monday at 19:30 | +| `.everyWeekdayAt('17:00')` | Run the task every day from Monday to Friday at 17:00 | +| `.everyWeekendAt('11:00')` | Run the task on Saturday and Sunday at 11:00 | + +### Function aliases to constants + +| Function | Constant | +| ----------------- | ---------------------------------- | +| `.everySecond()` | EVERY_SECOND | +| `.everyMinute()` | EVERY_MINUTE | +| `.hourly()` | EVERY_HOUR | +| `.daily()` | EVERY_DAY_AT_MIDNIGHT | +| `.everyWeekday()` | EVERY_WEEKDAY | +| `.everyWeekend()` | EVERY_WEEKEND | +| `.weekly()` | EVERY_WEEK | +| `.monthly()` | EVERY_1ST_DAY_OF_MONTH_AT_MIDNIGHT | +| `.everyQuarter()` | EVERY_QUARTER | +| `.yearly()` | EVERY_YEAR | + +### Constants + +| Constant | Pattern | +| ---------------------------------------- | -------------------- | +| `.EVERY_SECOND` | `* * * * * *` | +| `.EVERY_5_SECONDS` | `*/5 * * * * *` | +| `.EVERY_10_SECONDS` | `*/10 * * * * *` | +| `.EVERY_30_SECONDS` | `*/30 * * * * *` | +| `.EVERY_MINUTE` | `*/1 * * * *` | +| `.EVERY_5_MINUTES` | `0 */5 * * * *` | +| `.EVERY_10_MINUTES` | `0 */10 * * * *` | +| `.EVERY_30_MINUTES` | `0 */30 * * * *` | +| `.EVERY_HOUR` | `0 0-23/1 * * *` | +| `.EVERY_2_HOURS` | `0 0-23/2 * * *` | +| `.EVERY_3_HOURS` | `0 0-23/3 * * *` | +| `.EVERY_4_HOURS` | `0 0-23/4 * * *` | +| `.EVERY_5_HOURS` | `0 0-23/5 * * *` | +| `.EVERY_6_HOURS` | `0 0-23/6 * * *` | +| `.EVERY_7_HOURS` | `0 0-23/7 * * *` | +| `.EVERY_8_HOURS` | `0 0-23/8 * * *` | +| `.EVERY_9_HOURS` | `0 0-23/9 * * *` | +| `.EVERY_10_HOURS` | `0 0-23/10 * * *` | +| `.EVERY_11_HOURS` | `0 0-23/11 * * *` | +| `.EVERY_12_HOURS` | `0 0-23/12 * * *` | +| `.EVERY_DAY_AT_1AM` | `0 01 * * *` | +| `.EVERY_DAY_AT_2AM` | `0 02 * * *` | +| `.EVERY_DAY_AT_3AM` | `0 03 * * *` | +| `.EVERY_DAY_AT_4AM` | `0 04 * * *` | +| `.EVERY_DAY_AT_5AM` | `0 05 * * *` | +| `.EVERY_DAY_AT_6AM` | `0 06 * * *` | +| `.EVERY_DAY_AT_7AM` | `0 07 * * *` | +| `.EVERY_DAY_AT_8AM` | `0 08 * * *` | +| `.EVERY_DAY_AT_9AM` | `0 09 * * *` | +| `.EVERY_DAY_AT_10AM` | `0 10 * * *` | +| `.EVERY_DAY_AT_11AM` | `0 11 * * *` | +| `.EVERY_DAY_AT_NOON` | `0 12 * * *` | +| `.EVERY_DAY_AT_1PM` | `0 13 * * *` | +| `.EVERY_DAY_AT_2PM` | `0 14 * * *` | +| `.EVERY_DAY_AT_3PM` | `0 15 * * *` | +| `.EVERY_DAY_AT_4PM` | `0 16 * * *` | +| `.EVERY_DAY_AT_5PM` | `0 17 * * *` | +| `.EVERY_DAY_AT_6PM` | `0 18 * * *` | +| `.EVERY_DAY_AT_7PM` | `0 19 * * *` | +| `.EVERY_DAY_AT_8PM` | `0 20 * * *` | +| `.EVERY_DAY_AT_9PM` | `0 21 * * *` | +| `.EVERY_DAY_AT_10PM` | `0 22 * * *` | +| `.EVERY_DAY_AT_11PM` | `0 23 * * *` | +| `.EVERY_DAY_AT_MIDNIGHT` | `0 0 * * *` | +| `.EVERY_WEEK` | `0 0 * * 0` | +| `.EVERY_WEEKDAY` | `0 0 * * 1-5` | +| `.EVERY_WEEKEND` | `0 0 * * 6,0` | +| `.EVERY_1ST_DAY_OF_MONTH_AT_MIDNIGHT` | `0 0 1 * *` | +| `.EVERY_1ST_DAY_OF_MONTH_AT_NOON` | `0 12 1 * *` | +| `.EVERY_2ND_HOUR` | `0 */2 * * *` | +| `.EVERY_2ND_HOUR_FROM_1AM_THROUGH_11PM` | `0 1-23/2 * * *` | +| `.EVERY_2ND_MONTH` | `0 0 1 */2 *` | +| `.EVERY_QUARTER` | `0 0 1 */3 *` | +| `.EVERY_6_MONTHS` | `0 0 1 */6 *` | +| `.EVERY_YEAR` | `0 0 1 1 *` | +| `.EVERY_30_MINUTES_BETWEEN_9AM_AND_5PM` | `0 */30 9-17 * * *` | +| `.EVERY_30_MINUTES_BETWEEN_9AM_AND_6PM` | `0 */30 9-18 * * *` | +| `.EVERY_30_MINUTES_BETWEEN_10AM_AND_7PM` | `0 */30 10-19 * * *` | diff --git a/.agents/skills/elysiajs/plugins/graphql-apollo.md b/.agents/skills/elysiajs/plugins/graphql-apollo.md new file mode 100644 index 0000000..0608ce6 --- /dev/null +++ b/.agents/skills/elysiajs/plugins/graphql-apollo.md @@ -0,0 +1,91 @@ +# GraphQL Apollo + +Plugin for Elysia to use GraphQL Apollo. + +## Installation + +```bash +bun add graphql @elysiajs/apollo @apollo/server +``` + +## Basic Usage + +```typescript +import { apollo, gql } from "@elysiajs/apollo"; +import { Elysia } from "elysia"; + +const app = new Elysia() + .use( + apollo({ + typeDefs: gql` + type Book { + title: String + author: String + } + + type Query { + books: [Book] + } + `, + resolvers: { + Query: { + books: () => { + return [ + { + title: "Elysia", + author: "saltyAom", + }, + ]; + }, + }, + }, + }), + ) + .listen(3000); +``` + +Accessing `/graphql` should show Apollo GraphQL playground work with. + +## Context + +Because Elysia is based on Web Standard Request and Response which is different from Node's `HttpRequest` and `HttpResponse` that Express uses, results in `req, res` being undefined in context. + +Because of this, Elysia replaces both with `context` like route parameters. + +```typescript +const app = new Elysia() + .use( + apollo({ + typeDefs, + resolvers, + context: async ({ request }) => { + const authorization = request.headers.get("Authorization"); + + return { + authorization, + }; + }, + }), + ) + .listen(3000); +``` + +## Config + +This plugin extends Apollo's [ServerRegistration](https://www.apollographql.com/docs/apollo-server/api/apollo-server/#options) (which is `ApolloServer`'s' constructor parameter). + +Below are the extended parameters for configuring Apollo Server with Elysia. + +### path + +@default `"/graphql"` + +Path to expose Apollo Server. + +--- + +### enablePlayground + +@default `process.env.ENV !== 'production'` + +Determine whether should Apollo should provide Apollo Playground. diff --git a/.agents/skills/elysiajs/plugins/graphql-yoga.md b/.agents/skills/elysiajs/plugins/graphql-yoga.md new file mode 100644 index 0000000..d49ef62 --- /dev/null +++ b/.agents/skills/elysiajs/plugins/graphql-yoga.md @@ -0,0 +1,89 @@ +# GraphQL Yoga + +This plugin integrates GraphQL yoga with Elysia + +## Installation + +```bash +bun add @elysiajs/graphql-yoga +``` + +## Basic Usage + +```typescript +import { yoga } from "@elysiajs/graphql-yoga"; +import { Elysia } from "elysia"; + +const app = new Elysia() + .use( + yoga({ + typeDefs: /* GraphQL */ ` + type Query { + hi: String + } + `, + resolvers: { + Query: { + hi: () => "Hello from Elysia", + }, + }, + }), + ) + .listen(3000); +``` + +Accessing `/graphql` in the browser (GET request) would show you a GraphiQL instance for the GraphQL-enabled Elysia server. + +optional: you can install a custom version of optional peer dependencies as well: + +```bash +bun add graphql graphql-yoga +``` + +## Resolver + +Elysia uses Mobius to infer type from **typeDefs** field automatically, allowing you to get full type-safety and auto-complete when typing **resolver** types. + +## Context + +You can add custom context to the resolver function by adding **context** + +```ts +import { yoga } from "@elysiajs/graphql-yoga"; +import { Elysia } from "elysia"; + +const app = new Elysia() + .use( + yoga({ + typeDefs: /* GraphQL */ ` + type Query { + hi: String + } + `, + context: { + name: "Mobius", + }, + // If context is a function on this doesn't present + // for some reason it won't infer context type + useContext(_) {}, + resolvers: { + Query: { + hi: async (parent, args, context) => context.name, + }, + }, + }), + ) + .listen(3000); +``` + +## Config + +This plugin extends [GraphQL Yoga's createYoga options, please refer to the GraphQL Yoga documentation](https://the-guild.dev/graphql/yoga-server/docs) with inlining `schema` config to root. + +Below is a config which is accepted by the plugin + +### path + +@default `/graphql` + +Endpoint to expose GraphQL handler diff --git a/.agents/skills/elysiajs/plugins/html.md b/.agents/skills/elysiajs/plugins/html.md new file mode 100644 index 0000000..33a8b36 --- /dev/null +++ b/.agents/skills/elysiajs/plugins/html.md @@ -0,0 +1,194 @@ +# HTML + +Allows you to use JSX and HTML with proper headers and support. + +## Installation + +```bash +bun add @elysiajs/html +``` + +## Basic Usage + +```tsx twoslash +import React from "react"; +import { html, Html } from "@elysiajs/html"; +import { Elysia } from "elysia"; + +new Elysia() + .use(html()) + .get( + "/html", + () => ` + + + Hello World + + +

Hello World

+ + `, + ) + .get("/jsx", () => ( + + + Hello World + + +

Hello World

+ + + )) + .listen(3000); +``` + +This plugin will automatically add `Content-Type: text/html; charset=utf8` header to the response, add ``, and convert it into a Response object. + +## JSX + +Elysia can use JSX + +1. Replace your file that needs to use JSX to end with affix **"x"**: + +- .js -> .jsx +- .ts -> .tsx + +2. Register the TypeScript type by append the following to **tsconfig.json**: + +```jsonc +// tsconfig.json +{ + "compilerOptions": { + "jsx": "react", + "jsxFactory": "Html.createElement", + "jsxFragmentFactory": "Html.Fragment", + }, +} +``` + +3. Starts using JSX in your file + +```tsx twoslash +import React from "react"; +import { html, Html } from "@elysiajs/html"; +import { Elysia } from "elysia"; + +new Elysia() + .use(html()) + .get("/", () => ( + + + Hello World + + +

Hello World

+ + + )) + .listen(3000); +``` + +If the error `Cannot find name 'Html'. Did you mean 'html'?` occurs, this import must be added to the JSX template: + +```tsx +import { Html } from "@elysiajs/html"; +``` + +It is important that it is written in uppercase. + +## XSS + +Elysia HTML is based use of the Kita HTML plugin to detect possible XSS attacks in compile time. + +You can use a dedicated `safe` attribute to sanitize user value to prevent XSS vulnerability. + +```tsx +import { html, Html } from "@elysiajs/html"; +import { Elysia, t } from "elysia"; + +new Elysia() + .use(html()) + .post( + "/", + ({ body }) => ( + + + Hello World + + +

{body}

+ + + ), + { + body: t.String(), + }, + ) + .listen(3000); +``` + +However, when are building a large-scale app, it's best to have a type reminder to detect possible XSS vulnerabilities in your codebase. + +To add a type-safe reminder, please install: + +```sh +bun add @kitajs/ts-html-plugin +``` + +Then appends the following **tsconfig.json** + +```jsonc +// tsconfig.json +{ + "compilerOptions": { + "jsx": "react", + "jsxFactory": "Html.createElement", + "jsxFragmentFactory": "Html.Fragment", + "plugins": [{ "name": "@kitajs/ts-html-plugin" }], + }, +} +``` + +## Config + +Below is a config which is accepted by the plugin + +### contentType + +- Type: `string` +- Default: `'text/html; charset=utf8'` + +The content-type of the response. + +### autoDetect + +- Type: `boolean` +- Default: `true` + +Whether to automatically detect HTML content and set the content-type. + +### autoDoctype + +- Type: `boolean | 'full'` +- Default: `true` + +Whether to automatically add `` to a response starting with ``, if not found. + +Use `full` to also automatically add doctypes on responses returned without this plugin + +```ts +// without the plugin +app.get("/", () => ""); + +// With the plugin +app.get("/", ({ html }) => html("")); +``` + +### isHtml + +- Type: `(value: string) => boolean` +- Default: `isHtml` (exported function) + +The function is used to detect if a string is a html or not. Default implementation if length is greater than 7, starts with `<` and ends with `>`. + +Keep in mind there's no real way to validate HTML, so the default implementation is a best guess. diff --git a/.agents/skills/elysiajs/plugins/jwt.md b/.agents/skills/elysiajs/plugins/jwt.md new file mode 100644 index 0000000..bf9c72a --- /dev/null +++ b/.agents/skills/elysiajs/plugins/jwt.md @@ -0,0 +1,227 @@ +# JWT Plugin + +This plugin adds support for using JWT in Elysia handlers. + +## Installation + +```bash +bun add @elysiajs/jwt +``` + +## Basic Usage + +```typescript [cookie] +import { jwt } from "@elysiajs/jwt"; +import { Elysia } from "elysia"; + +const app = new Elysia() + .use( + jwt({ + name: "jwt", + secret: "Fischl von Luftschloss Narfidort", + }), + ) + .get("/sign/:name", async ({ jwt, params: { name }, cookie: { auth } }) => { + const value = await jwt.sign({ name }); + + auth.set({ + value, + httpOnly: true, + maxAge: 7 * 86400, + path: "/profile", + }); + + return `Sign in as ${value}`; + }) + .get("/profile", async ({ jwt, status, cookie: { auth } }) => { + const profile = await jwt.verify(auth.value); + + if (!profile) return status(401, "Unauthorized"); + + return `Hello ${profile.name}`; + }) + .listen(3000); +``` + +## Config + +This plugin extends config from [jose](https://github.com/panva/jose). + +Below is a config that is accepted by the plugin. + +### name + +Name to register `jwt` function as. + +For example, `jwt` function will be registered with a custom name. + +```typescript +new Elysia() + .use( + jwt({ + name: "myJWTNamespace", + secret: process.env.JWT_SECRETS!, + }), + ) + .get("/sign/:name", ({ myJWTNamespace, params }) => { + return myJWTNamespace.sign(params); + }); +``` + +Because some might need to use multiple `jwt` with different configs in a single server, explicitly registering the JWT function with a different name is needed. + +### secret + +The private key to sign JWT payload with. + +### schema + +Type strict validation for JWT payload. + +### alg + +@default `HS256` + +Signing Algorithm to sign JWT payload with. + +Possible properties for jose are: +HS256 +HS384 +HS512 +PS256 +PS384 +PS512 +RS256 +RS384 +RS512 +ES256 +ES256K +ES384 +ES512 +EdDSA + +### iss + +The issuer claim identifies the principal that issued the JWT as per [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.1) + +TLDR; is usually (the domain) name of the signer. + +### sub + +The subject claim identifies the principal that is the subject of the JWT. + +The claims in a JWT are normally statements about the subject as per [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.2) + +### aud + +The audience claim identifies the recipients that the JWT is intended for. + +Each principal intended to process the JWT MUST identify itself with a value in the audience claim as per [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.3) + +### jti + +JWT ID claim provides a unique identifier for the JWT as per [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.7) + +### nbf + +The "not before" claim identifies the time before which the JWT must not be accepted for processing as per [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.5) + +### exp + +The expiration time claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing as per [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.4) + +### iat + +The "issued at" claim identifies the time at which the JWT was issued. + +This claim can be used to determine the age of the JWT as per [RFC7519](https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6) + +### b64 + +This JWS Extension Header Parameter modifies the JWS Payload representation and the JWS Signing input computation as per [RFC7797](https://www.rfc-editor.org/rfc/rfc7797). + +### kid + +A hint indicating which key was used to secure the JWS. + +This parameter allows originators to explicitly signal a change of key to recipients as per [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.4) + +### x5t + +(X.509 certificate SHA-1 thumbprint) header parameter is a base64url-encoded SHA-1 digest of the DER encoding of the X.509 certificate [RFC5280](https://www.rfc-editor.org/rfc/rfc5280) corresponding to the key used to digitally sign the JWS as per [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.7) + +### x5c + +(X.509 certificate chain) header parameter contains the X.509 public key certificate or certificate chain [RFC5280](https://www.rfc-editor.org/rfc/rfc5280) corresponding to the key used to digitally sign the JWS as per [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.6) + +### x5u + +(X.509 URL) header parameter is a URI [RFC3986](https://www.rfc-editor.org/rfc/rfc3986) that refers to a resource for the X.509 public key certificate or certificate chain [RFC5280] corresponding to the key used to digitally sign the JWS as per [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.5) + +### jwk + +The "jku" (JWK Set URL) Header Parameter is a URI [RFC3986] that refers to a resource for a set of JSON-encoded public keys, one of which corresponds to the key used to digitally sign the JWS. + +The keys MUST be encoded as a JWK Set [JWK] as per [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.2) + +### typ + +The `typ` (type) Header Parameter is used by JWS applications to declare the media type [IANA.MediaTypes] of this complete JWS. + +This is intended for use by the application when more than one kind of object could be present in an application data structure that can contain a JWS as per [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.9) + +### ctr + +Content-Type parameter is used by JWS applications to declare the media type [IANA.MediaTypes] of the secured content (the payload). + +This is intended for use by the application when more than one kind of object could be present in the JWS Payload as per [RFC7515](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.9) + +## Handler + +Below are the value added to the handler. + +### jwt.sign + +A dynamic object of collection related to use with JWT registered by the JWT plugin. + +Type: + +```typescript +sign: (payload: JWTPayloadSpec): Promise +``` + +`JWTPayloadSpec` accepts the same value as [JWT config](#config) + +### jwt.verify + +Verify payload with the provided JWT config + +Type: + +```typescript +verify(payload: string) => Promise +``` + +`JWTPayloadSpec` accepts the same value as [JWT config](#config) + +## Pattern + +Below you can find the common patterns to use the plugin. + +## Set JWT expiration date + +By default, the config is passed to `setCookie` and inherits its value. + +```typescript +const app = new Elysia() + .use( + jwt({ + name: "jwt", + secret: "kunikuzushi", + exp: "7d", + }), + ) + .get("/sign/:name", async ({ jwt, params }) => jwt.sign(params)); +``` + +This will sign JWT with an expiration date of the next 7 days. diff --git a/.agents/skills/elysiajs/plugins/openapi.md b/.agents/skills/elysiajs/plugins/openapi.md new file mode 100644 index 0000000..2f57349 --- /dev/null +++ b/.agents/skills/elysiajs/plugins/openapi.md @@ -0,0 +1,270 @@ +# OpenAPI Plugin + +## Installation + +```bash +bun add @elysiajs/openapi +``` + +## Basic Usage + +```typescript +import { openapi } from "@elysiajs/openapi"; + +new Elysia().use(openapi()).get("/", () => "hello"); +``` + +Docs at `/openapi`, spec at `/openapi/json`. + +## Detail Object + +Extends OpenAPI Operation Object: + +```typescript +.get('/', () => 'hello', { + detail: { + title: 'Hello', + description: 'An example route', + summary: 'Short summary', + deprecated: false, + hide: true, // Hide from docs + tags: ['App'] + } +}) +``` + +### Documentation Config + +```typescript +openapi({ + documentation: { + info: { + title: "API", + version: "1.0.0", + }, + tags: [{ name: "App", description: "General" }], + components: { + securitySchemes: { + bearerAuth: { type: "http", scheme: "bearer" }, + }, + }, + }, +}); +``` + +### Standard Schema Mapping + +```typescript +mapJsonSchema: { + zod: z.toJSONSchema, // Zod 4 + valibot: toJsonSchema, + effect: JSONSchema.make +} +``` + +Zod 3: `zodToJsonSchema` from `zod-to-json-schema` + +## OpenAPI Type Gen + +Generate docs from types: + +```typescript +import { fromTypes } from "@elysiajs/openapi"; + +export const app = new Elysia().use( + openapi({ + references: fromTypes(), + }), +); +``` + +### Production + +Recommended to generate `.d.ts` file for production when using OpenAPI Type Gen + +```typescript +references: fromTypes(process.env.NODE_ENV === "production" ? "dist/index.d.ts" : "src/index.ts"); +``` + +### Options + +```typescript +fromTypes("src/index.ts", { + projectRoot: path.join("..", import.meta.dir), + tsconfigPath: "tsconfig.dts.json", +}); +``` + +### Caveat: Explicit Types + +Use `Prettify` helper to inline when type is not showing: + +```typescript +type Prettify = { [K in keyof T]: T[K] } & {}; + +function getUser(): Prettify {} +``` + +## Schema Description + +```typescript +body: t.Object({ + username: t.String(), + password: t.String({ + minLength: 8, + description: 'Password (8+ chars)' + }) +}, { + description: 'Expected username and password' +}), +detail: { + summary: 'Sign in user', + tags: ['auth'] +} +``` + +## Response Headers + +```typescript +import { withHeader } from "@elysiajs/openapi"; + +response: withHeader(t.Literal("Hi"), { "x-powered-by": t.Literal("Elysia") }); +``` + +Annotation only - doesn't enforce. Set headers manually. + +## Tags + +Define + assign: + +```typescript +.use(openapi({ + documentation: { + tags: [ + { name: 'App', description: 'General' }, + { name: 'Auth', description: 'Auth' } + ] + } +})) +.get('/', () => 'hello', { + detail: { tags: ['App'] } +}) +``` + +### Instance Tags + +```typescript +new Elysia({ tags: ["user"] }).get("/user", "user"); +``` + +## Reference Models + +Auto-generates schemas: + +```typescript +.model({ + User: t.Object({ + id: t.Number(), + username: t.String() + }) +}) +.get('/user', () => ({ id: 1, username: 'x' }), { + response: { 200: 'User' }, + detail: { tags: ['User'] } +}) +``` + +## Guard + +Apply to instance/group: + +```typescript +.guard({ + detail: { + description: 'Requires auth' + } +}) +.get('/user', 'user') +``` + +## Security + +```typescript +.use(openapi({ + documentation: { + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT' + } + } + } + } +})) + +new Elysia({ + prefix: '/address', + detail: { + security: [{ bearerAuth: [] }] + } +}) +``` + +Secures all routes under prefix. + +## Config + +Below is a config which is accepted by the `openapi({})` + +### enabled + +@default true +Enable/Disable the plugin + +### documentation + +OpenAPI documentation information +@see https://spec.openapis.org/oas/v3.0.3.html + +### exclude + +Configuration to exclude paths or methods from documentation + +### exclude.methods + +List of methods to exclude from documentation + +### exclude.paths + +List of paths to exclude from documentation + +### exclude.staticFile + +@default true + +Exclude static file routes from documentation + +### exclude.tags + +List of tags to exclude from documentation + +### mapJsonSchema + +A custom mapping function from Standard schema to OpenAPI schema + +### path + +@default '/openapi' +The endpoint to expose OpenAPI documentation frontend + +### provider + +@default 'scalar' + +OpenAPI documentation frontend between: + +- Scalar +- SwaggerUI +- null: disable frontend diff --git a/.agents/skills/elysiajs/plugins/opentelemetry.md b/.agents/skills/elysiajs/plugins/opentelemetry.md new file mode 100644 index 0000000..de30c22 --- /dev/null +++ b/.agents/skills/elysiajs/plugins/opentelemetry.md @@ -0,0 +1,191 @@ +# OpenTelemetry Plugin - SKILLS.md + +## Installation + +```bash +bun add @elysiajs/opentelemetry +``` + +## Basic Usage + +```typescript +import { opentelemetry } from "@elysiajs/opentelemetry"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; +import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node"; + +new Elysia().use( + opentelemetry({ + spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())], + }), +); +``` + +Auto-collects spans from OpenTelemetry-compatible libraries. Parent/child spans applied automatically. + +## Config + +Extends OpenTelemetry SDK params: + +- `autoDetectResources` (true) - Auto-detect from env +- `contextManager` (AsyncHooksContextManager) - Custom context +- `textMapPropagator` (CompositePropagator) - W3C Trace + Baggage +- `metricReader` - For MeterProvider +- `views` - Histogram bucket config +- `instrumentations` (getNodeAutoInstrumentations()) - Metapackage or individual +- `resource` - Custom resource +- `resourceDetectors` ([envDetector, processDetector, hostDetector]) - Auto-detect needs `autoDetectResources: true` +- `sampler` - Custom sampler (default: sample all) +- `serviceName` - Namespace identifier +- `spanProcessors` - Array for tracer provider +- `traceExporter` - Auto-setup OTLP/http/protobuf with BatchSpanProcessor if not set +- `spanLimits` - Tracing params + +### Resource Detectors via Env + +```bash +export OTEL_NODE_RESOURCE_DETECTORS="env,host" +# Options: env, host, os, process, serviceinstance, all, none +``` + +## Export to Backends + +Example - Axiom: + +```typescript +.use(opentelemetry({ + spanProcessors: [ + new BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'https://api.axiom.co/v1/traces', + headers: { + Authorization: `Bearer ${Bun.env.AXIOM_TOKEN}`, + 'X-Axiom-Dataset': Bun.env.AXIOM_DATASET + } + }) + ) + ] +})) +``` + +## OpenTelemetry SDK + +Use SDK normally - runs under Elysia's request span, auto-appears in trace. + +## Record Utility + +Equivalent to `startActiveSpan` - auto-closes + captures exceptions: + +```typescript +import { record } from '@elysiajs/opentelemetry' + +.get('', () => { + return record('database.query', () => { + return db.query('SELECT * FROM users') + }) +}) +``` + +Label for code shown in trace. + +## Function Naming + +Elysia reads function names as span names: + +```typescript +// ⚠️ Anonymous span +.derive(async ({ cookie: { session } }) => { + return { user: await getProfile(session) } +}) + +// ✅ Named span: "getProfile" +.derive(async function getProfile({ cookie: { session } }) { + return { user: await getProfile(session) } +}) +``` + +## getCurrentSpan + +Get current span outside handler (via AsyncLocalStorage): + +```typescript +import { getCurrentSpan } from "@elysiajs/opentelemetry"; + +function utility() { + const span = getCurrentSpan(); + span.setAttributes({ "custom.attribute": "value" }); +} +``` + +## setAttributes + +Sugar for `getCurrentSpan().setAttributes`: + +```typescript +import { setAttributes } from "@elysiajs/opentelemetry"; + +function utility() { + setAttributes({ "custom.attribute": "value" }); +} +``` + +## Instrumentations (Advanced) + +SDK must run before importing instrumented module. + +### Setup + +1. Separate file: + +```typescript +// src/instrumentation.ts +import { opentelemetry } from "@elysiajs/opentelemetry"; +import { PgInstrumentation } from "@opentelemetry/instrumentation-pg"; + +export const instrumentation = opentelemetry({ + instrumentations: [new PgInstrumentation()], +}); +``` + +2. Apply: + +```typescript +// src/index.ts +import { instrumentation } from "./instrumentation"; + +new Elysia().use(instrumentation).listen(3000); +``` + +3. Preload: + +```toml +# bunfig.toml +preload = ["./src/instrumentation.ts"] +``` + +### Production Deployment (Advanced) + +OpenTelemetry monkey-patches `node_modules`. Exclude instrumented libs from bundling: + +```bash +bun build --compile --external pg --outfile server src/index.ts +``` + +Package.json: + +```json +{ + "dependencies": { "pg": "^8.15.6" }, + "devDependencies": { + "@elysiajs/opentelemetry": "^1.2.0", + "@opentelemetry/instrumentation-pg": "^0.52.0" + } +} +``` + +Production install: + +```bash +bun install --production +``` + +Keeps `node_modules` with instrumented libs at runtime. diff --git a/.agents/skills/elysiajs/plugins/server-timing.md b/.agents/skills/elysiajs/plugins/server-timing.md new file mode 100644 index 0000000..c49b379 --- /dev/null +++ b/.agents/skills/elysiajs/plugins/server-timing.md @@ -0,0 +1,80 @@ +# Server Timing Plugin + +This plugin adds support for auditing performance bottlenecks with Server Timing API + +## Installation + +```bash +bun add @elysiajs/server-timing +``` + +## Basic Usage + +```typescript twoslash +import { serverTiming } from "@elysiajs/server-timing"; +import { Elysia } from "elysia"; + +new Elysia() + .use(serverTiming()) + .get("/", () => "hello") + .listen(3000); +``` + +Server Timing then will append header 'Server-Timing' with log duration, function name, and detail for each life-cycle function. + +To inspect, open browser developer tools > Network > [Request made through Elysia server] > Timing. + +Now you can effortlessly audit the performance bottleneck of your server. + +## Config + +Below is a config which is accepted by the plugin + +### enabled + +@default `NODE_ENV !== 'production'` + +Determine whether or not Server Timing should be enabled + +### allow + +@default `undefined` + +A condition whether server timing should be log + +### trace + +@default `undefined` + +Allow Server Timing to log specified life-cycle events: + +Trace accepts objects of the following: + +- request: capture duration from request +- parse: capture duration from parse +- transform: capture duration from transform +- beforeHandle: capture duration from beforeHandle +- handle: capture duration from the handle +- afterHandle: capture duration from afterHandle +- total: capture total duration from start to finish + +## Pattern + +Below you can find the common patterns to use the plugin. + +## Allow Condition + +You may disable Server Timing on specific routes via `allow` property + +```ts twoslash +import { serverTiming } from "@elysiajs/server-timing"; +import { Elysia } from "elysia"; + +new Elysia().use( + serverTiming({ + allow: ({ request }) => { + return new URL(request.url).pathname !== "/no-trace"; + }, + }), +); +``` diff --git a/.agents/skills/elysiajs/plugins/static.md b/.agents/skills/elysiajs/plugins/static.md new file mode 100644 index 0000000..d213180 --- /dev/null +++ b/.agents/skills/elysiajs/plugins/static.md @@ -0,0 +1,97 @@ +# Static Plugin + +This plugin can serve static files/folders for Elysia Server + +## Installation + +```bash +bun add @elysiajs/static +``` + +## Basic Usage + +```typescript twoslash +import { staticPlugin } from "@elysiajs/static"; +import { Elysia } from "elysia"; + +new Elysia().use(staticPlugin()).listen(3000); +``` + +By default, the static plugin default folder is `public`, and registered with `/public` prefix. + +Suppose your project structure is: + +``` +| - src + | - index.ts +| - public + | - takodachi.png + | - nested + | - takodachi.png +``` + +The available path will become: + +- /public/takodachi.png +- /public/nested/takodachi.png + +## Config + +Below is a config which is accepted by the plugin + +### assets + +@default `"public"` + +Path to the folder to expose as static + +### prefix + +@default `"/public"` + +Path prefix to register public files + +### ignorePatterns + +@default `[]` + +List of files to ignore from serving as static files + +### staticLimit + +@default `1024` + +By default, the static plugin will register paths to the Router with a static name, if the limits are exceeded, paths will be lazily added to the Router to reduce memory usage. +Tradeoff memory with performance. + +### alwaysStatic + +@default `false` + +If set to true, static files path will be registered to Router skipping the `staticLimits`. + +### headers + +@default `{}` + +Set response headers of files + +### indexHTML + +@default `false` + +If set to true, the `index.html` file from the static directory will be served for any request that is matching neither a route nor any existing static file. + +## Pattern + +Below you can find the common patterns to use the plugin. + +## Single file + +Suppose you want to return just a single file, you can use `file` instead of using the static plugin + +```typescript +import { Elysia, file } from "elysia"; + +new Elysia().get("/file", file("public/takodachi.png")); +``` diff --git a/.agents/skills/elysiajs/references/bun-fullstack-dev-server.md b/.agents/skills/elysiajs/references/bun-fullstack-dev-server.md new file mode 100644 index 0000000..f9f2334 --- /dev/null +++ b/.agents/skills/elysiajs/references/bun-fullstack-dev-server.md @@ -0,0 +1,146 @@ +# Fullstack Dev Server + +## What It Is + +Bun 1.3 Fullstack Dev Server with HMR. React without bundler (no Vite/Webpack). + +Example: [elysia-fullstack-example](https://github.com/saltyaom/elysia-fullstack-example) + +## Setup + +1. Install + use Elysia Static: + +```typescript +import { staticPlugin } from "@elysiajs/static"; +import { Elysia } from "elysia"; + +new Elysia() + .use(await staticPlugin()) // await required for HMR hooks + .listen(3000); +``` + +2. Create `public/index.html` + `public/index.tsx`: + +```html + + + + + + Elysia React App + + + +
+ + + +``` + +```tsx +// public/index.tsx +import { useState } from "react"; +import { createRoot } from "react-dom/client"; + +function App() { + const [count, setCount] = useState(0); + const increase = () => setCount((c) => c + 1); + + return ( +
+

{count}

+ +
+ ); +} + +const root = createRoot(document.getElementById("root")!); +root.render(); +``` + +3. Enable JSX in `tsconfig.json`: + +```json +{ + "compilerOptions": { + "jsx": "react-jsx" + } +} +``` + +4. Navigate to `http://localhost:3000/public`. + +Frontend + backend in single project. No bundler. Works with HMR, Tailwind, Tanstack Query, Eden Treaty, path alias. + +## Custom Prefix + +```typescript +.use(await staticPlugin({ prefix: '/' })) +``` + +Serves at `/` instead of `/public`. + +## Tailwind CSS + +1. Install: + +```bash +bun add tailwindcss@4 +bun add -d bun-plugin-tailwind +``` + +2. Create `bunfig.toml`: + +```toml +[serve.static] +plugins = ["bun-plugin-tailwind"] +``` + +3. Create `public/global.css`: + +```css +@tailwind base; +``` + +4. Add to HTML or TS: + +```html + +``` + +Or: + +```tsx +import "./global.css"; +``` + +## Path Alias + +1. Add to `tsconfig.json`: + +```json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@public/*": ["public/*"] + } + } +} +``` + +2. Use: + +```tsx +import "@public/global.css"; +``` + +Works out of box. + +## Production Build + +```bash +bun build --compile --target bun --outfile server src/index.ts +``` + +Creates single executable `server`. Include `public` folder when running. diff --git a/.agents/skills/elysiajs/references/cookie.md b/.agents/skills/elysiajs/references/cookie.md new file mode 100644 index 0000000..75fbc92 --- /dev/null +++ b/.agents/skills/elysiajs/references/cookie.md @@ -0,0 +1,220 @@ +# Cookie + +## What It Is + +Reactive mutable signal for cookie interaction. Auto-encodes/decodes objects. + +## Basic Usage + +No get/set - direct value access: + +```typescript +import { Elysia } from "elysia"; + +new Elysia().get("/", ({ cookie: { name } }) => { + // Get + name.value; + + // Set + name.value = "New Value"; +}); +``` + +Auto-encodes/decodes objects. Just works. + +## Reactivity + +Signal-like approach. Single source of truth. Auto-sets headers, syncs values. + +Cookie jar = Proxy object. Extract value always `Cookie`, never `undefined`. Access via `.value`. + +Iterate over cookie jar → only existing cookies. + +## Cookie Attributes + +### Direct Property Assignment + +```typescript +.get('/', ({ cookie: { name } }) => { + // Get + name.domain + + // Set + name.domain = 'millennium.sh' + name.httpOnly = true +}) +``` + +### set - Reset All Properties + +```typescript +.get('/', ({ cookie: { name } }) => { + name.set({ + domain: 'millennium.sh', + httpOnly: true + }) +}) +``` + +Overwrites all properties. + +### add - Update Specific Properties + +Like `set` but only overwrites defined properties. + +## Remove Cookie + +```typescript +.get('/', ({ cookie, cookie: { name } }) => { + name.remove() + // or + delete cookie.name +}) +``` + +## Cookie Schema + +Strict validation + type inference with `t.Cookie`: + +```typescript +import { Elysia, t } from "elysia"; + +new Elysia().get( + "/", + ({ cookie: { name } }) => { + name.value = { + id: 617, + name: "Summoning 101", + }; + }, + { + cookie: t.Cookie({ + name: t.Object({ + id: t.Numeric(), + name: t.String(), + }), + }), + }, +); +``` + +### Nullable Cookie + +```typescript +cookie: t.Cookie({ + name: t.Optional( + t.Object({ + id: t.Numeric(), + name: t.String(), + }), + ), +}); +``` + +## Cookie Signature + +Cryptographic hash for verification. Prevents malicious modification. + +```typescript +new Elysia().get( + "/", + ({ cookie: { profile } }) => { + profile.value = { id: 617, name: "Summoning 101" }; + }, + { + cookie: t.Cookie( + { + profile: t.Object({ + id: t.Numeric(), + name: t.String(), + }), + }, + { + secrets: "Fischl von Luftschloss Narfidort", + sign: ["profile"], + }, + ), + }, +); +``` + +Auto-signs/unsigns. + +### Global Config + +```typescript +new Elysia({ + cookie: { + secrets: "Fischl von Luftschloss Narfidort", + sign: ["profile"], + }, +}); +``` + +## Cookie Rotation + +Auto-handles secret rotation. Old signature verification + new signature signing. + +```typescript +new Elysia({ + cookie: { + secrets: ["Vengeance will be mine", "Fischl von Luftschloss Narfidort"], + }, +}); +``` + +Array = key rotation (retire old, replace with new). + +## Config + +### secrets + +Secret key for signing/unsigning. Array = key rotation. + +### domain + +Domain Set-Cookie attribute. Default: none (current domain only). + +### encode + +Function to encode value. Default: `encodeURIComponent`. + +### expires + +Date for Expires attribute. Default: none (non-persistent, deleted on browser exit). + +If both `expires` and `maxAge` set, `maxAge` takes precedence (spec-compliant clients). + +### httpOnly (false) + +HttpOnly attribute. If true, JS can't access via `document.cookie`. + +### maxAge (undefined) + +Seconds for Max-Age attribute. Rounded down to integer. + +If both `expires` and `maxAge` set, `maxAge` takes precedence (spec-compliant clients). + +### path + +Path attribute. Default: handler path. + +### priority + +Priority attribute: `low` | `medium` | `high`. Not fully standardized. + +### sameSite + +SameSite attribute: + +- `true` = Strict +- `false` = not set +- `'lax'` = Lax +- `'none'` = None (explicit cross-site) +- `'strict'` = Strict + +Not fully standardized. + +### secure + +Secure attribute. If true, only HTTPS. Clients won't send over HTTP. diff --git a/.agents/skills/elysiajs/references/deployment.md b/.agents/skills/elysiajs/references/deployment.md new file mode 100644 index 0000000..9409329 --- /dev/null +++ b/.agents/skills/elysiajs/references/deployment.md @@ -0,0 +1,427 @@ +# Deployment + +## Production Build + +### Compile to Binary (Recommended) + +```bash +bun build \ + --compile \ + --minify-whitespace \ + --minify-syntax \ + --target bun \ + --outfile server \ + src/index.ts +``` + +**Benefits:** + +- No runtime needed on deployment server +- Smaller memory footprint (2-3x reduction) +- Faster startup +- Single portable executable + +**Run the binary:** + +```bash +./server +``` + +### Compile to JavaScript + +```bash +bun build \ + --minify-whitespace \ + --minify-syntax \ + --outfile ./dist/index.js \ + src/index.ts +``` + +**Run:** + +```bash +NODE_ENV=production bun ./dist/index.js +``` + +## Docker + +### Basic Dockerfile + +```dockerfile +FROM oven/bun:1 AS build + +WORKDIR /app + +# Cache dependencies +COPY package.json bun.lock ./ +RUN bun install + +COPY ./src ./src + +ENV NODE_ENV=production + +RUN bun build \ + --compile \ + --minify-whitespace \ + --minify-syntax \ + --outfile server \ + src/index.ts + +FROM gcr.io/distroless/base + +WORKDIR /app + +COPY --from=build /app/server server + +ENV NODE_ENV=production + +CMD ["./server"] + +EXPOSE 3000 +``` + +### Build and Run + +```bash +docker build -t my-elysia-app . +docker run -p 3000:3000 my-elysia-app +``` + +### With Environment Variables + +```dockerfile +FROM gcr.io/distroless/base + +WORKDIR /app + +COPY --from=build /app/server server + +ENV NODE_ENV=production +ENV PORT=3000 +ENV DATABASE_URL="" +ENV JWT_SECRET="" + +CMD ["./server"] + +EXPOSE 3000 +``` + +## Cluster Mode (Multiple CPU Cores) + +```typescript +// src/index.ts +import cluster from "node:cluster"; +import os from "node:os"; +import process from "node:process"; + +if (cluster.isPrimary) { + for (let i = 0; i < os.availableParallelism(); i++) { + cluster.fork(); + } +} else { + await import("./server"); + console.log(`Worker ${process.pid} started`); +} +``` + +```typescript +// src/server.ts +import { Elysia } from "elysia"; + +new Elysia().get("/", () => "Hello World!").listen(3000); +``` + +## Environment Variables + +### .env File + +```env +NODE_ENV=production +PORT=3000 +DATABASE_URL=postgresql://user:password@localhost:5432/db +JWT_SECRET=your-secret-key +CORS_ORIGIN=https://example.com +``` + +### Load in App + +```typescript +import { Elysia } from "elysia"; + +const app = new Elysia() + .get("/env", () => ({ + env: process.env.NODE_ENV, + port: process.env.PORT, + })) + .listen(parseInt(process.env.PORT || "3000")); +``` + +## Platform-Specific Deployments + +### Railway + +```typescript +// Railway assigns random PORT via env variable +new Elysia().get("/", () => "Hello Railway").listen(process.env.PORT ?? 3000); +``` + +### Vercel + +```typescript +// src/index.ts +import { Elysia } from "elysia"; + +export default new Elysia().get("/", () => "Hello Vercel"); + +export const GET = app.fetch; +export const POST = app.fetch; +``` + +```json +// vercel.json +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "bunVersion": "1.x" +} +``` + +### Cloudflare Workers + +```typescript +import { Elysia } from "elysia"; +import { CloudflareAdapter } from "elysia/adapter/cloudflare-worker"; + +export default new Elysia({ + adapter: CloudflareAdapter, +}) + .get("/", () => "Hello Cloudflare!") + .compile(); +``` + +```toml +# wrangler.toml +name = "elysia-app" +main = "src/index.ts" +compatibility_date = "2025-06-01" +``` + +### Node.js Adapter + +```typescript +import { node } from "@elysiajs/node"; +import { Elysia } from "elysia"; + +const app = new Elysia({ adapter: node() }).get("/", () => "Hello Node.js").listen(3000); +``` + +## Performance Optimization + +### Enable AoT Compilation + +```typescript +new Elysia({ + aot: true, // Ahead-of-time compilation +}); +``` + +### Use Native Static Response + +```typescript +new Elysia({ + nativeStaticResponse: true, +}).get("/version", 1); // Optimized for Bun.serve.static +``` + +### Precompile Routes + +```typescript +new Elysia({ + precompile: true, // Compile all routes ahead of time +}); +``` + +## Health Checks + +```typescript +new Elysia() + .get("/health", () => ({ + status: "ok", + timestamp: Date.now(), + })) + .get("/ready", ({ db }) => { + // Check database connection + const isDbReady = checkDbConnection(); + + if (!isDbReady) { + return status(503, { status: "not ready" }); + } + + return { status: "ready" }; + }); +``` + +## Graceful Shutdown + +```typescript +import { Elysia } from "elysia"; + +const app = new Elysia().get("/", () => "Hello").listen(3000); + +process.on("SIGTERM", () => { + console.log("SIGTERM received, shutting down gracefully"); + app.stop(); + process.exit(0); +}); + +process.on("SIGINT", () => { + console.log("SIGINT received, shutting down gracefully"); + app.stop(); + process.exit(0); +}); +``` + +## Monitoring + +### OpenTelemetry + +```typescript +import { opentelemetry } from "@elysiajs/opentelemetry"; + +new Elysia().use( + opentelemetry({ + serviceName: "my-service", + endpoint: "http://localhost:4318", + }), +); +``` + +### Custom Logging + +```typescript +.onRequest(({ request }) => { + console.log(`[${new Date().toISOString()}] ${request.method} ${request.url}`) +}) +.onAfterResponse(({ request, set }) => { + console.log(`[${new Date().toISOString()}] ${request.method} ${request.url} - ${set.status}`) +}) +``` + +## SSL/TLS (HTTPS) + +```typescript +import { Elysia, file } from "elysia"; + +new Elysia({ + serve: { + tls: { + cert: file("cert.pem"), + key: file("key.pem"), + }, + }, +}) + .get("/", () => "Hello HTTPS") + .listen(3000); +``` + +## Best Practices + +1. **Always compile to binary for production** + - Reduces memory usage + - Smaller deployment size + - No runtime needed + +2. **Use environment variables** + - Never hardcode secrets + - Use different configs per environment + +3. **Enable health checks** + - Essential for load balancers + - K8s/Docker orchestration + +4. **Implement graceful shutdown** + - Handle SIGTERM/SIGINT + - Close connections properly + +5. **Use cluster mode** + - Utilize all CPU cores + - Better performance under load + +6. **Monitor your app** + - Use OpenTelemetry + - Log requests/responses + - Track errors + +## Example Production Setup + +```typescript +// src/server.ts +import { cors } from "@elysiajs/cors"; +import { opentelemetry } from "@elysiajs/opentelemetry"; +import { Elysia } from "elysia"; + +export const app = new Elysia({ + aot: true, + nativeStaticResponse: true, +}) + .use( + cors({ + origin: process.env.CORS_ORIGIN || "http://localhost:3000", + }), + ) + .use( + opentelemetry({ + serviceName: "my-service", + }), + ) + .get("/health", () => ({ status: "ok" })) + .get("/", () => "Hello Production") + .listen(parseInt(process.env.PORT || "3000")); + +// Graceful shutdown +process.on("SIGTERM", () => { + app.stop(); + process.exit(0); +}); +``` + +```typescript +// src/index.ts (cluster) +import cluster from "node:cluster"; +import os from "node:os"; + +if (cluster.isPrimary) { + for (let i = 0; i < os.availableParallelism(); i++) { + cluster.fork(); + } +} else { + await import("./server"); +} +``` + +```dockerfile +# Dockerfile +FROM oven/bun:1 AS build + +WORKDIR /app + +COPY package.json bun.lock ./ +RUN bun install + +COPY ./src ./src + +ENV NODE_ENV=production + +RUN bun build --compile --outfile server src/index.ts + +FROM gcr.io/distroless/base + +WORKDIR /app + +COPY --from=build /app/server server + +ENV NODE_ENV=production + +CMD ["./server"] + +EXPOSE 3000 +``` diff --git a/.agents/skills/elysiajs/references/eden.md b/.agents/skills/elysiajs/references/eden.md new file mode 100644 index 0000000..776e279 --- /dev/null +++ b/.agents/skills/elysiajs/references/eden.md @@ -0,0 +1,179 @@ +# Eden Treaty + +e2e type safe RPC client for share type from backend to frontend. + +## What It Is + +Type-safe object representation for Elysia server. Auto-completion + error handling. + +## Installation + +```bash +bun add @elysiajs/eden +bun add -d elysia +``` + +Export Elysia server type: + +```typescript +const app = new Elysia() + .get("/", () => "Hi Elysia") + .get("/id/:id", ({ params: { id } }) => id) + .post("/mirror", ({ body }) => body, { + body: t.Object({ + id: t.Number(), + name: t.String(), + }), + }) + .listen(3000); + +export type App = typeof app; +``` + +Consume on client side: + +```typescript +import { treaty } from "@elysiajs/eden"; + +import type { App } from "./server"; + +const client = treaty("localhost:3000"); + +// response: Hi Elysia +const { data: index } = await client.get(); + +// response: 1895 +const { data: id } = await client.id({ id: 1895 }).get(); + +// response: { id: 1895, name: 'Skadi' } +const { data: nendoroid } = await client.mirror.post({ + id: 1895, + name: "Skadi", +}); +``` + +## Common Errors & Fixes + +- **Strict mode**: Enable in tsconfig +- **Version mismatch**: `npm why elysia` - must match server/client +- **TypeScript**: Min 5.0 +- **Method chaining**: Required on server +- **Bun types**: `bun add -d @types/bun` if using Bun APIs +- **Path alias**: Must resolve same on frontend/backend + +### Monorepo Path Alias + +Must resolve to same file on frontend/backend + +```json +// tsconfig.json at root +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@frontend/*": ["./apps/frontend/src/*"], + "@backend/*": ["./apps/backend/src/*"] + } + } +} +``` + +## Syntax Mapping + +| Path | Method | Treaty | +| ------------ | ------ | ---------------------------- | +| / | GET | `.get()` | +| /hi | GET | `.hi.get()` | +| /deep/nested | POST | `.deep.nested.post()` | +| /item/:name | GET | `.item({ name: 'x' }).get()` | + +## Parameters + +### With body (POST/PUT/PATCH/DELETE): + +```typescript +.user.post( + { name: 'Elysia' }, // body + { headers: {}, query: {}, fetch: {} } // optional +) +``` + +### No body (GET/HEAD): + +```typescript +.hello.get({ headers: {}, query: {}, fetch: {} }) +``` + +### Empty body with query/headers: + +```typescript +.user.post(null, { query: { name: 'Ely' } }) +``` + +### Fetch options: + +```typescript +.hello.get({ fetch: { signal: controller.signal } }) +``` + +### File upload: + +```typescript +// Accepts: File | File[] | FileList | Blob +.image.post({ + title: 'Title', + image: fileInput.files! +}) +``` + +## Response + +```typescript +const { data, error, response, status, headers } = await api.user.post({ name: "x" }); + +if (error) { + switch (error.status) { + case 400: + throw error.value; + default: + throw error.value; + } +} +// data unwrapped after error handling +return data; +``` + +status >= 300 → `data = null`, `error` has value + +## Stream/SSE + +Interpreted as `AsyncGenerator`: + +```typescript +const { data, error } = await treaty(app).ok.get(); +if (error) throw error; + +for await (const chunk of data) console.log(chunk); +``` + +## Utility Types + +```typescript +import { Treaty } from "@elysiajs/eden"; + +type UserData = Treaty.Data; +type UserError = Treaty.Error; +``` + +## WebSocket + +```typescript +const chat = api.chat.subscribe(); + +chat.subscribe((message) => console.log("got", message)); +chat.on("open", () => chat.send("hello")); + +// Native access: chat.raw +``` + +`.subscribe()` accepts same params as `get`/`head` diff --git a/.agents/skills/elysiajs/references/lifecycle.md b/.agents/skills/elysiajs/references/lifecycle.md new file mode 100644 index 0000000..36aae3b --- /dev/null +++ b/.agents/skills/elysiajs/references/lifecycle.md @@ -0,0 +1,188 @@ +# Lifecycle + +Instead of a sequential process, Elysia's request handling is divided into multiple stages called lifecycle events. + +It's designed to separate the process into distinct phases based on their responsibility without interfering with each others. + +### List of events in order + +1. **request** - early, global +2. **parse** - body parsing +3. **transform** / **derive** - mutate context pre validation +4. **beforeHandle** / **resolve** - auth/guard logic +5. **handler** - your business code +6. **afterHandle** - tweak response, set headers +7. **mapResponse** - turn anything into a proper `Response` +8. **onError** - centralized error handling +9. **onAfterResponse** - post response/cleanup tasks + +## Request (`onRequest`) + +Runs first for every incoming request. + +- Ideal for **caching, rate limiting, CORS, adding global headers**. +- If the hook returns a value, the whole lifecycle stops and that value becomes the response. + +```ts +new Elysia().onRequest(({ ip, set }) => { + if (blocked(ip)) return (set.status = 429); +}); +``` + +--- + +## Parse (`onParse`) + +_Body parsing stage._ + +- Handles `text/plain`, `application/json`, `multipart/form-data`, `application/x www-form-urlencoded` by default. +- Use to add **custom parsers** or support extra `Content Type`s. + +```ts +new Elysia().onParse(({ request, contentType }) => { + if (contentType === "application/custom") return request.text(); +}); +``` + +--- + +## Transform (`onTransform`) + +_Runs **just before validation**; can mutate the request context._ + +- Perfect for **type coercion**, trimming strings, or adding temporary fields that validation will use. + +```ts +new Elysia().onTransform(({ params }) => { + params.id = Number(params.id); +}); +``` + +--- + +## Derive + +_Runs along with `onTransform` **but before validation**; adds per request values to the context._ + +- Useful for extracting info from headers, cookies, query, etc., that you want to reuse in handlers. + +```ts +new Elysia().derive(({ headers }) => ({ + bearer: headers.authorization?.replace(/^Bearer /, ""), +})); +``` + +--- + +## Before Handle (`onBeforeHandle`) + +_Executed after validation, right before the route handler._ + +- Great for **auth checks, permission gating, custom pre validation logic**. +- Returning a value skips the handler. + +```ts +new Elysia().get("/", () => "hi", { + beforeHandle({ cookie, status }) { + if (!cookie.session) return status(401); + }, +}); +``` + +--- + +## Resolve + +_Like `derive` but runs **after validation** along "Before Handle" (so you can rely on validated data)._ + +- Usually placed inside a `guard` because it isn't available as a local hook. + +```ts +new Elysia().guard({ headers: t.Object({ authorization: t.String() }) }, (app) => + app + .resolve(({ headers }) => ({ + bearer: headers.authorization.split(" ")[1], + })) + .get("/", ({ bearer }) => bearer), +); +``` + +--- + +## After Handle (`onAfterHandle`) + +_Runs after the handler finishes._ + +- Can **modify response headers**, wrap the result in a `Response`, or transform the payload. +- Returning a value **replaces** the handler’s result, but the next `afterHandle` hooks still run. + +```ts +new Elysia().get("/", () => "

Hello

", { + afterHandle({ response, set }) { + if (isHtml(response)) { + set.headers["content-type"] = "text/html; charset=utf-8"; + return new Response(response); + } + }, +}); +``` + +--- + +## Map Response (`mapResponse`) + +_Runs right after all `afterHandle` hooks; maps **any** value to a Web standard `Response`._ + +- Ideal for **compression, custom content type mapping, streaming**. + +```ts +new Elysia().mapResponse(({ responseValue, set }) => { + const body = typeof responseValue === "object" ? JSON.stringify(responseValue) : String(responseValue ?? ""); + + set.headers["content-encoding"] = "gzip"; + return new Response(Bun.gzipSync(new TextEncoder().encode(body)), { + headers: { + "Content-Type": typeof responseValue === "object" ? "application/json" : "text/plain", + }, + }); +}); +``` + +--- + +## On Error (`onError`) + +_Caught whenever an error bubbles up from any lifecycle stage._ + +- Use to **customize error messages**, **handle 404**, **log**, or **retry**. +- Must be registered **before** the routes it should protect. + +```ts +new Elysia().onError(({ code, status }) => { + if (code === "NOT_FOUND") return status(404, "❓ Not found"); + return new Response("Oops", { status: 500 }); +}); +``` + +--- + +## After Response (`onAfterResponse`) + +_Runs **after** the response has been sent to the client._ + +- Perfect for **logging, metrics, cleanup**. + +```ts +new Elysia().onAfterResponse(() => console.log("✅ response sent at", Date.now())); +``` + +--- + +## Hook Types + +| Type | Scope | How to add | +| -------------------- | --------------------------------- | --------------------------------------------------------- | +| **Local Hook** | Single route | Inside route options (`afterHandle`, `beforeHandle`, …) | +| **Interceptor Hook** | Whole instance (and later routes) | `.onXxx(cb)` or `.use(plugin)` | + +> **Remember:** Hooks only affect routes **defined after** they are registered, except `onRequest` which is global because it runs before route matching. diff --git a/.agents/skills/elysiajs/references/macro.md b/.agents/skills/elysiajs/references/macro.md new file mode 100644 index 0000000..0232195 --- /dev/null +++ b/.agents/skills/elysiajs/references/macro.md @@ -0,0 +1,97 @@ +# Macro + +Composable Elysia function for controlling lifecycle/schema/context with full type safety. Available in hook after definition control by key-value label. + +## Basic Pattern + +```typescript +.macro({ + hi: (word: string) => ({ + beforeHandle() { console.log(word) } + }) +}) +.get('/', () => 'hi', { hi: 'Elysia' }) +``` + +## Property Shorthand + +Object → function accepting boolean: + +```typescript +.macro({ + // These equivalent: + isAuth: { resolve: () => ({ user: 'saltyaom' }) }, + isAuth(enabled: boolean) { if(enabled) return { resolve() {...} } } +}) +``` + +## Error Handling + +Return `status`, don't throw: + +```typescript +.macro({ + auth: { + resolve({ headers }) { + if(!headers.authorization) return status(401, 'Unauthorized') + return { user: 'SaltyAom' } + } + } +}) +``` + +## Resolve - Add Context Props + +```typescript +.macro({ + user: (enabled: true) => ({ + resolve: () => ({ user: 'Pardofelis' }) + }) +}) +.get('/', ({ user }) => user, { user: true }) +``` + +### Named Macro for Type Inference + +TypeScript limitation workaround: + +```typescript +.macro('user', { resolve: () => ({ user: 'lilith' }) }) +.macro('user2', { user: true, resolve: ({ user }) => {} }) +``` + +## Schema + +Auto-validates, infers types, stacks with other schemas: + +```typescript +.macro({ + withFriends: { + body: t.Object({ friends: t.Tuple([...]) }) + } +}) +``` + +Use named single macro for lifecycle type inference within same macro. + +## Extension + +Stack macros: + +```typescript +.macro({ + sartre: { body: t.Object({...}) }, + fouco: { body: t.Object({...}) }, + lilith: { fouco: true, sartre: true, body: t.Object({...}) } +}) +``` + +## Deduplication + +Auto-dedupes by property value. Custom seed: + +```typescript +.macro({ sartre: (role: string) => ({ seed: role, ... }) }) +``` + +Max stack: 16 (prevents infinite loops) diff --git a/.agents/skills/elysiajs/references/plugin.md b/.agents/skills/elysiajs/references/plugin.md new file mode 100644 index 0000000..20bb97d --- /dev/null +++ b/.agents/skills/elysiajs/references/plugin.md @@ -0,0 +1,201 @@ +# Plugins + +## Plugin = Decoupled Elysia Instance + +```ts +const plugin = new Elysia().decorate("plugin", "hi").get("/plugin", ({ plugin }) => plugin); + +const app = new Elysia() + .use(plugin) // inherit properties + .get("/", ({ plugin }) => plugin); +``` + +**Inherits**: state, decorate +**Does NOT inherit**: lifecycle (isolated by default) + +## Dependency + +Each instance runs independently like microservice. **Must explicitly declare dependencies**. + +```ts +const auth = new Elysia().decorate("Auth", Auth); + +// ❌ Missing dependency +const main = new Elysia().get("/", ({ Auth }) => Auth.getProfile()); + +// ✅ Declare dependency +const main = new Elysia() + .use(auth) // required for Auth + .get("/", ({ Auth }) => Auth.getProfile()); +``` + +## Deduplication + +**Every plugin re-executes by default**. Use `name` + optional `seed` to deduplicate: + +```ts +const ip = new Elysia({ name: "ip" }) // unique identifier + .derive({ as: "global" }, ({ server, request }) => ({ + ip: server?.requestIP(request), + })); + +const router1 = new Elysia().use(ip); +const router2 = new Elysia().use(ip); +const server = new Elysia().use(router1).use(router2); +// `ip` only executes once due to deduplication +``` + +## Global vs Explicit Dependency + +**Global plugin** (rare, apply everywhere): + +- Doesn't add types - cors, compress, helmet +- Global lifecycle no instance controls - tracing, logging +- Examples: OpenAPI docs, OpenTelemetry, logging + +**Explicit dependency** (default, recommended): + +- Adds types - macro, state, model +- Business logic instances interact with - Auth, DB +- Examples: state management, ORM, auth, features + +## Scope + +**Lifecycle isolated by default**. Must specify scope to export. + +```ts +// ❌ NOT inherited by app +const profile = new Elysia().onBeforeHandle(({ cookie }) => throwIfNotSignIn(cookie)).get("/profile", () => "Hi"); + +const app = new Elysia().use(profile).patch("/rename", ({ body }) => updateProfile(body)); // No sign-in check + +// ✅ Exported to app +const profile = new Elysia() + .onBeforeHandle({ as: "global" }, ({ cookie }) => throwIfNotSignIn(cookie)) + .get("/profile", () => "Hi"); +``` + +## Scope Levels + +1. **local** (default) - current + descendants only +2. **scoped** - parent + current + descendants +3. **global** - all instances (all parents, current, descendants) + +Example with `.onBeforeHandle({ as: 'local' }, ...)`: + +| type | child | current | parent | main | +| ------ | ----- | ------- | ------ | ---- | +| local | ✅ | ✅ | ❌ | ❌ | +| scoped | ✅ | ✅ | ✅ | ❌ | +| global | ✅ | ✅ | ✅ | ✅ | + +## Config + +```ts +// Instance factory with config +const version = (v = 1) => new Elysia().get("/version", v); + +const app = new Elysia().use(version(1)); +``` + +## Functional Callback (not recommended) + +```ts +// Harder to handle scope/encapsulation +const plugin = (app: Elysia) => app.state("counter", 0).get("/plugin", () => "Hi"); + +// Prefer new instance (better type inference, no perf diff) +``` + +## Guard (Apply to Multiple Routes) + +```ts +.guard( + { body: t.Object({ username: t.String(), password: t.String() }) }, + (app) => + app.post('/sign-up', ({ body }) => signUp(body)) + .post('/sign-in', ({ body }) => signIn(body)) +) +``` + +**Grouped guard** (merge group + guard): + +```ts +.group( + '/v1', + { body: t.Literal('Rikuhachima Aru') }, // guard here + (app) => app.post('/student', ({ body }) => body) +) +``` + +## Scope Casting + +**3 methods to apply hook to parent**: + +1. **Inline as** (single hook): + +```ts +.derive({ as: 'scoped' }, () => ({ hi: 'ok' })) +``` + +2. **Guard as** (multiple hooks, no derive/resolve): + +```ts +.guard({ + as: 'scoped', + response: t.String(), + beforeHandle() { console.log('ok') } +}) +``` + +3. **Instance as** (all hooks + schema): + +```ts +const plugin = new Elysia() + .derive(() => ({ hi: "ok" })) + .get("/child", ({ hi }) => hi) + .as("scoped"); // lift scope up +``` + +`.as()` lifts scope: local → scoped → global + +## Lazy Load + +**Deferred module** (async plugin, non-blocking startup): + +```ts +// plugin.ts +export const loadStatic = async (app: Elysia) => { + const files = await loadAllFiles(); + files.forEach((asset) => app.get(asset, file(asset))); + return app; +}; + +// main.ts +const app = new Elysia().use(loadStatic); +``` + +**Lazy-load module** (dynamic import): + +```ts +const app = new Elysia().use(import("./plugin")); // loaded after startup +``` + +**Testing** (wait for modules): + +```ts +await app.modules; // ensure all deferred/lazy modules loaded +``` + +## Notes + +[Inference] Based on docs patterns: + +- Use inline values for static resources (performance optimization) +- Group routes by prefix for organization +- Extend context minimally (separation of concerns) +- Use `status()` over `set.status` for type safety +- Prefer `resolve()` over `derive()` when type integrity matters +- Plugins isolated by default (must declare scope explicitly) +- Use `name` for deduplication when plugin used multiple times +- Prefer explicit dependency over global (better modularity/tracking) diff --git a/.agents/skills/elysiajs/references/route.md b/.agents/skills/elysiajs/references/route.md new file mode 100644 index 0000000..bd89b76 --- /dev/null +++ b/.agents/skills/elysiajs/references/route.md @@ -0,0 +1,329 @@ +# ElysiaJS: Routing, Handlers & Context + +## Routing + +### Path Types + +```ts +new Elysia() + .get("/static", "static path") // exact match + .get("/id/:id", "dynamic path") // captures segment + .get("/id/*", "wildcard path"); // captures rest +``` + +**Path Priority**: static > dynamic > wildcard + +### Dynamic Paths + +```ts +new Elysia() + .get("/id/:id", ({ params: { id } }) => id) + .get("/id/:id/:name", ({ params: { id, name } }) => id + " " + name); +``` + +**Optional params**: `.get('/id/:id?', ...)` + +### HTTP Verbs + +- `.get()` - retrieve data +- `.post()` - submit/create +- `.put()` - replace +- `.patch()` - partial update +- `.delete()` - remove +- `.all()` - any method +- `.route(method, path, handler)` - custom verb + +### Grouping Routes + +```ts +new Elysia() + .group('/user', { body: t.Literal('auth') }, (app) => + app.post('/sign-in', ...) + .post('/sign-up', ...) +) + +// Or use prefix in constructor +new Elysia({ prefix: '/user' }) + .post('/sign-in', ...) +``` + +## Handlers + +### Handler = function accepting HTTP request, returning response + +```ts +// Inline value (compiled ahead, optimized) +.get('/', 'Hello Elysia') +.get('/video', file('video.mp4')) + +// Function handler +.get('/', () => 'hello') +.get('/', ({ params, query, body }) => {...}) +``` + +### Context Properties + +- `body` - HTTP message/form/file +- `query` - query string as object +- `params` - path parameters +- `headers` - HTTP headers +- `cookie` - mutable signal for cookies +- `store` - global mutable state +- `request` - Web Standard Request +- `server` - Bun server instance +- `path` - request pathname + +### Context Utilities + +```ts +import { form, redirect } from "elysia"; + +new Elysia().get("/", ({ status, set, form }) => { + // Status code (type-safe) + status(418, "I'm a teapot"); + + // Set response props + set.headers["x-custom"] = "value"; + set.status = 418; // legacy, no type inference + + // Redirect + return redirect("https://...", 302); + + // Cookies (mutable signal, no get/set) + cookie.name.value; // get + cookie.name.value = "new"; // set + + // FormData response + return form({ name: "Party", images: [file("a.jpg")] }); + + // Single file + return file("document.pdf"); +}); +``` + +### Streaming + +```ts +new Elysia() + .get('/stream', function* () { + yield 1 + yield 2 + yield 3 + }) + // Server-Sent Events + .get('/sse', function* () { + yield sse('hello') + yield sse({ event: 'msg', data: {...} }) + }) +``` + +**Note**: Headers only settable before first yield + +**Conditional stream**: returning without yield converts to normal response + +## Context Extension + +[Inference] Extend when property is: + +- Global mutable (use `state`) +- Request/response related (use `decorate`) +- Derived from existing props (use `derive`/`resolve`) + +### state() - Global Mutable + +```ts +new Elysia() + `.state('version', 1) + .get('/', ({ store: { version } }) => version) + // Multiple + .state({ counter: 0, visits: 0 }) + + // Remap (create new from existing) + .state(({ version, ...store }) => ({ + ...store, + apiVersion: version + })) +``` + +**Gotcha**: Use reference not value + +```ts +new Elysia() + // ✅ Correct + .get("/", ({ store }) => store.counter++) + + // ❌ Wrong - loses reference + .get("/", ({ store: { counter } }) => counter++); +``` + +### decorate() - Additional Context Props + +```ts +new Elysia() + .decorate("logger", new Logger()) + .get("/", ({ logger }) => logger.log("hi")) + + // Multiple + .decorate({ logger: new Logger(), db: connection }); +``` + +**When**: constant/readonly values, classes with internal state, singletons + +### derive() - Create from Existing (Transform Lifecycle) + +```ts +new Elysia() + .derive(({ headers }) => ({ + bearer: headers.authorization?.startsWith("Bearer ") ? headers.authorization.slice(7) : null, + })) + .get("/", ({ bearer }) => bearer); +``` + +**Timing**: runs at transform (before validation) +**Type safety**: request props typed as `unknown` + +### resolve() - Type-Safe Derive (beforeHandle Lifecycle) + +```ts +new Elysia() + .guard({ + headers: t.Object({ + bearer: t.String({ pattern: "^Bearer .+$" }), + }), + }) + .resolve(({ headers }) => ({ + bearer: headers.bearer.slice(7), // typed correctly + })); +``` + +**Timing**: runs at beforeHandle (after validation) +**Type safety**: request props fully typed + +### Error from derive/resolve + +```ts +new Elysia() + .derive(({ headers, status }) => { + if (!headers.authorization) return status(400) + return { bearer: ... } + }) +``` + +Returns early if error returned + +## Patterns + +### Affix (Bulk Remap) + +```ts +const plugin = new Elysia({ name: "setup" }).decorate({ + argon: "a", + boron: "b", +}); + +new Elysia() + .use(plugin) + .prefix("decorator", "setup") // setupArgon, setupBoron + .prefix("all", "setup"); // remap everything +``` + +### Assignment Patterns + +1. **key-value**: `.state('key', value)` +2. **object**: `.state({ k1: v1, k2: v2 })` +3. **remap**: `.state(({old}) => ({new}))` + +## Testing + +```ts +const app = new Elysia().get("/", "hi"); + +// Programmatic test +app.handle(new Request("http://localhost/")); +``` + +## To Throw or Return + +Most of an error handling in Elysia can be done by throwing an error and will be handle in `onError`. + +But for `status` it can be a little bit confusing, since it can be used both as a return value or throw an error. + +It could either be **return** or **throw** based on your specific needs. + +- If an `status` is **throw**, it will be caught by `onError` middleware. +- If an `status` is **return**, it will be **NOT** caught by `onError` middleware. + +See the following code: + +```typescript +import { Elysia, file } from "elysia"; + +new Elysia() + .onError(({ code, error, path }) => { + if (code === 418) return "caught"; + }) + .get("/throw", ({ status }) => { + // This will be caught by onError + throw status(418); + }) + .get("/return", ({ status }) => { + // This will NOT be caught by onError + return status(418); + }); +``` + +## To Throw or Return + +Elysia provide a `status` function for returning HTTP status code, prefers over `set.status`. + +`status` can be import from Elysia but preferably extract from route handler Context for type safety. + +```ts +import { Elysia, status } from "elysia"; + +function doThing() { + if (Math.random() > 0.33) return status(418, "I'm a teapot"); +} + +new Elysia().get("/", ({ status }) => { + if (Math.random() > 0.33) return status(418); + + return "ok"; +}); +``` + +Error Handling in Elysia can be done by throwing an error and will be handle in `onError`. + +Status could either be **return** or **throw** based on your specific needs. + +- If an `status` is **throw**, it will be caught by `onError` middleware. +- If an `status` is **return**, it will be **NOT** caught by `onError` middleware. + +See the following code: + +```typescript +import { Elysia, file } from "elysia"; + +new Elysia() + .onError(({ code, error, path }) => { + if (code === 418) return "caught"; + }) + .get("/throw", ({ status }) => { + // This will be caught by onError + throw status(418); + }) + .get("/return", ({ status }) => { + // This will NOT be caught by onError + return status(418); + }); +``` + +## Notes + +[Inference] Based on docs patterns: + +- Use inline values for static resources (performance optimization) +- Group routes by prefix for organization +- Extend context minimally (separation of concerns) +- Use `status()` over `set.status` for type safety +- Prefer `resolve()` over `derive()` when type integrity matters diff --git a/.agents/skills/elysiajs/references/testing.md b/.agents/skills/elysiajs/references/testing.md new file mode 100644 index 0000000..3007d03 --- /dev/null +++ b/.agents/skills/elysiajs/references/testing.md @@ -0,0 +1,389 @@ +# Unit Testing + +## Basic Test Setup + +### Installation + +```bash +bun add -d @elysiajs/eden +``` + +### Basic Test + +```typescript +// test/app.test.ts +import { describe, expect, it } from "bun:test"; +import { Elysia } from "elysia"; + +describe("Elysia App", () => { + it("should return hello world", async () => { + const app = new Elysia().get("/", () => "Hello World"); + + const res = await app.handle(new Request("http://localhost/")); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("Hello World"); + }); +}); +``` + +## Testing Routes + +### GET Request + +```typescript +it("should get user by id", async () => { + const app = new Elysia().get("/user/:id", ({ params: { id } }) => ({ + id, + name: "John Doe", + })); + + const res = await app.handle(new Request("http://localhost/user/123")); + + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data).toEqual({ + id: "123", + name: "John Doe", + }); +}); +``` + +### POST Request + +```typescript +it("should create user", async () => { + const app = new Elysia().post("/user", ({ body }) => body); + + const res = await app.handle( + new Request("http://localhost/user", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Jane Doe", + email: "jane@example.com", + }), + }), + ); + + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.name).toBe("Jane Doe"); +}); +``` + +## Testing Module/Plugin + +### Module Structure + +``` +src/ +├── modules/ +│ └── auth/ +│ ├── index.ts # Elysia instance +│ ├── service.ts +│ └── model.ts +└── index.ts +``` + +### Auth Module + +```typescript +// src/modules/auth/index.ts +import { Elysia, t } from "elysia"; + +export const authModule = new Elysia({ prefix: "/auth" }) + .post( + "/login", + ({ body, cookie: { session } }) => { + if (body.username === "admin" && body.password === "password") { + session.value = "valid-session"; + return { success: true }; + } + return { success: false }; + }, + { + body: t.Object({ + username: t.String(), + password: t.String(), + }), + }, + ) + .get("/profile", ({ cookie: { session }, status }) => { + if (!session.value) { + return status(401, { error: "Unauthorized" }); + } + return { username: "admin" }; + }); +``` + +### Auth Module Test + +```typescript +// test/auth.test.ts +import { describe, expect, it } from "bun:test"; + +import { authModule } from "../src/modules/auth"; + +describe("Auth Module", () => { + it("should login successfully", async () => { + const res = await authModule.handle( + new Request("http://localhost/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "admin", + password: "password", + }), + }), + ); + + const data = await res.json(); + expect(res.status).toBe(200); + expect(data.success).toBe(true); + }); + + it("should reject invalid credentials", async () => { + const res = await authModule.handle( + new Request("http://localhost/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "wrong", + password: "wrong", + }), + }), + ); + + const data = await res.json(); + expect(data.success).toBe(false); + }); + + it("should return 401 for unauthenticated profile request", async () => { + const res = await authModule.handle(new Request("http://localhost/auth/profile")); + + expect(res.status).toBe(401); + }); +}); +``` + +## Eden Treaty Testing + +### Setup + +```typescript +import { treaty } from "@elysiajs/eden"; + +import { app } from "../src/modules/auth"; + +const api = treaty(app); +``` + +### Eden Tests + +```typescript +describe("Auth Module with Eden", () => { + it("should login with Eden", async () => { + const { data, error } = await api.auth.login.post({ + username: "admin", + password: "password", + }); + + expect(error).toBeNull(); + expect(data?.success).toBe(true); + }); + + it("should get profile with Eden", async () => { + // First login + await api.auth.login.post({ + username: "admin", + password: "password", + }); + + // Then get profile + const { data, error } = await api.auth.profile.get(); + + expect(error).toBeNull(); + expect(data?.username).toBe("admin"); + }); +}); +``` + +## Mocking Dependencies + +### With Decorators + +```typescript +// test +import { app } from "../src/app"; + +// app.ts +export const app = new Elysia().decorate("db", realDatabase).get("/users", ({ db }) => db.getUsers()); + +describe("App with mocked DB", () => { + it("should use mock database", async () => { + const mockDb = { + getUsers: () => [{ id: 1, name: "Test User" }], + }; + + const testApp = app.decorate("db", mockDb); + + const res = await testApp.handle(new Request("http://localhost/users")); + + const data = await res.json(); + expect(data).toEqual([{ id: 1, name: "Test User" }]); + }); +}); +``` + +## Testing with Headers + +```typescript +it("should require authorization", async () => { + const app = new Elysia().get("/protected", ({ headers, status }) => { + if (!headers.authorization) { + return status(401); + } + return { data: "secret" }; + }); + + const res = await app.handle( + new Request("http://localhost/protected", { + headers: { + Authorization: "Bearer token123", + }, + }), + ); + + expect(res.status).toBe(200); +}); +``` + +## Testing Validation + +```typescript +import { Elysia, t } from "elysia"; + +it("should validate request body", async () => { + const app = new Elysia().post("/user", ({ body }) => body, { + body: t.Object({ + name: t.String(), + age: t.Number({ minimum: 0 }), + }), + }); + + // Valid request + const validRes = await app.handle( + new Request("http://localhost/user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "John", + age: 25, + }), + }), + ); + expect(validRes.status).toBe(200); + + // Invalid request (negative age) + const invalidRes = await app.handle( + new Request("http://localhost/user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "John", + age: -5, + }), + }), + ); + expect(invalidRes.status).toBe(400); +}); +``` + +## Testing WebSocket + +```typescript +it("should handle websocket connection", (done) => { + const app = new Elysia().ws("/chat", { + message(ws, message) { + ws.send("Echo: " + message); + }, + }); + + const ws = new WebSocket("ws://localhost:3000/chat"); + + ws.onopen = () => { + ws.send("Hello"); + }; + + ws.onmessage = (event) => { + expect(event.data).toBe("Echo: Hello"); + ws.close(); + done(); + }; +}); +``` + +## Complete Example + +```typescript +// src/modules/auth/index.ts +import { treaty } from "@elysiajs/eden"; +// test/auth.test.ts +import { describe, expect, it } from "bun:test"; +import { Elysia, t } from "elysia"; + +import { authModule } from "../src/modules/auth"; + +export const authModule = new Elysia({ prefix: "/auth" }) + .post( + "/login", + ({ body, cookie: { session } }) => { + if (body.username === "admin" && body.password === "password") { + session.value = "valid-session"; + return { success: true }; + } + return { success: false }; + }, + { + body: t.Object({ + username: t.String(), + password: t.String(), + }), + }, + ) + .get("/profile", ({ cookie: { session }, status }) => { + if (!session.value) { + return status(401); + } + return { username: "admin" }; + }); + +const api = treaty(authModule); + +describe("Auth Module", () => { + it("should login successfully", async () => { + const { data, error } = await api.auth.login.post({ + username: "admin", + password: "password", + }); + + expect(error).toBeNull(); + expect(data?.success).toBe(true); + }); + + it("should return 401 for unauthorized access", async () => { + const { error } = await api.auth.profile.get(); + + expect(error?.status).toBe(401); + }); +}); +``` diff --git a/.agents/skills/elysiajs/references/validation.md b/.agents/skills/elysiajs/references/validation.md new file mode 100644 index 0000000..5203804 --- /dev/null +++ b/.agents/skills/elysiajs/references/validation.md @@ -0,0 +1,549 @@ +# Validation Schema - SKILLS.md + +## What It Is + +Runtime validation + type inference + OpenAPI schema from single source. TypeBox-based with Standard Schema support. + +## Basic Usage + +```typescript +import { Elysia, t } from "elysia"; + +new Elysia().get("/id/:id", ({ params: { id } }) => id, { + params: t.Object({ id: t.Number({ minimum: 1 }) }), + response: { + 200: t.Number(), + 404: t.Literal("Not Found"), + }, +}); +``` + +## Schema Types + +Third parameter of HTTP method: + +- **body** - HTTP message +- **query** - URL query params +- **params** - Path params +- **headers** - Request headers +- **cookie** - Request cookies +- **response** - Response (per status) + +## Standard Schema Support + +Use Zod, Valibot, ArkType, Effect, Yup, Joi: + +```typescript +import { z } from 'zod' +import * as v from 'valibot' + +.get('/', ({ params, query }) => params.id, { + params: z.object({ id: z.coerce.number() }), + query: v.object({ name: v.literal('Lilith') }) +}) +``` + +Mix validators in same handler. + +## Body + +```typescript +body: t.Object({ name: t.String() }); +``` + +GET/HEAD: body-parser disabled by default (RFC2616). + +### File Upload + +```typescript +body: t.Object({ + file: t.File({ format: "image/*" }), + multipleFiles: t.Files(), +}); +// Auto-assumes multipart/form-data +``` + +### File (Standard Schema) + +```typescript +import { fileType } from "elysia"; + +body: z.object({ + file: z.file().refine((file) => fileType(file, "image/jpeg")), +}); +``` + +Use `fileType` for security (validates magic number, not just MIME). + +## Query + +```typescript +query: t.Object({ name: t.String() }); +// /?name=Elysia +``` + +Auto-coerces to specified type. + +### Arrays + +```typescript +query: t.Object({ name: t.Array(t.String()) }); +``` + +Formats supported: + +- **nuqs**: `?name=a,b,c` (comma delimiter) +- **HTML form**: `?name=a&name=b&name=c` (multiple keys) + +## Params + +```typescript +params: t.Object({ id: t.Number() }); +// /id/1 +``` + +Auto-inferred as string if schema not provided. + +## Headers + +```typescript +headers: t.Object({ authorization: t.String() }); +``` + +`additionalProperties: true` by default. Always lowercase keys. + +## Cookie + +```typescript +cookie: t.Cookie( + { + name: t.String(), + }, + { + secure: true, + httpOnly: true, + }, +); +``` + +Or use `t.Object`. `additionalProperties: true` by default. + +## Response + +```typescript +response: t.Object({ name: t.String() }); +``` + +### Per Status + +```typescript +response: { + 200: t.Object({ name: t.String() }), + 400: t.Object({ error: t.String() }) +} +``` + +## Error Handling + +### Inline Error Property + +```typescript +body: t.Object({ + x: t.Number({ error: "x must be number" }), +}); +``` + +Or function: + +```typescript +x: t.Number({ + error({ errors, type, validation, value }) { + return "Expected x to be number"; + }, +}); +``` + +### onError Hook + +```typescript +.onError(({ code, error }) => { + if (code === 'VALIDATION') + return error.message // or error.all[0].message +}) +``` + +`error.all` - list all error causes. `error.all.find(x => x.path === '/name')` - find specific field. + +## Reference Models + +Name + reuse models: + +```typescript +.model({ + sign: t.Object({ + username: t.String(), + password: t.String() + }) +}) +.post('/sign-in', ({ body }) => body, { + body: 'sign', + response: 'sign' +}) +``` + +Extract to plugin: + +```typescript +// auth.model.ts +export const authModel = new Elysia().model({ sign: t.Object({...}) }) + +// main.ts +new Elysia().use(authModel).post('/', ..., { body: 'sign' }) +``` + +### Naming Convention + +Prevent duplicates with namespaces: + +```typescript +.model({ + 'auth.admin': t.Object({...}), + 'auth.user': t.Object({...}) +}) +``` + +Or use `prefix` / `suffix` to rename models in current instance + +```typescript +.model({ sign: t.Object({...}) }) +.prefix('model', 'auth') +.post('/', () => '', { + body: 'auth.User' +}) +``` + +Models with `prefix` will be capitalized. + +## TypeScript Types + +```typescript +const MyType = t.Object({ hello: t.Literal("Elysia") }); +type MyType = typeof MyType.static; +``` + +Single schema → runtime validation + coercion + TypeScript type + OpenAPI. + +## Guard + +Apply schema to multiple handlers. Affects all handlers after definition. + +### Basic Usage + +```typescript +import { Elysia, t } from "elysia"; + +new Elysia() + .get("/none", ({ query }) => "hi") + .guard({ + query: t.Object({ + name: t.String(), + }), + }) + .get("/query", ({ query }) => query) + .listen(3000); +``` + +Ensures `query.name` string required for all handlers after guard. + +### Behavior + +| Path | Response | +| ------------- | -------- | +| /none | hi | +| /none?name=a | hi | +| /query | error | +| /query?name=a | a | + +### Precedence + +- Multiple global schemas: latest wins +- Global vs local: local wins + +### Schema Types + +1. override (default) + Latest schema overrides collided schema. + +```typescript +.guard({ query: t.Object({ name: t.String() }) }) +.guard({ query: t.Object({ id: t.Number() }) }) +// Only id required, name overridden +``` + +2. standalone + Both schemas run independently. Both validated. + +```typescript +.guard({ query: t.Object({ name: t.String() }) }, { type: 'standalone' }) +.guard({ query: t.Object({ id: t.Number() }) }, { type: 'standalone' }) +// Both name AND id required +``` + +# Typebox Validation (Elysia.t) + +Elysia.t = TypeBox with server-side pre-configuration + HTTP-specific types + +**TypeBox API mirrors TypeScript syntax** but provides runtime validation + +## Basic Types + +| TypeBox | TypeScript | Example Value | +| ----------------------------- | --------------- | ------------- | +| `t.String()` | `string` | `"hello"` | +| `t.Number()` | `number` | `42` | +| `t.Boolean()` | `boolean` | `true` | +| `t.Array(t.Number())` | `number[]` | `[1, 2, 3]` | +| `t.Object({ x: t.Number() })` | `{ x: number }` | `{ x: 10 }` | +| `t.Null()` | `null` | `null` | +| `t.Literal(42)` | `42` | `42` | + +## Attributes (JSON Schema 7) + +```ts +// Email format +t.String({ format: "email" }); + +// Number constraints +t.Number({ minimum: 10, maximum: 100 }); + +// Array constraints +t.Array(t.Number(), { + minItems: 1, // min items + maxItems: 5, // max items +}); + +// Object - allow extra properties +t.Object( + { x: t.Number() }, + { additionalProperties: true }, // default: false +); +``` + +## Common Patterns + +### Union (Multiple Types) + +```ts +t.Union([t.String(), t.Number()]); +// type: string | number +// values: "Hello" or 123 +``` + +### Optional (Field Optional) + +```ts +t.Object({ + x: t.Number(), + y: t.Optional(t.Number()), // can be undefined +}); +// type: { x: number, y?: number } +// value: { x: 123 } or { x: 123, y: 456 } +``` + +### Partial (All Fields Optional) + +```ts +t.Partial( + t.Object({ + x: t.Number(), + y: t.Number(), + }), +); +// type: { x?: number, y?: number } +// value: {} or { y: 123 } or { x: 1, y: 2 } +``` + +## Elysia-Specific Types + +### UnionEnum (One of Values) + +```ts +t.UnionEnum(["rapi", "anis", 1, true, false]); +``` + +### File (Single File Upload) + +```ts +t.File({ + type: "image", // or ['image', 'video'] + minSize: "1k", // 1024 bytes + maxSize: "5m", // 5242880 bytes +}); +``` + +**File unit suffixes**: + +- `m` = MegaByte (1048576 bytes) +- `k` = KiloByte (1024 bytes) + +### Files (Multiple Files) + +```ts +t.Files(); // extends File + array +``` + +### Cookie (Cookie Jar) + +```ts +t.Cookie( + { + name: t.String(), + }, + { + secrets: "secret-key", // or ['key1', 'key2'] for rotation + }, +); +``` + +### Nullable (Allow null) + +```ts +t.Nullable(t.String()); +// type: string | null +``` + +### MaybeEmpty (Allow null + undefined) + +```ts +t.MaybeEmpty(t.String()); +// type: string | null | undefined +``` + +### Form (FormData Validation) + +```ts +t.Form({ + someValue: t.File(), +}); +// Syntax sugar for t.Object with FormData support +``` + +### UInt8Array (Buffer → Uint8Array) + +```ts +t.UInt8Array(); +// For binary file uploads with arrayBuffer parser +``` + +### ArrayBuffer (Buffer → ArrayBuffer) + +```ts +t.ArrayBuffer(); +// For binary file uploads with arrayBuffer parser +``` + +### ObjectString (String → Object) + +```ts +t.ObjectString(); +// Accepts: '{"x":1}' → parses to { x: 1 } +// Use in: query string, headers, FormData +``` + +### BooleanString (String → Boolean) + +```ts +t.BooleanString(); +// Accepts: 'true'/'false' → parses to boolean +// Use in: query string, headers, FormData +``` + +### Numeric (String/Number → Number) + +```ts +t.Numeric(); +// Accepts: '123' or 123 → transforms to 123 +// Use in: path params, query string +``` + +## Elysia Behavior Differences from TypeBox + +### 1. Optional Behavior + +In Elysia, `t.Optional` makes **entire route parameter** optional (not object field): + +```ts +.get('/optional', ({ query }) => query, { + query: t.Optional( // makes query itself optional + t.Object({ name: t.String() }) + ) +}) +``` + +**Different from TypeBox**: TypeBox uses Optional for object fields only + +### 2. Number → Numeric Auto-Conversion + +**Route schema only** (not nested objects): + +```ts +.get('/:id', ({ id }) => id, { + params: t.Object({ + id: t.Number() // ✅ Auto-converts to t.Numeric() + }), + body: t.Object({ + id: t.Number() // ❌ NOT converted (stays t.Number()) + }) +}) + +// Outside route schema +t.Number() // ❌ NOT converted +``` + +**Why**: HTTP headers/query/params always strings. Auto-conversion parses numeric strings. + +### 3. Boolean → BooleanString Auto-Conversion + +Same as Number → Numeric: + +```ts +.get('/:active', ({ active }) => active, { + params: t.Object({ + active: t.Boolean() // ✅ Auto-converts to t.BooleanString() + }), + body: t.Object({ + active: t.Boolean() // ❌ NOT converted + }) +}) +``` + +## Usage Pattern + +```ts +import { Elysia, t } from "elysia"; + +new Elysia() + .post("/", ({ body }) => `Hello ${body}`, { + body: t.String(), // validates body is string + }) + .listen(3000); +``` + +**Validation flow**: + +1. Request arrives +2. Schema validates against HTTP body/params/query/headers +3. If valid → handler executes +4. If invalid → Error Life Cycle + +## Notes + +[Inference] Based on docs: + +- TypeBox mirrors TypeScript but adds runtime validation +- Elysia.t extends TypeBox with HTTP-specific types +- Auto-conversion (Number→Numeric, Boolean→BooleanString) only for route schemas +- Use `t.Optional` for optional route params (different from TypeBox behavior) +- File validation supports unit suffixes ('1k', '5m') +- ObjectString/BooleanString for parsing strings in query/headers +- Cookie supports key rotation with array of secrets diff --git a/.agents/skills/elysiajs/references/websocket.md b/.agents/skills/elysiajs/references/websocket.md new file mode 100644 index 0000000..3d75863 --- /dev/null +++ b/.agents/skills/elysiajs/references/websocket.md @@ -0,0 +1,245 @@ +# WebSocket + +## Basic WebSocket + +```typescript +import { Elysia } from "elysia"; + +new Elysia() + .ws("/chat", { + message(ws, message) { + ws.send(message); // Echo back + }, + }) + .listen(3000); +``` + +## With Validation + +```typescript +import { Elysia, t } from 'elysia' + +.ws('/chat', { + body: t.Object({ + message: t.String(), + username: t.String() + }), + response: t.Object({ + message: t.String(), + timestamp: t.Number() + }), + message(ws, body) { + ws.send({ + message: body.message, + timestamp: Date.now() + }) + } +}) +``` + +## Lifecycle Events + +```typescript +.ws('/chat', { + open(ws) { + console.log('Client connected') + }, + message(ws, message) { + console.log('Received:', message) + ws.send('Echo: ' + message) + }, + close(ws) { + console.log('Client disconnected') + }, + error(ws, error) { + console.error('Error:', error) + } +}) +``` + +## Broadcasting + +```typescript +const connections = new Set().ws("/chat", { + open(ws) { + connections.add(ws); + }, + message(ws, message) { + // Broadcast to all connected clients + for (const client of connections) { + client.send(message); + } + }, + close(ws) { + connections.delete(ws); + }, +}); +``` + +## With Authentication + +```typescript +.ws('/chat', { + beforeHandle({ headers, status }) { + const token = headers.authorization?.replace('Bearer ', '') + if (!verifyToken(token)) { + return status(401) + } + }, + message(ws, message) { + ws.send(message) + } +}) +``` + +## Room-Based Chat + +```typescript +const rooms = new Map>().ws("/chat/:room", { + open(ws) { + const room = ws.data.params.room; + if (!rooms.has(room)) { + rooms.set(room, new Set()); + } + rooms.get(room)!.add(ws); + }, + message(ws, message) { + const room = ws.data.params.room; + const clients = rooms.get(room); + + if (clients) { + for (const client of clients) { + client.send(message); + } + } + }, + close(ws) { + const room = ws.data.params.room; + const clients = rooms.get(room); + + if (clients) { + clients.delete(ws); + if (clients.size === 0) { + rooms.delete(room); + } + } + }, +}); +``` + +## With State/Context + +```typescript +.ws('/chat', { + open(ws) { + ws.data.userId = generateUserId() + ws.data.joinedAt = Date.now() + }, + message(ws, message) { + const response = { + userId: ws.data.userId, + message, + timestamp: Date.now() + } + ws.send(response) + } +}) +``` + +## Client Usage (Browser) + +```typescript +const ws = new WebSocket("ws://localhost:3000/chat"); + +ws.onopen = () => { + console.log("Connected"); + ws.send("Hello Server!"); +}; + +ws.onmessage = (event) => { + console.log("Received:", event.data); +}; + +ws.onerror = (error) => { + console.error("Error:", error); +}; + +ws.onclose = () => { + console.log("Disconnected"); +}; +``` + +## Eden Treaty WebSocket + +```typescript +// Client +import { treaty } from "@elysiajs/eden"; + +import type { App } from "./server"; + +// Server +export const app = new Elysia().ws("/chat", { + message(ws, message) { + ws.send(message); + }, +}); + +export type App = typeof app; + +const api = treaty("localhost:3000"); +const chat = api.chat.subscribe(); + +chat.subscribe((message) => { + console.log("Received:", message); +}); + +chat.send("Hello!"); +``` + +## Headers in WebSocket + +```typescript +.ws('/chat', { + header: t.Object({ + authorization: t.String() + }), + beforeHandle({ headers, status }) { + const token = headers.authorization?.replace('Bearer ', '') + if (!token) return status(401) + }, + message(ws, message) { + ws.send(message) + } +}) +``` + +## Query Parameters + +```typescript +.ws('/chat', { + query: t.Object({ + username: t.String() + }), + message(ws, message) { + const username = ws.data.query.username + ws.send(`${username}: ${message}`) + } +}) + +// Client +const ws = new WebSocket('ws://localhost:3000/chat?username=john') +``` + +## Compression + +```typescript +new Elysia({ + websocket: { + perMessageDeflate: true, + }, +}).ws("/chat", { + message(ws, message) { + ws.send(message); + }, +}); +``` diff --git a/.agents/skills/tanstack-router-best-practices/SKILL.md b/.agents/skills/tanstack-router-best-practices/SKILL.md new file mode 100644 index 0000000..191b692 --- /dev/null +++ b/.agents/skills/tanstack-router-best-practices/SKILL.md @@ -0,0 +1,114 @@ +--- +name: tanstack-router-best-practices +description: TanStack Router best practices for type-safe routing, data loading, search params, and navigation. Activate when building React applications with complex routing needs. +--- + +# TanStack Router Best Practices + +Comprehensive guidelines for implementing TanStack Router patterns in React applications. These rules optimize type safety, data loading, navigation, and code organization. + +## When to Apply + +- Setting up application routing +- Creating new routes and layouts +- Implementing search parameter handling +- Configuring data loaders +- Setting up code splitting +- Integrating with TanStack Query +- Refactoring navigation patterns + +## Rule Categories by Priority + +| Priority | Category | Rules | Impact | +| -------- | ------------------ | ------- | ----------------------------------------------- | +| CRITICAL | Type Safety | 4 rules | Prevents runtime errors and enables refactoring | +| CRITICAL | Route Organization | 5 rules | Ensures maintainable route structure | +| HIGH | Router Config | 1 rule | Global router defaults | +| HIGH | Data Loading | 6 rules | Optimizes data fetching and caching | +| HIGH | Search Params | 5 rules | Enables type-safe URL state | +| HIGH | Error Handling | 1 rule | Handles 404 and errors gracefully | +| MEDIUM | Navigation | 5 rules | Improves UX and accessibility | +| MEDIUM | Code Splitting | 3 rules | Reduces bundle size | +| MEDIUM | Preloading | 3 rules | Improves perceived performance | +| LOW | Route Context | 3 rules | Enables dependency injection | + +## Quick Reference + +### Type Safety (Prefix: `ts-`) + +- `ts-register-router` — Register router type for global inference +- `ts-use-from-param` — Use `from` parameter for type narrowing +- `ts-route-context-typing` — Type route context with createRootRouteWithContext +- `ts-query-options-loader` — Use queryOptions in loaders for type inference + +### Router Config (Prefix: `router-`) + +- `router-default-options` — Configure router defaults (scrollRestoration, defaultErrorComponent, etc.) + +### Route Organization (Prefix: `org-`) + +- `org-file-based-routing` — Prefer file-based routing for conventions +- `org-route-tree-structure` — Follow hierarchical route tree patterns +- `org-pathless-layouts` — Use pathless routes for shared layouts +- `org-index-routes` — Understand index vs layout routes +- `org-virtual-routes` — Understand virtual file routes + +### Data Loading (Prefix: `load-`) + +- `load-use-loaders` — Use route loaders for data fetching +- `load-loader-deps` — Define loaderDeps for cache control +- `load-ensure-query-data` — Use ensureQueryData with TanStack Query +- `load-deferred-data` — Split critical and non-critical data +- `load-error-handling` — Handle loader errors appropriately +- `load-parallel` — Leverage parallel route loading + +### Search Params (Prefix: `search-`) + +- `search-validation` — Always validate search params +- `search-type-inheritance` — Leverage parent search param types +- `search-middleware` — Use search param middleware +- `search-defaults` — Provide sensible defaults +- `search-custom-serializer` — Configure custom search param serializers + +### Error Handling (Prefix: `err-`) + +- `err-not-found` — Handle not-found routes properly + +### Navigation (Prefix: `nav-`) + +- `nav-link-component` — Prefer Link component for navigation +- `nav-active-states` — Configure active link states +- `nav-use-navigate` — Use useNavigate for programmatic navigation +- `nav-relative-paths` — Understand relative path navigation +- `nav-route-masks` — Use route masks for modal URLs + +### Code Splitting (Prefix: `split-`) + +- `split-lazy-routes` — Use .lazy.tsx for code splitting +- `split-critical-path` — Keep critical config in main route file +- `split-auto-splitting` — Enable autoCodeSplitting when possible + +### Preloading (Prefix: `preload-`) + +- `preload-intent` — Enable intent-based preloading +- `preload-stale-time` — Configure preload stale time +- `preload-manual` — Use manual preloading strategically + +### Route Context (Prefix: `ctx-`) + +- `ctx-root-context` — Define context at root route +- `ctx-before-load` — Extend context in beforeLoad +- `ctx-dependency-injection` — Use context for dependency injection + +## How to Use + +Each rule file in the `rules/` directory contains: + +1. **Explanation** — Why this pattern matters +2. **Bad Example** — Anti-pattern to avoid +3. **Good Example** — Recommended implementation +4. **Context** — When to apply or skip this rule + +## Full Reference + +See individual rule files in `rules/` directory for detailed guidance and code examples. diff --git a/.agents/skills/tanstack-router-best-practices/rules/ctx-root-context.md b/.agents/skills/tanstack-router-best-practices/rules/ctx-root-context.md new file mode 100644 index 0000000..aee5a84 --- /dev/null +++ b/.agents/skills/tanstack-router-best-practices/rules/ctx-root-context.md @@ -0,0 +1,168 @@ +# ctx-root-context: Define Context at Root Route + +## Priority: LOW + +## Explanation + +Use `createRootRouteWithContext` to define typed context that flows through your entire route tree. This enables dependency injection for things like query clients, auth state, and services. + +## Bad Example + +```tsx +// No context - importing globals directly +// routes/__root.tsx +// routes/posts.tsx +import { queryClient, queryClient } from "@/lib/query-client"; // Global import +import { createRootRoute } from "@tanstack/react-router"; + +export const Route = createRootRoute({ + component: RootComponent, +}); + +// Import again + +export const Route = createFileRoute("/posts")({ + loader: async () => { + // Using global - harder to test, couples to implementation + return queryClient.ensureQueryData(postQueries.list()); + }, +}); +``` + +## Good Example + +```tsx +// routes/__root.tsx +import { QueryClient, QueryClient } from "@tanstack/react-query"; +// router.tsx - Provide context when creating router +import { createRootRouteWithContext, createRouter, Outlet } from "@tanstack/react-router"; +import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; + +import { routeTree } from "./routeTree.gen"; + +// Define the context interface +interface RouterContext { + queryClient: QueryClient; + auth: { + user: User | null; + isAuthenticated: boolean; + }; +} + +export const Route = createRootRouteWithContext()({ + component: RootComponent, +}); + +function RootComponent() { + return ( + <> +
+
+ +
+