diff --git a/skills/ddd-implementation/SKILL.md b/.agent/skills/ddd-implementation/SKILL.md similarity index 100% rename from skills/ddd-implementation/SKILL.md rename to .agent/skills/ddd-implementation/SKILL.md diff --git a/.agent/skills/elysiajs/SKILL.md b/.agent/skills/elysiajs/SKILL.md new file mode 100644 index 0000000..d707a64 --- /dev/null +++ b/.agent/skills/elysiajs/SKILL.md @@ -0,0 +1,475 @@ +--- +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, t, status } 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/.agent/skills/elysiajs/examples/basic.ts b/.agent/skills/elysiajs/examples/basic.ts new file mode 100644 index 0000000..61c8d14 --- /dev/null +++ b/.agent/skills/elysiajs/examples/basic.ts @@ -0,0 +1,9 @@ +import { Elysia, t } from 'elysia' + +new Elysia() + .get('/', 'Hello Elysia') + .post('/', ({ body: { name } }) => name, { + body: t.Object({ + name: t.String() + }) + }) diff --git a/.agent/skills/elysiajs/examples/body-parser.ts b/.agent/skills/elysiajs/examples/body-parser.ts new file mode 100644 index 0000000..32e4053 --- /dev/null +++ b/.agent/skills/elysiajs/examples/body-parser.ts @@ -0,0 +1,34 @@ +import { Elysia, t } from 'elysia' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +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/.agent/skills/elysiajs/examples/complex.ts b/.agent/skills/elysiajs/examples/complex.ts new file mode 100644 index 0000000..1f86ede --- /dev/null +++ b/.agent/skills/elysiajs/examples/complex.ts @@ -0,0 +1,114 @@ +import { Elysia, t, file } 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')) + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +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() + }) + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .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/.agent/skills/elysiajs/examples/cookie.ts b/.agent/skills/elysiajs/examples/cookie.ts new file mode 100644 index 0000000..504bb54 --- /dev/null +++ b/.agent/skills/elysiajs/examples/cookie.ts @@ -0,0 +1,46 @@ +import { Elysia, t } from 'elysia' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +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/.agent/skills/elysiajs/examples/error.ts b/.agent/skills/elysiajs/examples/error.ts new file mode 100644 index 0000000..2c2f126 --- /dev/null +++ b/.agent/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/.agent/skills/elysiajs/examples/file.ts b/.agent/skills/elysiajs/examples/file.ts new file mode 100644 index 0000000..504cad7 --- /dev/null +++ b/.agent/skills/elysiajs/examples/file.ts @@ -0,0 +1,10 @@ +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/.agent/skills/elysiajs/examples/guard.ts b/.agent/skills/elysiajs/examples/guard.ts new file mode 100644 index 0000000..48fe58a --- /dev/null +++ b/.agent/skills/elysiajs/examples/guard.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +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/.agent/skills/elysiajs/examples/map-response.ts b/.agent/skills/elysiajs/examples/map-response.ts new file mode 100644 index 0000000..8cd4be4 --- /dev/null +++ b/.agent/skills/elysiajs/examples/map-response.ts @@ -0,0 +1,15 @@ +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/.agent/skills/elysiajs/examples/redirect.ts b/.agent/skills/elysiajs/examples/redirect.ts new file mode 100644 index 0000000..28171b0 --- /dev/null +++ b/.agent/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/.agent/skills/elysiajs/examples/rename.ts b/.agent/skills/elysiajs/examples/rename.ts new file mode 100644 index 0000000..41c0a8a --- /dev/null +++ b/.agent/skills/elysiajs/examples/rename.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +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/.agent/skills/elysiajs/examples/schema.ts b/.agent/skills/elysiajs/examples/schema.ts new file mode 100644 index 0000000..3297924 --- /dev/null +++ b/.agent/skills/elysiajs/examples/schema.ts @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +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/.agent/skills/elysiajs/examples/state.ts b/.agent/skills/elysiajs/examples/state.ts new file mode 100644 index 0000000..8bcc993 --- /dev/null +++ b/.agent/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/.agent/skills/elysiajs/examples/upload-file.ts b/.agent/skills/elysiajs/examples/upload-file.ts new file mode 100644 index 0000000..15d5fe4 --- /dev/null +++ b/.agent/skills/elysiajs/examples/upload-file.ts @@ -0,0 +1,21 @@ +import { Elysia, t } from 'elysia' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +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/.agent/skills/elysiajs/examples/websocket.ts b/.agent/skills/elysiajs/examples/websocket.ts new file mode 100644 index 0000000..f97e47b --- /dev/null +++ b/.agent/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/.agent/skills/elysiajs/integrations/ai-sdk.md b/.agent/skills/elysiajs/integrations/ai-sdk.md new file mode 100644 index 0000000..99f5409 --- /dev/null +++ b/.agent/skills/elysiajs/integrations/ai-sdk.md @@ -0,0 +1,92 @@ +# AI SDK Integration + +## What It Is +Seamless integration with Vercel AI SDK via response streaming. + +## Response Streaming +Return `ReadableStream` or `Response` directly: +```typescript +import { streamText } from 'ai' +import { openai } from '@ai-sdk/openai' + +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/.agent/skills/elysiajs/integrations/astro.md b/.agent/skills/elysiajs/integrations/astro.md new file mode 100644 index 0000000..41cd451 --- /dev/null +++ b/.agent/skills/elysiajs/integrations/astro.md @@ -0,0 +1,59 @@ +# 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/.agent/skills/elysiajs/integrations/better-auth.md b/.agent/skills/elysiajs/integrations/better-auth.md new file mode 100644 index 0000000..0dfa3af --- /dev/null +++ b/.agent/skills/elysiajs/integrations/better-auth.md @@ -0,0 +1,117 @@ +# 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/.agent/skills/elysiajs/integrations/cloudflare-worker.md b/.agent/skills/elysiajs/integrations/cloudflare-worker.md new file mode 100644 index 0000000..4245c1a --- /dev/null +++ b/.agent/skills/elysiajs/integrations/cloudflare-worker.md @@ -0,0 +1,95 @@ + +# 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/.agent/skills/elysiajs/integrations/deno.md b/.agent/skills/elysiajs/integrations/deno.md new file mode 100644 index 0000000..28687d5 --- /dev/null +++ b/.agent/skills/elysiajs/integrations/deno.md @@ -0,0 +1,34 @@ +# 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/.agent/skills/elysiajs/integrations/drizzle.md b/.agent/skills/elysiajs/integrations/drizzle.md new file mode 100644 index 0000000..779db4e --- /dev/null +++ b/.agent/skills/elysiajs/integrations/drizzle.md @@ -0,0 +1,258 @@ +# 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 { pgTable, varchar, timestamp } from 'drizzle-orm/pg-core' +import { createId } from '@paralleldrive/cuid2' + +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 { t } from 'elysia' +import { createInsertSchema } from 'drizzle-typebox' +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 +// src/database/utils.ts +/** + * @lastModified 2025-02-04 + * @see https://elysiajs.com/recipe/drizzle.html#utility + */ + +import { Kind, type TObject } from '@sinclair/typebox' +import { + createInsertSchema, + createSelectSchema, + BuildSchema, +} from 'drizzle-typebox' + +import { table } from './schema' +import type { Table } from 'drizzle-orm' + +type Spread< + T extends TObject | Table, + Mode extends 'select' | 'insert' | undefined, +> = + 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 = < + T extends TObject | Table, + Mode extends 'select' | 'insert' | undefined, +>( + 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 = < + T extends Record, + 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/.agent/skills/elysiajs/integrations/expo.md b/.agent/skills/elysiajs/integrations/expo.md new file mode 100644 index 0000000..fad1471 --- /dev/null +++ b/.agent/skills/elysiajs/integrations/expo.md @@ -0,0 +1,95 @@ +# 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/.agent/skills/elysiajs/integrations/nextjs.md b/.agent/skills/elysiajs/integrations/nextjs.md new file mode 100644 index 0000000..ddbc849 --- /dev/null +++ b/.agent/skills/elysiajs/integrations/nextjs.md @@ -0,0 +1,103 @@ + +# 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/.agent/skills/elysiajs/integrations/nodejs.md b/.agent/skills/elysiajs/integrations/nodejs.md new file mode 100644 index 0000000..ce2edfa --- /dev/null +++ b/.agent/skills/elysiajs/integrations/nodejs.md @@ -0,0 +1,64 @@ +# 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 { Elysia } from 'elysia' +import { node } from '@elysiajs/node' + +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/.agent/skills/elysiajs/integrations/nuxt.md b/.agent/skills/elysiajs/integrations/nuxt.md new file mode 100644 index 0000000..0b4d13d --- /dev/null +++ b/.agent/skills/elysiajs/integrations/nuxt.md @@ -0,0 +1,67 @@ +# 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/.agent/skills/elysiajs/integrations/prisma.md b/.agent/skills/elysiajs/integrations/prisma.md new file mode 100644 index 0000000..f0684f1 --- /dev/null +++ b/.agent/skills/elysiajs/integrations/prisma.md @@ -0,0 +1,93 @@ + +# 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/.agent/skills/elysiajs/integrations/react-email.md b/.agent/skills/elysiajs/integrations/react-email.md new file mode 100644 index 0000000..1cb636f --- /dev/null +++ b/.agent/skills/elysiajs/integrations/react-email.md @@ -0,0 +1,134 @@ +# 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 { Tailwind, Section, 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/.agent/skills/elysiajs/integrations/sveltekit.md b/.agent/skills/elysiajs/integrations/sveltekit.md new file mode 100644 index 0000000..4ad306a --- /dev/null +++ b/.agent/skills/elysiajs/integrations/sveltekit.md @@ -0,0 +1,53 @@ + +# 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/.agent/skills/elysiajs/integrations/tanstack-start.md b/.agent/skills/elysiajs/integrations/tanstack-start.md new file mode 100644 index 0000000..2a1e642 --- /dev/null +++ b/.agent/skills/elysiajs/integrations/tanstack-start.md @@ -0,0 +1,87 @@ +# 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 { Elysia } from 'elysia' +import { createFileRoute } from '@tanstack/react-router' +import { createIsomorphicFn } from '@tanstack/react-start' + +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/.agent/skills/elysiajs/integrations/vercel.md b/.agent/skills/elysiajs/integrations/vercel.md new file mode 100644 index 0000000..555ec8e --- /dev/null +++ b/.agent/skills/elysiajs/integrations/vercel.md @@ -0,0 +1,55 @@ +# 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/.agent/skills/elysiajs/patterns/mvc.md b/.agent/skills/elysiajs/patterns/mvc.md new file mode 100644 index 0000000..dc227c6 --- /dev/null +++ b/.agent/skills/elysiajs/patterns/mvc.md @@ -0,0 +1,380 @@ +# 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 +// Don't +import { Elysia, t, type Context } from 'elysia' + +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 { Elysia } from 'elysia' +import { Service } from './service' + +import { describe, it, expect } from 'bun:test' + +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/.agent/skills/elysiajs/plugins/bearer.md b/.agent/skills/elysiajs/plugins/bearer.md new file mode 100644 index 0000000..df529e5 --- /dev/null +++ b/.agent/skills/elysiajs/plugins/bearer.md @@ -0,0 +1,30 @@ +# Bearer +Plugin for Elysia for retrieving the Bearer token. + +## Installation +```bash +bun add @elysiajs/bearer +``` + +## Basic Usage +```typescript twoslash +import { Elysia } from 'elysia' +import { bearer } from '@elysiajs/bearer' + +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/.agent/skills/elysiajs/plugins/cors.md b/.agent/skills/elysiajs/plugins/cors.md new file mode 100644 index 0000000..2d8db2a --- /dev/null +++ b/.agent/skills/elysiajs/plugins/cors.md @@ -0,0 +1,141 @@ +# 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 { Elysia } from 'elysia' +import { cors } from '@elysiajs/cors' + +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 { Elysia } from 'elysia' +import { cors } from '@elysiajs/cors' + +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/.agent/skills/elysiajs/plugins/cron.md b/.agent/skills/elysiajs/plugins/cron.md new file mode 100644 index 0000000..3905ad5 --- /dev/null +++ b/.agent/skills/elysiajs/plugins/cron.md @@ -0,0 +1,265 @@ +# Cron Plugin + +This plugin adds support for running cronjob to Elysia server. + +## Installation + +```bash +bun add @elysiajs/cron +``` + +## Basic Usage +```typescript twoslash +import { Elysia } from 'elysia' +import { cron } from '@elysiajs/cron' + +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 { Elysia } from 'elysia' +import { cron } from '@elysiajs/cron' + +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 { Elysia } from 'elysia' +import { cron, Patterns } from '@elysiajs/cron' + +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/.agent/skills/elysiajs/plugins/graphql-apollo.md b/.agent/skills/elysiajs/plugins/graphql-apollo.md new file mode 100644 index 0000000..655f258 --- /dev/null +++ b/.agent/skills/elysiajs/plugins/graphql-apollo.md @@ -0,0 +1,90 @@ +# GraphQL Apollo + +Plugin for Elysia to use GraphQL Apollo. + +## Installation +```bash +bun add graphql @elysiajs/apollo @apollo/server +``` + +## Basic Usage + +```typescript +import { Elysia } from 'elysia' +import { apollo, gql } from '@elysiajs/apollo' + +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/.agent/skills/elysiajs/plugins/graphql-yoga.md b/.agent/skills/elysiajs/plugins/graphql-yoga.md new file mode 100644 index 0000000..3203d02 --- /dev/null +++ b/.agent/skills/elysiajs/plugins/graphql-yoga.md @@ -0,0 +1,87 @@ +# GraphQL Yoga + +This plugin integrates GraphQL yoga with Elysia + +## Installation +```bash +bun add @elysiajs/graphql-yoga +``` + +## Basic Usage +```typescript +import { Elysia } from 'elysia' +import { yoga } from '@elysiajs/graphql-yoga' + +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 { Elysia } from 'elysia' +import { yoga } from '@elysiajs/graphql-yoga' + +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/.agent/skills/elysiajs/plugins/html.md b/.agent/skills/elysiajs/plugins/html.md new file mode 100644 index 0000000..777a536 --- /dev/null +++ b/.agent/skills/elysiajs/plugins/html.md @@ -0,0 +1,188 @@ +# 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 { Elysia } from 'elysia' +import { html, Html } from '@elysiajs/html' + +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 { Elysia } from 'elysia' +import { html, Html } from '@elysiajs/html' + +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 { Elysia, t } from 'elysia' +import { html, Html } from '@elysiajs/html' + +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/.agent/skills/elysiajs/plugins/jwt.md b/.agent/skills/elysiajs/plugins/jwt.md new file mode 100644 index 0000000..b5767bf --- /dev/null +++ b/.agent/skills/elysiajs/plugins/jwt.md @@ -0,0 +1,197 @@ +# JWT Plugin +This plugin adds support for using JWT in Elysia handlers. + +## Installation +```bash +bun add @elysiajs/jwt +``` + +## Basic Usage +```typescript [cookie] +import { Elysia } from 'elysia' +import { jwt } from '@elysiajs/jwt' + +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/.agent/skills/elysiajs/plugins/openapi.md b/.agent/skills/elysiajs/plugins/openapi.md new file mode 100644 index 0000000..c69150d --- /dev/null +++ b/.agent/skills/elysiajs/plugins/openapi.md @@ -0,0 +1,246 @@ +# 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/.agent/skills/elysiajs/plugins/opentelemetry.md b/.agent/skills/elysiajs/plugins/opentelemetry.md new file mode 100644 index 0000000..0ca95c3 --- /dev/null +++ b/.agent/skills/elysiajs/plugins/opentelemetry.md @@ -0,0 +1,167 @@ +# OpenTelemetry Plugin - SKILLS.md + +## Installation +```bash +bun add @elysiajs/opentelemetry +``` + +## Basic Usage +```typescript +import { opentelemetry } from '@elysiajs/opentelemetry' +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' + +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/.agent/skills/elysiajs/plugins/server-timing.md b/.agent/skills/elysiajs/plugins/server-timing.md new file mode 100644 index 0000000..0021424 --- /dev/null +++ b/.agent/skills/elysiajs/plugins/server-timing.md @@ -0,0 +1,71 @@ +# 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 { Elysia } from 'elysia' +import { serverTiming } from '@elysiajs/server-timing' + +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 { Elysia } from 'elysia' +import { serverTiming } from '@elysiajs/server-timing' + +new Elysia() + .use( + serverTiming({ + allow: ({ request }) => { + return new URL(request.url).pathname !== '/no-trace' + } + }) + ) +``` diff --git a/.agent/skills/elysiajs/plugins/static.md b/.agent/skills/elysiajs/plugins/static.md new file mode 100644 index 0000000..82fa1da --- /dev/null +++ b/.agent/skills/elysiajs/plugins/static.md @@ -0,0 +1,84 @@ +# Static Plugin +This plugin can serve static files/folders for Elysia Server + +## Installation +```bash +bun add @elysiajs/static +``` + +## Basic Usage +```typescript twoslash +import { Elysia } from 'elysia' +import { staticPlugin } from '@elysiajs/static' + +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/.agent/skills/elysiajs/references/bun-fullstack-dev-server.md b/.agent/skills/elysiajs/references/bun-fullstack-dev-server.md new file mode 100644 index 0000000..70d721b --- /dev/null +++ b/.agent/skills/elysiajs/references/bun-fullstack-dev-server.md @@ -0,0 +1,129 @@ +# 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 { Elysia } from 'elysia' +import { staticPlugin } from '@elysiajs/static' + +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/.agent/skills/elysiajs/references/cookie.md b/.agent/skills/elysiajs/references/cookie.md new file mode 100644 index 0000000..9e1aa1c --- /dev/null +++ b/.agent/skills/elysiajs/references/cookie.md @@ -0,0 +1,187 @@ +# 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/.agent/skills/elysiajs/references/deployment.md b/.agent/skills/elysiajs/references/deployment.md new file mode 100644 index 0000000..3c4cca8 --- /dev/null +++ b/.agent/skills/elysiajs/references/deployment.md @@ -0,0 +1,413 @@ +# 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 { Elysia } from 'elysia' +import { node } from '@elysiajs/node' + +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 { Elysia } from 'elysia' +import { cors } from '@elysiajs/cors' +import { opentelemetry } from '@elysiajs/opentelemetry' + +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/.agent/skills/elysiajs/references/eden.md b/.agent/skills/elysiajs/references/eden.md new file mode 100644 index 0000000..7d9165d --- /dev/null +++ b/.agent/skills/elysiajs/references/eden.md @@ -0,0 +1,158 @@ +# 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/.agent/skills/elysiajs/references/lifecycle.md b/.agent/skills/elysiajs/references/lifecycle.md new file mode 100644 index 0000000..645584e --- /dev/null +++ b/.agent/skills/elysiajs/references/lifecycle.md @@ -0,0 +1,198 @@ +# 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/.agent/skills/elysiajs/references/macro.md b/.agent/skills/elysiajs/references/macro.md new file mode 100644 index 0000000..f89ee75 --- /dev/null +++ b/.agent/skills/elysiajs/references/macro.md @@ -0,0 +1,83 @@ +# 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/.agent/skills/elysiajs/references/plugin.md b/.agent/skills/elysiajs/references/plugin.md new file mode 100644 index 0000000..cd10e64 --- /dev/null +++ b/.agent/skills/elysiajs/references/plugin.md @@ -0,0 +1,207 @@ +# 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/.agent/skills/elysiajs/references/route.md b/.agent/skills/elysiajs/references/route.md new file mode 100644 index 0000000..c767283 --- /dev/null +++ b/.agent/skills/elysiajs/references/route.md @@ -0,0 +1,331 @@ +# 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 { redirect, form } 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/.agent/skills/elysiajs/references/testing.md b/.agent/skills/elysiajs/references/testing.md new file mode 100644 index 0000000..ffcdff3 --- /dev/null +++ b/.agent/skills/elysiajs/references/testing.md @@ -0,0 +1,385 @@ +# 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 +// app.ts +export const app = new Elysia() + .decorate('db', realDatabase) + .get('/users', ({ db }) => db.getUsers()) + +// test +import { app } from '../src/app' + +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 { 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) + } + return { username: 'admin' } + }) + +// test/auth.test.ts +import { describe, expect, it } from 'bun:test' +import { treaty } from '@elysiajs/eden' +import { authModule } from '../src/modules/auth' + +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/.agent/skills/elysiajs/references/validation.md b/.agent/skills/elysiajs/references/validation.md new file mode 100644 index 0000000..ba723e0 --- /dev/null +++ b/.agent/skills/elysiajs/references/validation.md @@ -0,0 +1,491 @@ +# 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/.agent/skills/elysiajs/references/websocket.md b/.agent/skills/elysiajs/references/websocket.md new file mode 100644 index 0000000..b2c86a8 --- /dev/null +++ b/.agent/skills/elysiajs/references/websocket.md @@ -0,0 +1,250 @@ +# 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 +// Server +export const app = new Elysia() + .ws('/chat', { + message(ws, message) { + ws.send(message) + } + }) + +export type App = typeof app + +// Client +import { treaty } from '@elysiajs/eden' +import type { App } from './server' + +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/skills/evolution-server/SKILL.md b/.agent/skills/evolution-server/SKILL.md similarity index 100% rename from skills/evolution-server/SKILL.md rename to .agent/skills/evolution-server/SKILL.md diff --git a/skills/hexagonal-architecture/SKILL.md b/.agent/skills/hexagonal-architecture/SKILL.md similarity index 100% rename from skills/hexagonal-architecture/SKILL.md rename to .agent/skills/hexagonal-architecture/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index 79046da..e8eccdd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ - Start here for project norms and development patterns. - This project is the backend API (`evolution-api`) for the Evolution Yu-Gi-Oh! platform. -- Consult specific skills in the `skills/` directory for detailed implementation guides. +- Consult specific skills in the `.agent/skills/` directory for detailed implementation guides. ## Available Skills @@ -13,13 +13,14 @@ Use these skills for detailed patterns on-demand: ### Generic Skills | Skill | Description | URL | |-------|-------------|-----| -| `ddd-implementation` | Patterns for Entities, Value Objects, Aggregates, and Domain Services in TypeScript | [SKILL.md](skills/ddd-implementation/SKILL.md) | -| `hexagonal-architecture` | Guide for Ports and Adapters, layer responsibilities, and dependency rules | [SKILL.md](skills/hexagonal-architecture/SKILL.md) | +| `ddd-implementation` | Patterns for Entities, Value Objects, Aggregates, and Domain Services in TypeScript | [SKILL.md](.agent/skills/ddd-implementation/SKILL.md) | +| `hexagonal-architecture` | Guide for Ports and Adapters, layer responsibilities, and dependency rules | [SKILL.md](.agent/skills/hexagonal-architecture/SKILL.md) | +| `elysiajs` | ElysiaJS framework patterns, plugins, and integrations | [SKILL.md](.agent/skills/elysiajs/SKILL.md) | ### Project-Specific Skills | Skill | Description | URL | |-------|-------------|-----| -| `evolution-server` | Reference to EDOpro-server-ts for stats calculation and game logic | [SKILL.md](skills/evolution-server/SKILL.md) | +| `evolution-server` | Reference to EDOpro-server-ts for stats calculation and game logic | [SKILL.md](.agent/skills/evolution-server/SKILL.md) | ### Auto-invoke Skills diff --git a/src/evolution-types b/src/evolution-types index 6e7aa7f..bcfd727 160000 --- a/src/evolution-types +++ b/src/evolution-types @@ -1 +1 @@ -Subproject commit 6e7aa7f98cd0a303a97e1f66434bdc79a13c4890 +Subproject commit bcfd727f64ef18f2208f360504d709c0caa1bf06