feat: add bun-fullstack agent and update skills
This commit is contained in:
@@ -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
|
||||
<!-- public/index.html -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Elysia React App</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
```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 (
|
||||
<main>
|
||||
<h2>{count}</h2>
|
||||
<button onClick={increase}>Increase</button>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById('root')!)
|
||||
root.render(<App />)
|
||||
```
|
||||
|
||||
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
|
||||
<link rel="stylesheet" href="tailwindcss">
|
||||
```
|
||||
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.
|
||||
187
.opencode/skills/tech-stack/elysiajs/references/cookie.md
Normal file
187
.opencode/skills/tech-stack/elysiajs/references/cookie.md
Normal file
@@ -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<unknown>`, 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.
|
||||
413
.opencode/skills/tech-stack/elysiajs/references/deployment.md
Normal file
413
.opencode/skills/tech-stack/elysiajs/references/deployment.md
Normal file
@@ -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
|
||||
```
|
||||
158
.opencode/skills/tech-stack/elysiajs/references/eden.md
Normal file
158
.opencode/skills/tech-stack/elysiajs/references/eden.md
Normal file
@@ -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<App>('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<typeof api.user.post>
|
||||
type UserError = Treaty.Error<typeof api.user.post>
|
||||
```
|
||||
|
||||
## 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`
|
||||
198
.opencode/skills/tech-stack/elysiajs/references/lifecycle.md
Normal file
198
.opencode/skills/tech-stack/elysiajs/references/lifecycle.md
Normal file
@@ -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('/', () => '<h1>Hello</h1>', {
|
||||
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.
|
||||
83
.opencode/skills/tech-stack/elysiajs/references/macro.md
Normal file
83
.opencode/skills/tech-stack/elysiajs/references/macro.md
Normal file
@@ -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)
|
||||
207
.opencode/skills/tech-stack/elysiajs/references/plugin.md
Normal file
207
.opencode/skills/tech-stack/elysiajs/references/plugin.md
Normal file
@@ -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)
|
||||
331
.opencode/skills/tech-stack/elysiajs/references/route.md
Normal file
331
.opencode/skills/tech-stack/elysiajs/references/route.md
Normal file
@@ -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
|
||||
385
.opencode/skills/tech-stack/elysiajs/references/testing.md
Normal file
385
.opencode/skills/tech-stack/elysiajs/references/testing.md
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
```
|
||||
491
.opencode/skills/tech-stack/elysiajs/references/validation.md
Normal file
491
.opencode/skills/tech-stack/elysiajs/references/validation.md
Normal file
@@ -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
|
||||
250
.opencode/skills/tech-stack/elysiajs/references/websocket.md
Normal file
250
.opencode/skills/tech-stack/elysiajs/references/websocket.md
Normal file
@@ -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<any>()
|
||||
|
||||
.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<string, Set<any>>()
|
||||
|
||||
.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<App>('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)
|
||||
}
|
||||
})
|
||||
```
|
||||
Reference in New Issue
Block a user