skills
This commit is contained in:
175
.agents/skills/better-auth-best-practices/SKILL.md
Normal file
175
.agents/skills/better-auth-best-practices/SKILL.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
name: better-auth-best-practices
|
||||||
|
description: Configure Better Auth server and client, set up database adapters, manage sessions, add plugins, and handle environment variables. Use when users mention Better Auth, betterauth, auth.ts, or need to set up TypeScript authentication with email/password, OAuth, or plugin configuration.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Better Auth Integration Guide
|
||||||
|
|
||||||
|
**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup Workflow
|
||||||
|
|
||||||
|
1. Install: `npm install better-auth`
|
||||||
|
2. Set env vars: `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL`
|
||||||
|
3. Create `auth.ts` with database + config
|
||||||
|
4. Create route handler for your framework
|
||||||
|
5. Run `npx @better-auth/cli@latest migrate`
|
||||||
|
6. Verify: call `GET /api/auth/ok` — should return `{ status: "ok" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32`
|
||||||
|
- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`)
|
||||||
|
|
||||||
|
Only define `baseURL`/`secret` in config if env vars are NOT set.
|
||||||
|
|
||||||
|
### File Location
|
||||||
|
CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path.
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
- `npx @better-auth/cli@latest migrate` - Apply schema (built-in adapter)
|
||||||
|
- `npx @better-auth/cli@latest generate` - Generate schema for Prisma/Drizzle
|
||||||
|
- `npx @better-auth/cli mcp --cursor` - Add MCP to AI tools
|
||||||
|
|
||||||
|
**Re-run after adding/changing plugins.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Config Options
|
||||||
|
|
||||||
|
| Option | Notes |
|
||||||
|
|--------|-------|
|
||||||
|
| `appName` | Optional display name |
|
||||||
|
| `baseURL` | Only if `BETTER_AUTH_URL` not set |
|
||||||
|
| `basePath` | Default `/api/auth`. Set `/` for root. |
|
||||||
|
| `secret` | Only if `BETTER_AUTH_SECRET` not set |
|
||||||
|
| `database` | Required for most features. See adapters docs. |
|
||||||
|
| `secondaryStorage` | Redis/KV for sessions & rate limits |
|
||||||
|
| `emailAndPassword` | `{ enabled: true }` to activate |
|
||||||
|
| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` |
|
||||||
|
| `plugins` | Array of plugins |
|
||||||
|
| `trustedOrigins` | CSRF whitelist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance.
|
||||||
|
|
||||||
|
**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`.
|
||||||
|
|
||||||
|
**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
**Storage priority:**
|
||||||
|
1. If `secondaryStorage` defined → sessions go there (not DB)
|
||||||
|
2. Set `session.storeSessionInDatabase: true` to also persist to DB
|
||||||
|
3. No database + `cookieCache` → fully stateless mode
|
||||||
|
|
||||||
|
**Cookie cache strategies:**
|
||||||
|
- `compact` (default) - Base64url + HMAC. Smallest.
|
||||||
|
- `jwt` - Standard JWT. Readable but signed.
|
||||||
|
- `jwe` - Encrypted. Maximum security.
|
||||||
|
|
||||||
|
**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User & Account Config
|
||||||
|
|
||||||
|
**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default).
|
||||||
|
|
||||||
|
**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth).
|
||||||
|
|
||||||
|
**Required for registration:** `email` and `name` fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Flows
|
||||||
|
|
||||||
|
- `emailVerification.sendVerificationEmail` - Must be defined for verification to work
|
||||||
|
- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers
|
||||||
|
- `emailAndPassword.sendResetPassword` - Password reset email handler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
**In `advanced`:**
|
||||||
|
- `useSecureCookies` - Force HTTPS cookies
|
||||||
|
- `disableCSRFCheck` - ⚠️ Security risk
|
||||||
|
- `disableOriginCheck` - ⚠️ Security risk
|
||||||
|
- `crossSubDomainCookies.enabled` - Share cookies across subdomains
|
||||||
|
- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies
|
||||||
|
- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false`
|
||||||
|
|
||||||
|
**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`.
|
||||||
|
|
||||||
|
**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions.
|
||||||
|
|
||||||
|
**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
**Import from dedicated paths for tree-shaking:**
|
||||||
|
```
|
||||||
|
import { twoFactor } from "better-auth/plugins/two-factor"
|
||||||
|
```
|
||||||
|
NOT `from "better-auth/plugins"`.
|
||||||
|
|
||||||
|
**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`.
|
||||||
|
|
||||||
|
Client plugins go in `createAuthClient({ plugins: [...] })`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`.
|
||||||
|
|
||||||
|
Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Safety
|
||||||
|
|
||||||
|
Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`.
|
||||||
|
|
||||||
|
For separate client/server projects: `createAuthClient<typeof auth>()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
|
||||||
|
1. **Model vs table name** - Config uses ORM model name, not DB table name
|
||||||
|
2. **Plugin schema** - Re-run CLI after adding plugins
|
||||||
|
3. **Secondary storage** - Sessions go there by default, not DB
|
||||||
|
4. **Cookie cache** - Custom session fields NOT cached, always re-fetched
|
||||||
|
5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry
|
||||||
|
6. **Change email flow** - Sends to current email first, then new email
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Docs](https://better-auth.com/docs)
|
||||||
|
- [Options Reference](https://better-auth.com/docs/reference/options)
|
||||||
|
- [LLMs.txt](https://better-auth.com/llms.txt)
|
||||||
|
- [GitHub](https://github.com/better-auth/better-auth)
|
||||||
|
- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts)
|
||||||
696
.agents/skills/bun-development/SKILL.md
Normal file
696
.agents/skills/bun-development/SKILL.md
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
---
|
||||||
|
name: bun-development
|
||||||
|
description: "Fast, modern JavaScript/TypeScript development with the Bun runtime, inspired by [oven-sh/bun](https://github.com/oven-sh/bun)."
|
||||||
|
risk: critical
|
||||||
|
source: community
|
||||||
|
date_added: "2026-02-27"
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- security-allowlist: curl-pipe-bash, irm-pipe-iex -->
|
||||||
|
|
||||||
|
# ⚡ Bun Development
|
||||||
|
|
||||||
|
> Fast, modern JavaScript/TypeScript development with the Bun runtime, inspired by [oven-sh/bun](https://github.com/oven-sh/bun).
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when:
|
||||||
|
|
||||||
|
- Starting new JS/TS projects with Bun
|
||||||
|
- Migrating from Node.js to Bun
|
||||||
|
- Optimizing development speed
|
||||||
|
- Using Bun's built-in tools (bundler, test runner)
|
||||||
|
- Troubleshooting Bun-specific issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Getting Started
|
||||||
|
|
||||||
|
### 1.1 Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS / Linux
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||||
|
|
||||||
|
# Homebrew
|
||||||
|
brew tap oven-sh/bun
|
||||||
|
brew install bun
|
||||||
|
|
||||||
|
# npm (if needed)
|
||||||
|
npm install -g bun
|
||||||
|
|
||||||
|
# Upgrade
|
||||||
|
bun upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Why Bun?
|
||||||
|
|
||||||
|
| Feature | Bun | Node.js |
|
||||||
|
| :-------------- | :------------- | :-------------------------- |
|
||||||
|
| Startup time | ~25ms | ~100ms+ |
|
||||||
|
| Package install | 10-100x faster | Baseline |
|
||||||
|
| TypeScript | Native | Requires transpiler |
|
||||||
|
| JSX | Native | Requires transpiler |
|
||||||
|
| Test runner | Built-in | External (Jest, Vitest) |
|
||||||
|
| Bundler | Built-in | External (Webpack, esbuild) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Project Setup
|
||||||
|
|
||||||
|
### 2.1 Create New Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize project
|
||||||
|
bun init
|
||||||
|
|
||||||
|
# Creates:
|
||||||
|
# ├── package.json
|
||||||
|
# ├── tsconfig.json
|
||||||
|
# ├── index.ts
|
||||||
|
# └── README.md
|
||||||
|
|
||||||
|
# With specific template
|
||||||
|
bun create <template> <project-name>
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
bun create react my-app # React app
|
||||||
|
bun create next my-app # Next.js app
|
||||||
|
bun create vite my-app # Vite app
|
||||||
|
bun create elysia my-api # Elysia API
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-bun-project",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --watch index.ts",
|
||||||
|
"start": "bun run index.ts",
|
||||||
|
"test": "bun test",
|
||||||
|
"build": "bun build ./index.ts --outdir ./dist",
|
||||||
|
"lint": "bunx eslint ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 tsconfig.json (Bun-optimized)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"composite": true,
|
||||||
|
"strict": true,
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Package Management
|
||||||
|
|
||||||
|
### 3.1 Installing Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install from package.json
|
||||||
|
bun install # or 'bun i'
|
||||||
|
|
||||||
|
# Add dependencies
|
||||||
|
bun add express # Regular dependency
|
||||||
|
bun add -d typescript # Dev dependency
|
||||||
|
bun add -D @types/node # Dev dependency (alias)
|
||||||
|
bun add --optional pkg # Optional dependency
|
||||||
|
|
||||||
|
# From specific registry
|
||||||
|
bun add lodash --registry https://registry.npmmirror.com
|
||||||
|
|
||||||
|
# Install specific version
|
||||||
|
bun add react@18.2.0
|
||||||
|
bun add react@latest
|
||||||
|
bun add react@next
|
||||||
|
|
||||||
|
# From git
|
||||||
|
bun add github:user/repo
|
||||||
|
bun add git+https://github.com/user/repo.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Removing & Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove package
|
||||||
|
bun remove lodash
|
||||||
|
|
||||||
|
# Update packages
|
||||||
|
bun update # Update all
|
||||||
|
bun update lodash # Update specific
|
||||||
|
bun update --latest # Update to latest (ignore ranges)
|
||||||
|
|
||||||
|
# Check outdated
|
||||||
|
bun outdated
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 bunx (npx equivalent)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Execute package binaries
|
||||||
|
bunx prettier --write .
|
||||||
|
bunx tsc --init
|
||||||
|
bunx create-react-app my-app
|
||||||
|
|
||||||
|
# With specific version
|
||||||
|
bunx -p typescript@4.9 tsc --version
|
||||||
|
|
||||||
|
# Run without installing
|
||||||
|
bunx cowsay "Hello from Bun!"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Lockfile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bun.lockb is a binary lockfile (faster parsing)
|
||||||
|
# To generate text lockfile for debugging:
|
||||||
|
bun install --yarn # Creates yarn.lock
|
||||||
|
|
||||||
|
# Trust existing lockfile
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Running Code
|
||||||
|
|
||||||
|
### 4.1 Basic Execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run TypeScript directly (no build step!)
|
||||||
|
bun run index.ts
|
||||||
|
|
||||||
|
# Run JavaScript
|
||||||
|
bun run index.js
|
||||||
|
|
||||||
|
# Run with arguments
|
||||||
|
bun run server.ts --port 3000
|
||||||
|
|
||||||
|
# Run package.json script
|
||||||
|
bun run dev
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Short form (for scripts)
|
||||||
|
bun dev
|
||||||
|
bun build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Watch Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auto-restart on file changes
|
||||||
|
bun --watch run index.ts
|
||||||
|
|
||||||
|
# With hot reloading
|
||||||
|
bun --hot run server.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Environment Variables
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// .env file is loaded automatically!
|
||||||
|
|
||||||
|
// Access environment variables
|
||||||
|
const apiKey = Bun.env.API_KEY;
|
||||||
|
const port = Bun.env.PORT ?? "3000";
|
||||||
|
|
||||||
|
// Or use process.env (Node.js compatible)
|
||||||
|
const dbUrl = process.env.DATABASE_URL;
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with specific env file
|
||||||
|
bun --env-file=.env.production run index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Built-in APIs
|
||||||
|
|
||||||
|
### 5.1 File System (Bun.file)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Read file
|
||||||
|
const file = Bun.file("./data.json");
|
||||||
|
const text = await file.text();
|
||||||
|
const json = await file.json();
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
// File info
|
||||||
|
console.log(file.size); // bytes
|
||||||
|
console.log(file.type); // MIME type
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
await Bun.write("./output.txt", "Hello, Bun!");
|
||||||
|
await Bun.write("./data.json", JSON.stringify({ foo: "bar" }));
|
||||||
|
|
||||||
|
// Stream large files
|
||||||
|
const reader = file.stream();
|
||||||
|
for await (const chunk of reader) {
|
||||||
|
console.log(chunk);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 HTTP Server (Bun.serve)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 3000,
|
||||||
|
|
||||||
|
fetch(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/") {
|
||||||
|
return new Response("Hello World!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/users") {
|
||||||
|
return Response.json([
|
||||||
|
{ id: 1, name: "Alice" },
|
||||||
|
{ id: 2, name: "Bob" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
},
|
||||||
|
|
||||||
|
error(error) {
|
||||||
|
return new Response(`Error: ${error.message}`, { status: 500 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Server running at http://localhost:${server.port}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 WebSocket Server
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 3000,
|
||||||
|
|
||||||
|
fetch(req, server) {
|
||||||
|
// Upgrade to WebSocket
|
||||||
|
if (server.upgrade(req)) {
|
||||||
|
return; // Upgraded
|
||||||
|
}
|
||||||
|
return new Response("Upgrade failed", { status: 500 });
|
||||||
|
},
|
||||||
|
|
||||||
|
websocket: {
|
||||||
|
open(ws) {
|
||||||
|
console.log("Client connected");
|
||||||
|
ws.send("Welcome!");
|
||||||
|
},
|
||||||
|
|
||||||
|
message(ws, message) {
|
||||||
|
console.log(`Received: ${message}`);
|
||||||
|
ws.send(`Echo: ${message}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
close(ws) {
|
||||||
|
console.log("Client disconnected");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 SQLite (Bun.sql)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
|
||||||
|
const db = new Database("mydb.sqlite");
|
||||||
|
|
||||||
|
// Create table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Insert
|
||||||
|
const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
|
||||||
|
insert.run("Alice", "alice@example.com");
|
||||||
|
|
||||||
|
// Query
|
||||||
|
const query = db.prepare("SELECT * FROM users WHERE name = ?");
|
||||||
|
const user = query.get("Alice");
|
||||||
|
console.log(user); // { id: 1, name: "Alice", email: "alice@example.com" }
|
||||||
|
|
||||||
|
// Query all
|
||||||
|
const allUsers = db.query("SELECT * FROM users").all();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Password Hashing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Hash password
|
||||||
|
const password = "super-secret";
|
||||||
|
const hash = await Bun.password.hash(password);
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValid = await Bun.password.verify(password, hash);
|
||||||
|
console.log(isValid); // true
|
||||||
|
|
||||||
|
// With algorithm options
|
||||||
|
const bcryptHash = await Bun.password.hash(password, {
|
||||||
|
algorithm: "bcrypt",
|
||||||
|
cost: 12,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
### 6.1 Basic Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// math.test.ts
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
|
||||||
|
describe("Math operations", () => {
|
||||||
|
it("adds two numbers", () => {
|
||||||
|
expect(1 + 1).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts two numbers", () => {
|
||||||
|
expect(5 - 3).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Run specific file
|
||||||
|
bun test math.test.ts
|
||||||
|
|
||||||
|
# Run matching pattern
|
||||||
|
bun test --grep "adds"
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
bun test --watch
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
bun test --coverage
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
bun test --timeout 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Matchers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
|
||||||
|
test("matchers", () => {
|
||||||
|
// Equality
|
||||||
|
expect(1).toBe(1);
|
||||||
|
expect({ a: 1 }).toEqual({ a: 1 });
|
||||||
|
expect([1, 2]).toContain(1);
|
||||||
|
|
||||||
|
// Comparisons
|
||||||
|
expect(10).toBeGreaterThan(5);
|
||||||
|
expect(5).toBeLessThanOrEqual(5);
|
||||||
|
|
||||||
|
// Truthiness
|
||||||
|
expect(true).toBeTruthy();
|
||||||
|
expect(null).toBeNull();
|
||||||
|
expect(undefined).toBeUndefined();
|
||||||
|
|
||||||
|
// Strings
|
||||||
|
expect("hello").toMatch(/ell/);
|
||||||
|
expect("hello").toContain("ell");
|
||||||
|
|
||||||
|
// Arrays
|
||||||
|
expect([1, 2, 3]).toHaveLength(3);
|
||||||
|
|
||||||
|
// Exceptions
|
||||||
|
expect(() => {
|
||||||
|
throw new Error("fail");
|
||||||
|
}).toThrow("fail");
|
||||||
|
|
||||||
|
// Async
|
||||||
|
await expect(Promise.resolve(1)).resolves.toBe(1);
|
||||||
|
await expect(Promise.reject("err")).rejects.toBe("err");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Mocking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mock, spyOn } from "bun:test";
|
||||||
|
|
||||||
|
// Mock function
|
||||||
|
const mockFn = mock((x: number) => x * 2);
|
||||||
|
mockFn(5);
|
||||||
|
expect(mockFn).toHaveBeenCalled();
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(5);
|
||||||
|
expect(mockFn.mock.results[0].value).toBe(10);
|
||||||
|
|
||||||
|
// Spy on method
|
||||||
|
const obj = {
|
||||||
|
method: () => "original",
|
||||||
|
};
|
||||||
|
const spy = spyOn(obj, "method").mockReturnValue("mocked");
|
||||||
|
expect(obj.method()).toBe("mocked");
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Bundling
|
||||||
|
|
||||||
|
### 7.1 Basic Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bundle for production
|
||||||
|
bun build ./src/index.ts --outdir ./dist
|
||||||
|
|
||||||
|
# With options
|
||||||
|
bun build ./src/index.ts \
|
||||||
|
--outdir ./dist \
|
||||||
|
--target browser \
|
||||||
|
--minify \
|
||||||
|
--sourcemap
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Build API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: ["./src/index.ts"],
|
||||||
|
outdir: "./dist",
|
||||||
|
target: "browser", // or "bun", "node"
|
||||||
|
minify: true,
|
||||||
|
sourcemap: "external",
|
||||||
|
splitting: true,
|
||||||
|
format: "esm",
|
||||||
|
|
||||||
|
// External packages (not bundled)
|
||||||
|
external: ["react", "react-dom"],
|
||||||
|
|
||||||
|
// Define globals
|
||||||
|
define: {
|
||||||
|
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Naming
|
||||||
|
naming: {
|
||||||
|
entry: "[name].[hash].js",
|
||||||
|
chunk: "chunks/[name].[hash].js",
|
||||||
|
asset: "assets/[name].[hash][ext]",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(result.logs);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Compile to Executable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create standalone executable
|
||||||
|
bun build ./src/cli.ts --compile --outfile myapp
|
||||||
|
|
||||||
|
# Cross-compile
|
||||||
|
bun build ./src/cli.ts --compile --target=bun-linux-x64 --outfile myapp-linux
|
||||||
|
bun build ./src/cli.ts --compile --target=bun-darwin-arm64 --outfile myapp-mac
|
||||||
|
|
||||||
|
# With embedded assets
|
||||||
|
bun build ./src/cli.ts --compile --outfile myapp --embed ./assets
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Migration from Node.js
|
||||||
|
|
||||||
|
### 8.1 Compatibility
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Most Node.js APIs work out of the box
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
// process is global
|
||||||
|
console.log(process.cwd());
|
||||||
|
console.log(process.env.HOME);
|
||||||
|
|
||||||
|
// Buffer is global
|
||||||
|
const buf = Buffer.from("hello");
|
||||||
|
|
||||||
|
// __dirname and __filename work
|
||||||
|
console.log(__dirname);
|
||||||
|
console.log(__filename);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Common Migration Steps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Install Bun
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
|
# 2. Replace package manager
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# 3. Update scripts in package.json
|
||||||
|
# "start": "node index.js" → "start": "bun run index.ts"
|
||||||
|
# "test": "jest" → "test": "bun test"
|
||||||
|
|
||||||
|
# 4. Add Bun types
|
||||||
|
bun add -d @types/bun
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Differences from Node.js
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Node.js specific (may not work)
|
||||||
|
require("module") // Use import instead
|
||||||
|
require.resolve("pkg") // Use import.meta.resolve
|
||||||
|
__non_webpack_require__ // Not supported
|
||||||
|
|
||||||
|
// ✅ Bun equivalents
|
||||||
|
import pkg from "pkg";
|
||||||
|
const resolved = import.meta.resolve("pkg");
|
||||||
|
Bun.resolveSync("pkg", process.cwd());
|
||||||
|
|
||||||
|
// ❌ These globals differ
|
||||||
|
process.hrtime() // Use Bun.nanoseconds()
|
||||||
|
setImmediate() // Use queueMicrotask()
|
||||||
|
|
||||||
|
// ✅ Bun-specific features
|
||||||
|
const file = Bun.file("./data.txt"); // Fast file API
|
||||||
|
Bun.serve({ port: 3000, fetch: ... }); // Fast HTTP server
|
||||||
|
Bun.password.hash(password); // Built-in hashing
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Performance Tips
|
||||||
|
|
||||||
|
### 9.1 Use Bun-native APIs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Slow (Node.js compat)
|
||||||
|
import fs from "fs/promises";
|
||||||
|
const content = await fs.readFile("./data.txt", "utf-8");
|
||||||
|
|
||||||
|
// Fast (Bun-native)
|
||||||
|
const file = Bun.file("./data.txt");
|
||||||
|
const content = await file.text();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Use Bun.serve for HTTP
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Don't: Express/Fastify (overhead)
|
||||||
|
import express from "express";
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Do: Bun.serve (native, 4-10x faster)
|
||||||
|
Bun.serve({
|
||||||
|
fetch(req) {
|
||||||
|
return new Response("Hello!");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Or use Elysia (Bun-optimized framework)
|
||||||
|
import { Elysia } from "elysia";
|
||||||
|
new Elysia().get("/", () => "Hello!").listen(3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.3 Bundle for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Always bundle and minify for production
|
||||||
|
bun build ./src/index.ts --outdir ./dist --minify --target node
|
||||||
|
|
||||||
|
# Then run the bundle
|
||||||
|
bun run ./dist/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
| :----------- | :----------------------------------------- |
|
||||||
|
| Init project | `bun init` |
|
||||||
|
| Install deps | `bun install` |
|
||||||
|
| Add package | `bun add <pkg>` |
|
||||||
|
| Run script | `bun run <script>` |
|
||||||
|
| Run file | `bun run file.ts` |
|
||||||
|
| Watch mode | `bun --watch run file.ts` |
|
||||||
|
| Run tests | `bun test` |
|
||||||
|
| Build | `bun build ./src/index.ts --outdir ./dist` |
|
||||||
|
| Execute pkg | `bunx <pkg>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Bun Documentation](https://bun.sh/docs)
|
||||||
|
- [Bun GitHub](https://github.com/oven-sh/bun)
|
||||||
|
- [Elysia Framework](https://elysiajs.com/)
|
||||||
|
- [Bun Discord](https://bun.sh/discord)
|
||||||
475
.agents/skills/elysiajs/SKILL.md
Normal file
475
.agents/skills/elysiajs/SKILL.md
Normal file
@@ -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
|
||||||
9
.agents/skills/elysiajs/examples/basic.ts
Normal file
9
.agents/skills/elysiajs/examples/basic.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Elysia, t } from 'elysia'
|
||||||
|
|
||||||
|
new Elysia()
|
||||||
|
.get('/', 'Hello Elysia')
|
||||||
|
.post('/', ({ body: { name } }) => name, {
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
33
.agents/skills/elysiajs/examples/body-parser.ts
Normal file
33
.agents/skills/elysiajs/examples/body-parser.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Elysia, t } from 'elysia'
|
||||||
|
|
||||||
|
const app = new Elysia()
|
||||||
|
// Add custom body parser
|
||||||
|
.onParse(async ({ request, contentType }) => {
|
||||||
|
switch (contentType) {
|
||||||
|
case 'application/Elysia':
|
||||||
|
return request.text()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.post('/', ({ body: { username } }) => `Hi ${username}`, {
|
||||||
|
body: t.Object({
|
||||||
|
id: t.Number(),
|
||||||
|
username: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Increase id by 1 from body before main handler
|
||||||
|
.post('/transform', ({ body }) => body, {
|
||||||
|
transform: ({ body }) => {
|
||||||
|
body.id = body.id + 1
|
||||||
|
},
|
||||||
|
body: t.Object({
|
||||||
|
id: t.Number(),
|
||||||
|
username: t.String()
|
||||||
|
}),
|
||||||
|
detail: {
|
||||||
|
summary: 'A'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.post('/mirror', ({ body }) => body)
|
||||||
|
.listen(3000)
|
||||||
|
|
||||||
|
console.log('🦊 Elysia is running at :8080')
|
||||||
112
.agents/skills/elysiajs/examples/complex.ts
Normal file
112
.agents/skills/elysiajs/examples/complex.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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'))
|
||||||
|
|
||||||
|
const app = new Elysia()
|
||||||
|
.onRequest(({ set }) => {
|
||||||
|
set.headers = {
|
||||||
|
'Access-Control-Allow-Origin': '*'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onError(({ code }) => {
|
||||||
|
if (code === 'NOT_FOUND')
|
||||||
|
return 'Not Found :('
|
||||||
|
})
|
||||||
|
.use(loggerPlugin)
|
||||||
|
.state('build', Date.now())
|
||||||
|
.get('/', 'Elysia')
|
||||||
|
.get('/tako', file('./example/takodachi.png'))
|
||||||
|
.get('/json', () => ({
|
||||||
|
hi: 'world'
|
||||||
|
}))
|
||||||
|
.get('/root/plugin/log', ({ log, store: { build } }) => {
|
||||||
|
log()
|
||||||
|
|
||||||
|
return build
|
||||||
|
})
|
||||||
|
.get('/wildcard/*', () => 'Hi Wildcard')
|
||||||
|
.get('/query', () => 'Elysia', {
|
||||||
|
beforeHandle: ({ query }) => {
|
||||||
|
console.log('Name:', query?.name)
|
||||||
|
|
||||||
|
if (query?.name === 'aom') return 'Hi saltyaom'
|
||||||
|
},
|
||||||
|
query: t.Object({
|
||||||
|
name: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post('/json', async ({ body }) => body, {
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
additional: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post('/transform-body', async ({ body }) => body, {
|
||||||
|
beforeHandle: (ctx) => {
|
||||||
|
ctx.body = {
|
||||||
|
...ctx.body,
|
||||||
|
additional: 'Elysia'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
additional: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.get('/id/:id', ({ params: { id } }) => id, {
|
||||||
|
transform({ params }) {
|
||||||
|
params.id = +params.id
|
||||||
|
},
|
||||||
|
params: t.Object({
|
||||||
|
id: t.Number()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post('/new/:id', async ({ body, params }) => body, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.Number()
|
||||||
|
}),
|
||||||
|
body: t.Object({
|
||||||
|
username: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.get('/trailing-slash', () => 'A')
|
||||||
|
.group('/group', (app) =>
|
||||||
|
app
|
||||||
|
.onBeforeHandle(({ query }) => {
|
||||||
|
if (query?.name === 'aom') return 'Hi saltyaom'
|
||||||
|
})
|
||||||
|
.get('/', () => 'From Group')
|
||||||
|
.get('/hi', () => 'HI GROUP')
|
||||||
|
.get('/elysia', () => 'Welcome to Elysian Realm')
|
||||||
|
.get('/fbk', () => 'FuBuKing')
|
||||||
|
)
|
||||||
|
.get('/response-header', ({ set }) => {
|
||||||
|
set.status = 404
|
||||||
|
set.headers['a'] = 'b'
|
||||||
|
|
||||||
|
return 'A'
|
||||||
|
})
|
||||||
|
.get('/this/is/my/deep/nested/root', () => 'Hi')
|
||||||
|
.get('/build', ({ store: { build } }) => build)
|
||||||
|
.get('/ref', ({ date }) => date())
|
||||||
|
.get('/response', () => new Response('Hi'))
|
||||||
|
.get('/error', () => new Error('Something went wrong'))
|
||||||
|
.get('/401', ({ set }) => {
|
||||||
|
set.status = 401
|
||||||
|
|
||||||
|
return 'Status should be 401'
|
||||||
|
})
|
||||||
|
.get('/timeout', async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||||
|
|
||||||
|
return 'A'
|
||||||
|
})
|
||||||
|
.all('/all', () => 'hi')
|
||||||
|
.listen(8080, ({ hostname, port }) => {
|
||||||
|
console.log(`🦊 Elysia is running at http://${hostname}:${port}`)
|
||||||
|
})
|
||||||
45
.agents/skills/elysiajs/examples/cookie.ts
Normal file
45
.agents/skills/elysiajs/examples/cookie.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Elysia, t } from 'elysia'
|
||||||
|
|
||||||
|
const app = new Elysia({
|
||||||
|
cookie: {
|
||||||
|
secrets: 'Fischl von Luftschloss Narfidort',
|
||||||
|
sign: ['name']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get(
|
||||||
|
'/council',
|
||||||
|
({ cookie: { council } }) =>
|
||||||
|
(council.value = [
|
||||||
|
{
|
||||||
|
name: 'Rin',
|
||||||
|
affilation: 'Administration'
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
cookie: t.Cookie({
|
||||||
|
council: t.Array(
|
||||||
|
t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
affilation: t.String()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.get('/create', ({ cookie: { name } }) => (name.value = 'Himari'))
|
||||||
|
.get(
|
||||||
|
'/update',
|
||||||
|
({ cookie: { name } }) => {
|
||||||
|
name.value = 'seminar: Rio'
|
||||||
|
name.value = 'seminar: Himari'
|
||||||
|
name.maxAge = 86400
|
||||||
|
|
||||||
|
return name.value
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cookie: t.Cookie({
|
||||||
|
name: t.Optional(t.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.listen(3000)
|
||||||
38
.agents/skills/elysiajs/examples/error.ts
Normal file
38
.agents/skills/elysiajs/examples/error.ts
Normal file
@@ -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)
|
||||||
10
.agents/skills/elysiajs/examples/file.ts
Normal file
10
.agents/skills/elysiajs/examples/file.ts
Normal file
@@ -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)
|
||||||
34
.agents/skills/elysiajs/examples/guard.ts
Normal file
34
.agents/skills/elysiajs/examples/guard.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Elysia, t } from 'elysia'
|
||||||
|
|
||||||
|
new Elysia()
|
||||||
|
.state('name', 'salt')
|
||||||
|
.get('/', ({ store: { name } }) => `Hi ${name}`, {
|
||||||
|
query: t.Object({
|
||||||
|
name: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// If query 'name' is not preset, skip the whole handler
|
||||||
|
.guard(
|
||||||
|
{
|
||||||
|
query: t.Object({
|
||||||
|
name: t.String()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(app) =>
|
||||||
|
app
|
||||||
|
// Query type is inherited from guard
|
||||||
|
.get('/profile', ({ query }) => `Hi`)
|
||||||
|
// Store is inherited
|
||||||
|
.post('/name', ({ store: { name }, body, query }) => name, {
|
||||||
|
body: t.Object({
|
||||||
|
id: t.Number({
|
||||||
|
minimum: 5
|
||||||
|
}),
|
||||||
|
username: t.String(),
|
||||||
|
profile: t.Object({
|
||||||
|
name: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.listen(3000)
|
||||||
15
.agents/skills/elysiajs/examples/map-response.ts
Normal file
15
.agents/skills/elysiajs/examples/map-response.ts
Normal file
@@ -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)
|
||||||
6
.agents/skills/elysiajs/examples/redirect.ts
Normal file
6
.agents/skills/elysiajs/examples/redirect.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { Elysia } from 'elysia'
|
||||||
|
|
||||||
|
new Elysia()
|
||||||
|
.get('/', () => 'Hi')
|
||||||
|
.get('/redirect', ({ redirect }) => redirect('/'))
|
||||||
|
.listen(3000)
|
||||||
32
.agents/skills/elysiajs/examples/rename.ts
Normal file
32
.agents/skills/elysiajs/examples/rename.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
61
.agents/skills/elysiajs/examples/schema.ts
Normal file
61
.agents/skills/elysiajs/examples/schema.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Elysia, t } from 'elysia'
|
||||||
|
|
||||||
|
const app = new Elysia()
|
||||||
|
.model({
|
||||||
|
name: t.Object({
|
||||||
|
name: t.String()
|
||||||
|
}),
|
||||||
|
b: t.Object({
|
||||||
|
response: t.Number()
|
||||||
|
}),
|
||||||
|
authorization: t.Object({
|
||||||
|
authorization: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Strictly validate response
|
||||||
|
.get('/', () => 'hi')
|
||||||
|
// Strictly validate body and response
|
||||||
|
.post('/', ({ body, query }) => body.id, {
|
||||||
|
body: t.Object({
|
||||||
|
id: t.Number(),
|
||||||
|
username: t.String(),
|
||||||
|
profile: t.Object({
|
||||||
|
name: t.String()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Strictly validate query, params, and body
|
||||||
|
.get('/query/:id', ({ query: { name }, params }) => name, {
|
||||||
|
query: t.Object({
|
||||||
|
name: t.String()
|
||||||
|
}),
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: t.String(),
|
||||||
|
300: t.Object({
|
||||||
|
error: t.String()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.guard(
|
||||||
|
{
|
||||||
|
headers: 'authorization'
|
||||||
|
},
|
||||||
|
(app) =>
|
||||||
|
app
|
||||||
|
.derive(({ headers }) => ({
|
||||||
|
userId: headers.authorization
|
||||||
|
}))
|
||||||
|
.get('/', ({ userId }) => 'A')
|
||||||
|
.post('/id/:id', ({ query, body, params, userId }) => body, {
|
||||||
|
params: t.Object({
|
||||||
|
id: t.Number()
|
||||||
|
}),
|
||||||
|
transform({ params }) {
|
||||||
|
params.id = +params.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.listen(3000)
|
||||||
6
.agents/skills/elysiajs/examples/state.ts
Normal file
6
.agents/skills/elysiajs/examples/state.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { Elysia } from 'elysia'
|
||||||
|
|
||||||
|
new Elysia()
|
||||||
|
.state('counter', 0)
|
||||||
|
.get('/', ({ store }) => store.counter++)
|
||||||
|
.listen(3000)
|
||||||
20
.agents/skills/elysiajs/examples/upload-file.ts
Normal file
20
.agents/skills/elysiajs/examples/upload-file.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Elysia, t } from 'elysia'
|
||||||
|
|
||||||
|
const app = new Elysia()
|
||||||
|
.post('/single', ({ body: { file } }) => file, {
|
||||||
|
body: t.Object({
|
||||||
|
file: t.File({
|
||||||
|
maxSize: '1m'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post(
|
||||||
|
'/multiple',
|
||||||
|
({ body: { files } }) => files.reduce((a, b) => a + b.size, 0),
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
files: t.Files()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.listen(3000)
|
||||||
25
.agents/skills/elysiajs/examples/websocket.ts
Normal file
25
.agents/skills/elysiajs/examples/websocket.ts
Normal file
@@ -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}`)
|
||||||
|
})
|
||||||
92
.agents/skills/elysiajs/integrations/ai-sdk.md
Normal file
92
.agents/skills/elysiajs/integrations/ai-sdk.md
Normal file
@@ -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.
|
||||||
59
.agents/skills/elysiajs/integrations/astro.md
Normal file
59
.agents/skills/elysiajs/integrations/astro.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
117
.agents/skills/elysiajs/integrations/better-auth.md
Normal file
117
.agents/skills/elysiajs/integrations/better-auth.md
Normal file
@@ -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<typeof auth.api.generateOpenAPISchema>
|
||||||
|
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<any>,
|
||||||
|
components: getSchema().then(({ components }) => components) as Promise<any>
|
||||||
|
} 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.
|
||||||
95
.agents/skills/elysiajs/integrations/cloudflare-worker.md
Normal file
95
.agents/skills/elysiajs/integrations/cloudflare-worker.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
34
.agents/skills/elysiajs/integrations/deno.md
Normal file
34
.agents/skills/elysiajs/integrations/deno.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
258
.agents/skills/elysiajs/integrations/drizzle.md
Normal file
258
.agents/skills/elysiajs/integrations/drizzle.md
Normal file
@@ -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<infer Fields>
|
||||||
|
? {
|
||||||
|
[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<T, Mode> => {
|
||||||
|
const newSchema: Record<string, unknown> = {}
|
||||||
|
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<string, TObject | Table>,
|
||||||
|
Mode extends 'select' | 'insert' | undefined,
|
||||||
|
>(
|
||||||
|
models: T,
|
||||||
|
mode?: Mode,
|
||||||
|
): {
|
||||||
|
[K in keyof T]: Spread<T[K], Mode>
|
||||||
|
} => {
|
||||||
|
const newSchema: Record<string, unknown> = {}
|
||||||
|
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.
|
||||||
95
.agents/skills/elysiajs/integrations/expo.md
Normal file
95
.agents/skills/elysiajs/integrations/expo.md
Normal file
@@ -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<app>('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 <h1>Hello, {message}</h1>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
103
.agents/skills/elysiajs/integrations/nextjs.md
Normal file
103
.agents/skills/elysiajs/integrations/nextjs.md
Normal file
@@ -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<typeof app>('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 <h1>Hello, {message}</h1>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
64
.agents/skills/elysiajs/integrations/nodejs.md
Normal file
64
.agents/skills/elysiajs/integrations/nodejs.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
67
.agents/skills/elysiajs/integrations/nuxt.md
Normal file
67
.agents/skills/elysiajs/integrations/nuxt.md
Normal file
@@ -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
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p>{{ data.message }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { $api } = useNuxtApp()
|
||||||
|
|
||||||
|
const { data } = await useAsyncData(async () => {
|
||||||
|
const { data, error } = await $api.hello.get()
|
||||||
|
|
||||||
|
if (error) throw new Error('Failed to call API')
|
||||||
|
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
93
.agents/skills/elysiajs/integrations/prisma.md
Normal file
93
.agents/skills/elysiajs/integrations/prisma.md
Normal file
@@ -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.
|
||||||
134
.agents/skills/elysiajs/integrations/react-email.md
Normal file
134
.agents/skills/elysiajs/integrations/react-email.md
Normal file
@@ -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 (
|
||||||
|
<Tailwind>
|
||||||
|
<Section className="flex justify-center items-center w-full min-h-screen font-sans">
|
||||||
|
<Section className="flex flex-col items-center w-76 rounded-2xl px-6 py-1 bg-gray-50">
|
||||||
|
<Text className="text-xs font-medium text-violet-500">
|
||||||
|
Verify your Email Address
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-500 my-0">
|
||||||
|
Use the following code to verify your email address
|
||||||
|
</Text>
|
||||||
|
<Text className="text-5xl font-bold pt-2">{otp}</Text>
|
||||||
|
<Text className="text-gray-400 font-light text-xs pb-4">
|
||||||
|
This code is valid for 10 minutes
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-600 text-xs">
|
||||||
|
Thank you for joining us
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
</Tailwind>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<OTPEmail otp={otp} />)
|
||||||
|
|
||||||
|
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: <OTPEmail otp={otp} /> // 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).
|
||||||
53
.agents/skills/elysiajs/integrations/sveltekit.md
Normal file
53
.agents/skills/elysiajs/integrations/sveltekit.md
Normal file
@@ -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<Response>
|
||||||
|
|
||||||
|
export const fallback: RequestHandler = ({ request }) => app.handle(request)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensures routing works in any location.
|
||||||
|
|
||||||
|
## pnpm
|
||||||
|
Manual install:
|
||||||
|
```bash
|
||||||
|
pnpm add @sinclair/typebox openapi-types
|
||||||
|
```
|
||||||
87
.agents/skills/elysiajs/integrations/tanstack-start.md
Normal file
87
.agents/skills/elysiajs/integrations/tanstack-start.md
Normal file
@@ -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<typeof app>('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
|
||||||
|
```
|
||||||
55
.agents/skills/elysiajs/integrations/vercel.md
Normal file
55
.agents/skills/elysiajs/integrations/vercel.md
Normal file
@@ -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).
|
||||||
380
.agents/skills/elysiajs/patterns/mvc.md
Normal file
380
.agents/skills/elysiajs/patterns/mvc.md
Normal file
@@ -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.
|
||||||
30
.agents/skills/elysiajs/plugins/bearer.md
Normal file
30
.agents/skills/elysiajs/plugins/bearer.md
Normal file
@@ -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
|
||||||
141
.agents/skills/elysiajs/plugins/cors.md
Normal file
141
.agents/skills/elysiajs/plugins/cors.md
Normal file
@@ -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<string | RegExp | Function>** - 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`
|
||||||
265
.agents/skills/elysiajs/plugins/cron.md
Normal file
265
.agents/skills/elysiajs/plugins/cron.md
Normal file
@@ -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 * * *` |
|
||||||
90
.agents/skills/elysiajs/plugins/graphql-apollo.md
Normal file
90
.agents/skills/elysiajs/plugins/graphql-apollo.md
Normal file
@@ -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.
|
||||||
87
.agents/skills/elysiajs/plugins/graphql-yoga.md
Normal file
87
.agents/skills/elysiajs/plugins/graphql-yoga.md
Normal file
@@ -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
|
||||||
188
.agents/skills/elysiajs/plugins/html.md
Normal file
188
.agents/skills/elysiajs/plugins/html.md
Normal file
@@ -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',
|
||||||
|
() => `
|
||||||
|
<html lang='en'>
|
||||||
|
<head>
|
||||||
|
<title>Hello World</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Hello World</h1>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
)
|
||||||
|
.get('/jsx', () => (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Hello World</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Hello World</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
))
|
||||||
|
.listen(3000)
|
||||||
|
```
|
||||||
|
|
||||||
|
This plugin will automatically add `Content-Type: text/html; charset=utf8` header to the response, add `<!doctype html>`, 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('/', () => (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Hello World</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Hello World</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
))
|
||||||
|
.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 }) => (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Hello World</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 safe>{body}</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
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 `<!doctype html>` to a response starting with `<html>`, if not found.
|
||||||
|
|
||||||
|
Use `full` to also automatically add doctypes on responses returned without this plugin
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// without the plugin
|
||||||
|
app.get('/', () => '<html></html>')
|
||||||
|
|
||||||
|
// With the plugin
|
||||||
|
app.get('/', ({ html }) => html('<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.
|
||||||
197
.agents/skills/elysiajs/plugins/jwt.md
Normal file
197
.agents/skills/elysiajs/plugins/jwt.md
Normal file
@@ -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<string>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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 | false>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
246
.agents/skills/elysiajs/plugins/openapi.md
Normal file
246
.agents/skills/elysiajs/plugins/openapi.md
Normal file
@@ -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<T> = { [K in keyof T]: T[K] } & {}
|
||||||
|
|
||||||
|
function getUser(): Prettify<User> { }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
167
.agents/skills/elysiajs/plugins/opentelemetry.md
Normal file
167
.agents/skills/elysiajs/plugins/opentelemetry.md
Normal file
@@ -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.
|
||||||
71
.agents/skills/elysiajs/plugins/server-timing.md
Normal file
71
.agents/skills/elysiajs/plugins/server-timing.md
Normal file
@@ -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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
```
|
||||||
84
.agents/skills/elysiajs/plugins/static.md
Normal file
84
.agents/skills/elysiajs/plugins/static.md
Normal file
@@ -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'))
|
||||||
|
```
|
||||||
129
.agents/skills/elysiajs/references/bun-fullstack-dev-server.md
Normal file
129
.agents/skills/elysiajs/references/bun-fullstack-dev-server.md
Normal file
@@ -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
.agents/skills/elysiajs/references/cookie.md
Normal file
187
.agents/skills/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
.agents/skills/elysiajs/references/deployment.md
Normal file
413
.agents/skills/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
.agents/skills/elysiajs/references/eden.md
Normal file
158
.agents/skills/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
.agents/skills/elysiajs/references/lifecycle.md
Normal file
198
.agents/skills/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
.agents/skills/elysiajs/references/macro.md
Normal file
83
.agents/skills/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
.agents/skills/elysiajs/references/plugin.md
Normal file
207
.agents/skills/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
.agents/skills/elysiajs/references/route.md
Normal file
331
.agents/skills/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
.agents/skills/elysiajs/references/testing.md
Normal file
385
.agents/skills/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
.agents/skills/elysiajs/references/validation.md
Normal file
491
.agents/skills/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
.agents/skills/elysiajs/references/websocket.md
Normal file
250
.agents/skills/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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
133
.agents/skills/mantine-dev/SKILL.md
Normal file
133
.agents/skills/mantine-dev/SKILL.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
name: mantine-dev
|
||||||
|
description: "Mantine UI library for React: 100+ components, hooks, forms, theming, dark mode, CSS modules, and Vite/TypeScript setup. Use when building React applications with Mantine components, configuring theming/dark mode, or working with Mantine hooks and forms. Keywords: Mantine, React, UI components, CSS modules, theming."
|
||||||
|
metadata:
|
||||||
|
version: "8.3.18"
|
||||||
|
release_date: "2026-03-17"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mantine UI Library
|
||||||
|
|
||||||
|
Mantine is a fully-featured React components library with TypeScript support. It provides 100+ hooks and components with native dark mode, CSS-in-JS via CSS modules, and excellent accessibility.
|
||||||
|
|
||||||
|
## Focus
|
||||||
|
|
||||||
|
This skill focuses on:
|
||||||
|
|
||||||
|
- **Vite** + **TypeScript** setup (not Next.js or CRA)
|
||||||
|
- CSS modules with PostCSS preset
|
||||||
|
- Vitest for testing
|
||||||
|
- ESLint with eslint-config-mantine
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
See `references/getting-started.md` for Vite template setup, manual installation, and optional packages.
|
||||||
|
|
||||||
|
## PostCSS Configuration
|
||||||
|
|
||||||
|
Create `postcss.config.cjs`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"postcss-preset-mantine": {},
|
||||||
|
"postcss-simple-vars": {
|
||||||
|
variables: {
|
||||||
|
"mantine-breakpoint-xs": "36em",
|
||||||
|
"mantine-breakpoint-sm": "48em",
|
||||||
|
"mantine-breakpoint-md": "62em",
|
||||||
|
"mantine-breakpoint-lg": "75em",
|
||||||
|
"mantine-breakpoint-xl": "88em",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Setup
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/App.tsx
|
||||||
|
import "@mantine/core/styles.css";
|
||||||
|
// Other style imports as needed:
|
||||||
|
// import '@mantine/dates/styles.css';
|
||||||
|
// import '@mantine/notifications/styles.css';
|
||||||
|
|
||||||
|
import { MantineProvider, createTheme } from "@mantine/core";
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
// Theme customization here
|
||||||
|
});
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <MantineProvider theme={theme}>{/* Your app */}</MantineProvider>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Prohibitions
|
||||||
|
|
||||||
|
- Do NOT skip MantineProvider wrapper — all components require it
|
||||||
|
- Do NOT forget to import `@mantine/core/styles.css` — components won't style without it
|
||||||
|
- Do NOT mix Mantine with other UI libraries (e.g., Chakra, MUI) in same project
|
||||||
|
- Do NOT use inline styles for theme values — use CSS variables or theme object
|
||||||
|
- Do NOT skip PostCSS setup — responsive mixins won't work
|
||||||
|
- Do NOT forget `key={form.key('path')}` when using uncontrolled forms
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### 1. MantineProvider
|
||||||
|
|
||||||
|
Wraps your app, provides theme context and color scheme management.
|
||||||
|
|
||||||
|
### 2. Theme Object
|
||||||
|
|
||||||
|
Customize colors, typography, spacing, component default props.
|
||||||
|
|
||||||
|
### 3. Style Props
|
||||||
|
|
||||||
|
All components accept style props like `mt`, `p`, `c`, `bg`, etc.
|
||||||
|
|
||||||
|
### 4. CSS Variables
|
||||||
|
|
||||||
|
All theme values exposed as CSS variables (e.g., `--mantine-color-blue-6`).
|
||||||
|
|
||||||
|
### 5. Polymorphic Components
|
||||||
|
|
||||||
|
Many components support `component` prop to render as different elements.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- [ ] MantineProvider wraps the app
|
||||||
|
- [ ] Styles imported (`@mantine/core/styles.css`)
|
||||||
|
- [ ] PostCSS configured with mantine-preset
|
||||||
|
- [ ] Theme customization in createTheme
|
||||||
|
- [ ] Color scheme (light/dark) handled
|
||||||
|
- [ ] TypeScript types working
|
||||||
|
- [ ] Tests pass with Vitest + custom render
|
||||||
|
|
||||||
|
## References (Detailed Guides)
|
||||||
|
|
||||||
|
### Setup & Configuration
|
||||||
|
|
||||||
|
- [getting-started.md](references/getting-started.md) — Installation, Vite setup, project structure
|
||||||
|
- [styling.md](references/styling.md) — MantineProvider, theme, CSS modules, style props, dark mode
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
|
||||||
|
- [components.md](references/components.md) — Core UI components patterns
|
||||||
|
- [hooks.md](references/hooks.md) — @mantine/hooks utility hooks
|
||||||
|
- [forms.md](references/forms.md) — @mantine/form, useForm, validation
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
- [testing.md](references/testing.md) — Vitest setup, custom render, mocking
|
||||||
|
- [eslint.md](references/eslint.md) — eslint-config-mantine setup
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [Documentation](https://mantine.dev)
|
||||||
|
- [Releases](https://github.com/mantinedev/mantine/releases)
|
||||||
|
- [GitHub](https://github.com/mantinedev/mantine)
|
||||||
|
- [npm](https://www.npmjs.com/package/@mantine/core)
|
||||||
|
- [Vite template](https://github.com/mantinedev/vite-template)
|
||||||
|
- [LLM docs](https://mantine.dev/llms.txt)
|
||||||
258
.agents/skills/mantine-dev/references/components.md
Normal file
258
.agents/skills/mantine-dev/references/components.md
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# Components Reference
|
||||||
|
|
||||||
|
`@mantine/core` provides 120+ components. This reference covers key patterns.
|
||||||
|
|
||||||
|
## Layout Components
|
||||||
|
|
||||||
|
### Container, Stack, Group, Flex
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Container, Stack, Group, Flex } from '@mantine/core';
|
||||||
|
|
||||||
|
<Container size="md">{/* Centers content, max-width */}</Container>
|
||||||
|
|
||||||
|
<Stack gap="md">{/* Vertical flex */}</Stack>
|
||||||
|
|
||||||
|
<Group gap="sm" justify="space-between">{/* Horizontal flex */}</Group>
|
||||||
|
|
||||||
|
<Flex direction="column" gap="md" align="center">{/* Generic flex */}</Flex>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid & SimpleGrid
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Grid, SimpleGrid } from '@mantine/core';
|
||||||
|
|
||||||
|
// CSS Grid with responsive spans
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, sm: 6, lg: 4 }}>Responsive</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
// Equal-width columns
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>{/* Items */}</SimpleGrid>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Button Variants
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button, ActionIcon } from '@mantine/core';
|
||||||
|
|
||||||
|
<Button variant="filled">Default</Button>
|
||||||
|
<Button variant="outline">Outline</Button>
|
||||||
|
<Button variant="light">Light</Button>
|
||||||
|
<Button variant="subtle">Subtle</Button>
|
||||||
|
<Button variant="white">White</Button>
|
||||||
|
|
||||||
|
<Button loading>Loading state</Button>
|
||||||
|
<Button leftSection={<IconPlus />}>With Icon</Button>
|
||||||
|
|
||||||
|
// Icon button
|
||||||
|
<ActionIcon variant="filled" color="blue"><IconSettings /></ActionIcon>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Inputs Pattern
|
||||||
|
|
||||||
|
All inputs follow consistent API:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TextInput, PasswordInput, Textarea, NumberInput, Select } from '@mantine/core';
|
||||||
|
|
||||||
|
// Common props: label, description, error, required, placeholder
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
description="We won't share it"
|
||||||
|
error="Invalid email"
|
||||||
|
required
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Country"
|
||||||
|
data={['USA', 'Canada']}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Objects with value/label
|
||||||
|
<Select data={[{ value: 'us', label: 'United States' }]} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Overlays Pattern
|
||||||
|
|
||||||
|
Modals, Drawers, Menus, Popovers all use similar pattern:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Modal, Drawer, Menu, Popover } from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
|
||||||
|
// Common pattern with useDisclosure
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
<Modal opened={opened} onClose={close} title="Title">Content</Modal>
|
||||||
|
|
||||||
|
// Drawer
|
||||||
|
<Drawer opened={opened} onClose={close} position="left">Navigation</Drawer>
|
||||||
|
|
||||||
|
// Menu (dropdown)
|
||||||
|
<Menu>
|
||||||
|
<Menu.Target><Button>Toggle</Button></Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item leftSection={<IconSettings />}>Settings</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item color="red">Delete</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
// Popover
|
||||||
|
<Popover width={200} withArrow>
|
||||||
|
<Popover.Target><Button>Info</Button></Popover.Target>
|
||||||
|
<Popover.Dropdown>Details here</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feedback Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Loader, Alert, Notification, Progress, Skeleton } from '@mantine/core';
|
||||||
|
|
||||||
|
<Loader type="bars" /> // oval, bars, dots
|
||||||
|
|
||||||
|
<Alert variant="light" color="blue" title="Info">Message</Alert>
|
||||||
|
|
||||||
|
<Notification title="Success" color="green" icon={<IconCheck />}>
|
||||||
|
Saved!
|
||||||
|
</Notification>
|
||||||
|
|
||||||
|
<Progress value={65} />
|
||||||
|
<Progress.Root size="xl">
|
||||||
|
<Progress.Section value={35} color="cyan"><Progress.Label>Docs</Progress.Label></Progress.Section>
|
||||||
|
</Progress.Root>
|
||||||
|
|
||||||
|
// Loading placeholders
|
||||||
|
<Skeleton height={50} circle />
|
||||||
|
<Skeleton height={8} radius="xl" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Title, Text, Anchor, Highlight, Code } from '@mantine/core';
|
||||||
|
|
||||||
|
<Title order={1}>h1 heading</Title>
|
||||||
|
<Title order={2} c="dimmed">h2 dimmed</Title>
|
||||||
|
|
||||||
|
<Text size="sm" c="dimmed" fw={700}>Small bold dimmed</Text>
|
||||||
|
<Text truncate>Long text...</Text>
|
||||||
|
<Text lineClamp={2}>Multi-line truncate...</Text>
|
||||||
|
|
||||||
|
<Highlight highlight={['react', 'mantine']}>
|
||||||
|
Learn React with Mantine
|
||||||
|
</Highlight>
|
||||||
|
|
||||||
|
<Code>inline</Code>
|
||||||
|
<Code block>{`const x = 1;`}</Code>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Display
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Badge, Card, Table, Avatar, Image, Tabs, Accordion } from '@mantine/core';
|
||||||
|
|
||||||
|
// Badge variants
|
||||||
|
<Badge>Default</Badge>
|
||||||
|
<Badge variant="dot" color="red">Dot</Badge>
|
||||||
|
|
||||||
|
// Card with sections
|
||||||
|
<Card shadow="sm" padding="lg" withBorder>
|
||||||
|
<Card.Section><Image src="/img.jpg" height={160} /></Card.Section>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
// Table
|
||||||
|
<Table striped highlightOnHover withTableBorder>
|
||||||
|
<Table.Thead><Table.Tr><Table.Th>Name</Table.Th></Table.Tr></Table.Thead>
|
||||||
|
<Table.Tbody><Table.Tr><Table.Td>John</Table.Td></Table.Tr></Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
<Tabs defaultValue="tab1">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="tab1">First</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="tab2">Second</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
<Tabs.Panel value="tab1">Content 1</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
// Accordion
|
||||||
|
<Accordion defaultValue="item-1">
|
||||||
|
<Accordion.Item value="item-1">
|
||||||
|
<Accordion.Control>Section 1</Accordion.Control>
|
||||||
|
<Accordion.Panel>Content</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NavLink, Pagination, Stepper, Breadcrumbs } from '@mantine/core';
|
||||||
|
|
||||||
|
<NavLink href="#" label="Dashboard" leftSection={<IconHome />} active />
|
||||||
|
<NavLink label="Settings">
|
||||||
|
<NavLink label="General" />
|
||||||
|
<NavLink label="Security" />
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<Pagination total={10} value={page} onChange={setPage} />
|
||||||
|
|
||||||
|
<Stepper active={active}>
|
||||||
|
<Stepper.Step label="Step 1">Content 1</Stepper.Step>
|
||||||
|
<Stepper.Step label="Step 2">Content 2</Stepper.Step>
|
||||||
|
<Stepper.Completed>Done!</Stepper.Completed>
|
||||||
|
</Stepper>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Style Props
|
||||||
|
|
||||||
|
All components accept these props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Component
|
||||||
|
// Margin & Padding
|
||||||
|
m="md" mt="xs" p="sm" px="md"
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
c="dimmed" bg="blue.1"
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
fw={500} fz="sm"
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
w={200} h="100%" maw={500}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
p={{ base: 'xs', sm: 'md', lg: 'xl' }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Polymorphic Components
|
||||||
|
|
||||||
|
Render as different elements:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@mantine/core';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
<Button component={Link} to="/about">Link Button</Button>
|
||||||
|
<Button component="a" href="https://example.com">Anchor Button</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Visibility Props
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Text hiddenFrom="sm">Hidden on sm+</Text>
|
||||||
|
<Text visibleFrom="md">Visible on md+</Text>
|
||||||
|
<Text lightHidden>Only in dark mode</Text>
|
||||||
|
<Text darkHidden>Only in light mode</Text>
|
||||||
|
```
|
||||||
269
.agents/skills/mantine-dev/references/eslint.md
Normal file
269
.agents/skills/mantine-dev/references/eslint.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# ESLint Configuration Reference
|
||||||
|
|
||||||
|
`eslint-config-mantine` provides ESLint rules and configurations used in Mantine projects.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D @eslint/js eslint eslint-plugin-jsx-a11y eslint-plugin-react typescript-eslint eslint-config-mantine
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create `eslint.config.js` (ESLint flat config):
|
||||||
|
|
||||||
|
```js
|
||||||
|
import mantine from 'eslint-config-mantine';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...mantine,
|
||||||
|
{
|
||||||
|
ignores: ['**/*.{mjs,cjs,js,d.ts,d.mts}'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.story.tsx'],
|
||||||
|
rules: {
|
||||||
|
'no-console': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: './tsconfig.json',
|
||||||
|
tsconfigRootDir: process.cwd(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Included
|
||||||
|
|
||||||
|
eslint-config-mantine includes:
|
||||||
|
|
||||||
|
### TypeScript Rules
|
||||||
|
- `typescript-eslint/recommended` base rules
|
||||||
|
- Strict type checking
|
||||||
|
- No unused variables (except with `_` prefix)
|
||||||
|
- No explicit `any` (warning)
|
||||||
|
|
||||||
|
### React Rules
|
||||||
|
- React hooks rules (exhaustive-deps, rules-of-hooks)
|
||||||
|
- JSX-specific rules
|
||||||
|
- No unknown properties
|
||||||
|
- Self-closing components
|
||||||
|
|
||||||
|
### Accessibility (a11y)
|
||||||
|
- `eslint-plugin-jsx-a11y` rules
|
||||||
|
- Alt text requirements
|
||||||
|
- ARIA attribute validation
|
||||||
|
- Interactive element handling
|
||||||
|
- Focus management rules
|
||||||
|
|
||||||
|
### Import/Export
|
||||||
|
- Import order organization
|
||||||
|
- No duplicate imports
|
||||||
|
- No unresolved imports
|
||||||
|
|
||||||
|
### General
|
||||||
|
- No console.log (warning)
|
||||||
|
- Consistent code style
|
||||||
|
- No debugger
|
||||||
|
|
||||||
|
## Script Setup
|
||||||
|
|
||||||
|
Add to `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"lint": "npm run eslint && npm run stylelint",
|
||||||
|
"eslint": "eslint . --cache",
|
||||||
|
"eslint:fix": "eslint . --cache --fix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Configuration Adjustments
|
||||||
|
|
||||||
|
### Allow console in specific files
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default [
|
||||||
|
...mantine,
|
||||||
|
{
|
||||||
|
files: ['**/*.test.tsx', '**/*.story.tsx'],
|
||||||
|
rules: {
|
||||||
|
'no-console': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable specific rules
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default [
|
||||||
|
...mantine,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'react/jsx-no-target-blank': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add custom rules
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default [
|
||||||
|
...mantine,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'object-shorthand': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure for monorepo
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default [
|
||||||
|
...mantine,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.json', './packages/*/tsconfig.json'],
|
||||||
|
tsconfigRootDir: process.cwd(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Prettier
|
||||||
|
|
||||||
|
If using Prettier, add `eslint-config-prettier`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D eslint-config-prettier
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import mantine from 'eslint-config-mantine';
|
||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...mantine,
|
||||||
|
prettier, // Must be last to override conflicting rules
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## VS Code Integration
|
||||||
|
|
||||||
|
Install ESLint extension, then add to `.vscode/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact"
|
||||||
|
],
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stylelint (Optional)
|
||||||
|
|
||||||
|
For CSS linting, the Mantine Vite template also includes Stylelint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D stylelint stylelint-config-standard-scss
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `stylelint.config.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
extends: ['stylelint-config-standard-scss'],
|
||||||
|
rules: {
|
||||||
|
'selector-class-pattern': null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to scripts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"stylelint": "stylelint '**/*.css' --cache"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common ESLint Errors & Fixes
|
||||||
|
|
||||||
|
### React Hook Dependency Warning
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Warning: React Hook useEffect has missing dependency
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(id);
|
||||||
|
}, []); // Missing 'id' and 'fetchData'
|
||||||
|
|
||||||
|
// Fix: Add dependencies
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(id);
|
||||||
|
}, [id, fetchData]);
|
||||||
|
|
||||||
|
// Or use useCallback for functions
|
||||||
|
const fetchData = useCallback((id) => { /* ... */ }, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unused Variable
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Error: 'x' is defined but never used
|
||||||
|
const x = 5;
|
||||||
|
|
||||||
|
// Fix: Prefix with underscore if intentionally unused
|
||||||
|
const _x = 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Missing Key Prop
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Error: Missing "key" prop
|
||||||
|
items.map((item) => <Item>{item.name}</Item>);
|
||||||
|
|
||||||
|
// Fix: Add unique key
|
||||||
|
items.map((item) => <Item key={item.id}>{item.name}</Item>);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessibility Issues
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Error: img elements must have an alt prop
|
||||||
|
<img src="photo.jpg" />
|
||||||
|
|
||||||
|
// Fix: Add alt text
|
||||||
|
<img src="photo.jpg" alt="Description" />
|
||||||
|
|
||||||
|
// Decorative image
|
||||||
|
<img src="decoration.jpg" alt="" role="presentation" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template Configuration
|
||||||
|
|
||||||
|
The [Mantine Vite template](https://github.com/mantinedev/vite-template) includes a complete ESLint + Prettier + Stylelint setup that you can use as reference.
|
||||||
496
.agents/skills/mantine-dev/references/forms.md
Normal file
496
.agents/skills/mantine-dev/references/forms.md
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
# Forms Reference
|
||||||
|
|
||||||
|
`@mantine/form` provides `useForm` hook for managing form state, validation, and submission.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @mantine/form
|
||||||
|
```
|
||||||
|
|
||||||
|
No styles needed — works with or without `@mantine/core`.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { TextInput, Button, Box } from '@mantine/core';
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Demo() {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
mode: 'uncontrolled', // Recommended for performance
|
||||||
|
initialValues: {
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
|
||||||
|
name: (value) => (value.length < 2 ? 'Name too short' : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="form" onSubmit={form.onSubmit((values) => console.log(values))}>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
placeholder="Your name"
|
||||||
|
key={form.key('name')}
|
||||||
|
{...form.getInputProps('name')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
key={form.key('email')}
|
||||||
|
{...form.getInputProps('email')}
|
||||||
|
/>
|
||||||
|
<Button type="submit" mt="md">Submit</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## useForm Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface UseFormInput<Values> {
|
||||||
|
mode?: 'controlled' | 'uncontrolled'; // Default: 'controlled'
|
||||||
|
initialValues?: Values;
|
||||||
|
initialErrors?: FormErrors;
|
||||||
|
initialDirty?: Record<string, boolean>;
|
||||||
|
initialTouched?: Record<string, boolean>;
|
||||||
|
validate?: FormValidation<Values>;
|
||||||
|
validateInputOnChange?: boolean | string[];
|
||||||
|
validateInputOnBlur?: boolean | string[];
|
||||||
|
clearInputErrorOnChange?: boolean;
|
||||||
|
onValuesChange?: (values: Values, previous: Values) => void;
|
||||||
|
onSubmitPreventDefault?: 'always' | 'never' | 'validation-failed';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controlled vs Uncontrolled Mode
|
||||||
|
|
||||||
|
### Uncontrolled (Recommended)
|
||||||
|
|
||||||
|
Better performance — values stored in DOM:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled', // Add mode
|
||||||
|
initialValues: { name: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
key={form.key('name')} // Required for uncontrolled
|
||||||
|
{...form.getInputProps('name')}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controlled
|
||||||
|
|
||||||
|
Values stored in React state — re-renders on every change:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'controlled',
|
||||||
|
initialValues: { name: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
<TextInput {...form.getInputProps('name')} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Values
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Get all values
|
||||||
|
const values = form.getValues();
|
||||||
|
|
||||||
|
// Set single field
|
||||||
|
form.setFieldValue('email', 'new@email.com');
|
||||||
|
|
||||||
|
// Set multiple values
|
||||||
|
form.setValues({ name: 'John', email: 'john@email.com' });
|
||||||
|
|
||||||
|
// Set values from previous state
|
||||||
|
form.setValues((prev) => ({ ...prev, name: 'Updated' }));
|
||||||
|
|
||||||
|
// Reset to initialValues
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
// Reset single field
|
||||||
|
form.resetField('email');
|
||||||
|
|
||||||
|
// Update initialValues (affects reset)
|
||||||
|
form.setInitialValues({ name: 'New Initial' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
### Inline Rules
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
initialValues: {
|
||||||
|
email: '',
|
||||||
|
age: 0,
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
|
||||||
|
age: (value) => (value < 18 ? 'Must be 18+' : null),
|
||||||
|
// Access all values for cross-field validation
|
||||||
|
confirmPassword: (value, values) =>
|
||||||
|
value !== values.password ? 'Passwords do not match' : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Function-based Validation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
initialValues: { name: '', email: '' },
|
||||||
|
validate: (values) => ({
|
||||||
|
name: values.name.length < 2 ? 'Name too short' : null,
|
||||||
|
email: !values.email.includes('@') ? 'Invalid email' : null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Validation (Zod, Yup, Joi)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { zodResolver } from 'mantine-form-zod-resolver';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(2, 'Name must have at least 2 characters'),
|
||||||
|
email: z.string().email('Invalid email'),
|
||||||
|
age: z.number().min(18, 'Must be 18 or older'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
initialValues: { name: '', email: '', age: 0 },
|
||||||
|
validate: zodResolver(schema),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Install resolver:
|
||||||
|
```bash
|
||||||
|
npm install mantine-form-zod-resolver zod
|
||||||
|
# or
|
||||||
|
npm install mantine-form-yup-resolver yup
|
||||||
|
# or
|
||||||
|
npm install mantine-form-joi-resolver joi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Timing
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
validateInputOnChange: true, // Validate all on change
|
||||||
|
validateInputOnBlur: true, // Validate all on blur
|
||||||
|
clearInputErrorOnChange: true, // Clear error when value changes (default)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate specific fields only
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
validateInputOnChange: ['email', 'password'],
|
||||||
|
validateInputOnBlur: ['email'],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Validation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Validate all fields
|
||||||
|
const result = form.validate();
|
||||||
|
// result.hasErrors: boolean
|
||||||
|
// result.errors: FormErrors
|
||||||
|
|
||||||
|
// Validate single field
|
||||||
|
form.validateField('email');
|
||||||
|
|
||||||
|
// Check if valid (without setting errors)
|
||||||
|
const isValid = form.isValid();
|
||||||
|
const isEmailValid = form.isValid('email');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Get current errors
|
||||||
|
form.errors; // { email: 'Invalid', name: null }
|
||||||
|
|
||||||
|
// Set error
|
||||||
|
form.setFieldError('email', 'This email is taken');
|
||||||
|
|
||||||
|
// Set multiple errors
|
||||||
|
form.setErrors({ email: 'Invalid', name: 'Required' });
|
||||||
|
|
||||||
|
// Clear all errors
|
||||||
|
form.clearErrors();
|
||||||
|
|
||||||
|
// Clear single field error
|
||||||
|
form.clearFieldError('email');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Submission
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(
|
||||||
|
// Success handler - called when validation passes
|
||||||
|
(values, event) => {
|
||||||
|
console.log('Valid:', values);
|
||||||
|
// Submit to API
|
||||||
|
},
|
||||||
|
// Error handler - called when validation fails
|
||||||
|
(errors, values, event) => {
|
||||||
|
console.log('Errors:', errors);
|
||||||
|
// Show notification, focus first error, etc.
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* inputs */}
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nested Objects
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
initialValues: {
|
||||||
|
user: {
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
address: {
|
||||||
|
city: '',
|
||||||
|
country: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
user: {
|
||||||
|
firstName: (value) => (value.length < 2 ? 'Too short' : null),
|
||||||
|
address: {
|
||||||
|
city: (value) => (!value ? 'Required' : null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
key={form.key('user.firstName')}
|
||||||
|
{...form.getInputProps('user.firstName')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
key={form.key('user.address.city')}
|
||||||
|
{...form.getInputProps('user.address.city')}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Fields
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
initialValues: {
|
||||||
|
employees: [
|
||||||
|
{ name: '', email: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add item
|
||||||
|
form.insertListItem('employees', { name: '', email: '' });
|
||||||
|
|
||||||
|
// Add at specific index
|
||||||
|
form.insertListItem('employees', { name: '', email: '' }, 0);
|
||||||
|
|
||||||
|
// Remove item
|
||||||
|
form.removeListItem('employees', 1);
|
||||||
|
|
||||||
|
// Replace item
|
||||||
|
form.replaceListItem('employees', 0, { name: 'New', email: 'new@email.com' });
|
||||||
|
|
||||||
|
// Reorder items
|
||||||
|
form.reorderListItem('employees', { from: 0, to: 2 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rendering List
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FORM_INDEX } from '@mantine/form';
|
||||||
|
|
||||||
|
function Demo() {
|
||||||
|
const fields = form.getValues().employees.map((item, index) => (
|
||||||
|
<Group key={item.key}>
|
||||||
|
<TextInput
|
||||||
|
key={form.key(`employees.${index}.name`)}
|
||||||
|
{...form.getInputProps(`employees.${index}.name`)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
key={form.key(`employees.${index}.email`)}
|
||||||
|
{...form.getInputProps(`employees.${index}.email`)}
|
||||||
|
/>
|
||||||
|
<ActionIcon onClick={() => form.removeListItem('employees', index)}>
|
||||||
|
<IconTrash />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{fields}
|
||||||
|
<Button onClick={() => form.insertListItem('employees', { name: '', email: '' })}>
|
||||||
|
Add Employee
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation for list items with FORM_INDEX
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
validateInputOnChange: [`employees.${FORM_INDEX}.name`],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Touched & Dirty State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Check if any field was interacted with
|
||||||
|
form.isTouched();
|
||||||
|
form.isTouched('email');
|
||||||
|
|
||||||
|
// Check if values differ from initialValues
|
||||||
|
form.isDirty();
|
||||||
|
form.isDirty('email');
|
||||||
|
|
||||||
|
// Set touched state
|
||||||
|
form.setTouched({ email: true, name: false });
|
||||||
|
form.resetTouched();
|
||||||
|
|
||||||
|
// Set dirty state
|
||||||
|
form.setDirty({ email: true });
|
||||||
|
form.resetDirty(); // Snapshot current values as "clean"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Context
|
||||||
|
|
||||||
|
Share form across components without prop drilling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// formContext.ts
|
||||||
|
import { createFormContext } from '@mantine/form';
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const [FormProvider, useFormContext, useForm] = createFormContext<FormValues>();
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Parent component
|
||||||
|
import { FormProvider, useForm } from './formContext';
|
||||||
|
|
||||||
|
function Parent() {
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
initialValues: { name: '', email: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider form={form}>
|
||||||
|
<NameInput />
|
||||||
|
<EmailInput />
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child component
|
||||||
|
import { useFormContext } from './formContext';
|
||||||
|
|
||||||
|
function NameInput() {
|
||||||
|
const form = useFormContext();
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
key={form.key('name')}
|
||||||
|
{...form.getInputProps('name')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## UseFormReturnType
|
||||||
|
|
||||||
|
Type for passing form as prop:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { UseFormReturnType } from '@mantine/form';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
form: UseFormReturnType<{ name: string; email: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NameField({ form }: Props) {
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
key={form.key('name')}
|
||||||
|
{...form.getInputProps('name')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Built-in Validators
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { isNotEmpty, isEmail, hasLength, matches, isInRange } from '@mantine/form';
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
initialValues: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
age: 0,
|
||||||
|
website: '',
|
||||||
|
terms: false,
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
name: isNotEmpty('Name is required'),
|
||||||
|
email: isEmail('Invalid email'),
|
||||||
|
age: isInRange({ min: 18, max: 99 }, 'Age must be 18-99'),
|
||||||
|
website: matches(/^https?:\/\//, 'Must start with http'),
|
||||||
|
terms: isNotEmpty('Must accept terms'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Focus First Error
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(
|
||||||
|
(values) => { /* success */ },
|
||||||
|
(errors) => {
|
||||||
|
const firstErrorPath = Object.keys(errors)[0];
|
||||||
|
form.getInputNode(firstErrorPath)?.focus();
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
```
|
||||||
215
.agents/skills/mantine-dev/references/getting-started.md
Normal file
215
.agents/skills/mantine-dev/references/getting-started.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Getting Started Reference
|
||||||
|
|
||||||
|
## Vite Template (Recommended)
|
||||||
|
|
||||||
|
The fastest way to start — official template includes everything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone template
|
||||||
|
git clone https://github.com/mantinedev/vite-template my-app
|
||||||
|
cd my-app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
yarn install # or npm install
|
||||||
|
|
||||||
|
# Start development
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Features
|
||||||
|
- PostCSS with `postcss-preset-mantine`
|
||||||
|
- TypeScript configured
|
||||||
|
- Storybook setup
|
||||||
|
- Vitest with React Testing Library
|
||||||
|
- ESLint with `eslint-config-mantine`
|
||||||
|
- Prettier configured
|
||||||
|
|
||||||
|
## Manual Setup
|
||||||
|
|
||||||
|
### 1. Create Vite Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm create vite@latest my-app -- --template react-ts
|
||||||
|
cd my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Core (required)
|
||||||
|
npm install @mantine/core @mantine/hooks
|
||||||
|
|
||||||
|
# PostCSS (required for responsive styles)
|
||||||
|
npm install -D postcss postcss-preset-mantine postcss-simple-vars
|
||||||
|
|
||||||
|
# Optional packages
|
||||||
|
npm install @mantine/form # Forms with validation
|
||||||
|
npm install @mantine/dates dayjs # Date/time components
|
||||||
|
npm install @mantine/notifications # Toast notifications
|
||||||
|
npm install @mantine/modals # Modal manager
|
||||||
|
npm install @mantine/charts recharts # Charts
|
||||||
|
npm install @mantine/dropzone # File upload
|
||||||
|
npm install @mantine/spotlight # Command palette (Cmd+K)
|
||||||
|
npm install @mantine/code-highlight # Code syntax highlighting
|
||||||
|
npm install @mantine/carousel embla-carousel-react # Carousel
|
||||||
|
npm install @mantine/tiptap @tiptap/react @tiptap/pm @tiptap/starter-kit # Rich text editor
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure PostCSS
|
||||||
|
|
||||||
|
Create `postcss.config.cjs`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-preset-mantine': {},
|
||||||
|
'postcss-simple-vars': {
|
||||||
|
variables: {
|
||||||
|
'mantine-breakpoint-xs': '36em',
|
||||||
|
'mantine-breakpoint-sm': '48em',
|
||||||
|
'mantine-breakpoint-md': '62em',
|
||||||
|
'mantine-breakpoint-lg': '75em',
|
||||||
|
'mantine-breakpoint-xl': '88em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Import Styles
|
||||||
|
|
||||||
|
In your entry file (e.g., `src/main.tsx` or `src/App.tsx`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Required - core styles
|
||||||
|
import '@mantine/core/styles.css';
|
||||||
|
|
||||||
|
// Package-specific styles (import only what you use)
|
||||||
|
import '@mantine/dates/styles.css';
|
||||||
|
import '@mantine/notifications/styles.css';
|
||||||
|
import '@mantine/code-highlight/styles.css';
|
||||||
|
import '@mantine/dropzone/styles.css';
|
||||||
|
import '@mantine/spotlight/styles.css';
|
||||||
|
import '@mantine/carousel/styles.css';
|
||||||
|
import '@mantine/tiptap/styles.css';
|
||||||
|
// Note: @mantine/form and @mantine/hooks have no styles
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Setup MantineProvider
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/App.tsx
|
||||||
|
import '@mantine/core/styles.css';
|
||||||
|
|
||||||
|
import { MantineProvider, createTheme } from '@mantine/core';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
// Your theme customization
|
||||||
|
primaryColor: 'blue',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
});
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<MantineProvider theme={theme}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
{/* Your routes */}
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Recommended structure for Mantine projects:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── Layout/
|
||||||
|
│ │ ├── AppShell.tsx
|
||||||
|
│ │ ├── Header.tsx
|
||||||
|
│ │ └── Navbar.tsx
|
||||||
|
│ └── ui/ # Custom UI components
|
||||||
|
├── pages/
|
||||||
|
│ ├── Home.tsx
|
||||||
|
│ └── Settings.tsx
|
||||||
|
├── hooks/ # Custom hooks
|
||||||
|
├── theme/
|
||||||
|
│ ├── index.ts # createTheme export
|
||||||
|
│ └── components.ts # Component default props
|
||||||
|
├── utils/
|
||||||
|
├── App.tsx
|
||||||
|
├── main.tsx
|
||||||
|
└── App.module.css # CSS modules
|
||||||
|
```
|
||||||
|
|
||||||
|
## VS Code Setup
|
||||||
|
|
||||||
|
Install recommended extensions:
|
||||||
|
|
||||||
|
1. **PostCSS Intellisense and Highlighting**
|
||||||
|
For postcss syntax support and `$variable` recognition.
|
||||||
|
|
||||||
|
2. **CSS Variable Autocomplete**
|
||||||
|
For Mantine CSS variables autocomplete.
|
||||||
|
|
||||||
|
Create `.vscode/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cssVariables.lookupFiles": [
|
||||||
|
"**/*.css",
|
||||||
|
"node_modules/@mantine/core/styles.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Configuration
|
||||||
|
|
||||||
|
Mantine is fully typed. Your `tsconfig.json` should have:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Styles Not Loading
|
||||||
|
Ensure `@mantine/core/styles.css` is imported before any component usage.
|
||||||
|
|
||||||
|
### PostCSS Mixins Not Working
|
||||||
|
Check that `postcss-preset-mantine` is installed and configured in `postcss.config.cjs`.
|
||||||
|
|
||||||
|
### Dark Mode Flash
|
||||||
|
For SSR apps, add `ColorSchemeScript` to `<head>`:
|
||||||
|
```tsx
|
||||||
|
import { ColorSchemeScript } from '@mantine/core';
|
||||||
|
|
||||||
|
// In your HTML head
|
||||||
|
<ColorSchemeScript defaultColorScheme="auto" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hydration Warning
|
||||||
|
Spread `mantineHtmlProps` on `<html>` element:
|
||||||
|
```tsx
|
||||||
|
import { mantineHtmlProps } from '@mantine/core';
|
||||||
|
|
||||||
|
<html {...mantineHtmlProps}>
|
||||||
|
```
|
||||||
344
.agents/skills/mantine-dev/references/hooks.md
Normal file
344
.agents/skills/mantine-dev/references/hooks.md
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
# Hooks Reference
|
||||||
|
|
||||||
|
`@mantine/hooks` provides 75+ React hooks. No styles needed — works independently.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @mantine/hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### useDisclosure
|
||||||
|
|
||||||
|
Boolean state for modals/menus:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const [opened, { open, close, toggle }] = useDisclosure(false);
|
||||||
|
|
||||||
|
// With callbacks
|
||||||
|
const [opened, handlers] = useDisclosure(false, {
|
||||||
|
onOpen: () => console.log('Opened'),
|
||||||
|
onClose: () => console.log('Closed'),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### useToggle
|
||||||
|
|
||||||
|
Cycle through values:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useToggle } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const [value, toggle] = useToggle(['blue', 'orange', 'cyan']);
|
||||||
|
// toggle() cycles, toggle('cyan') sets specific
|
||||||
|
|
||||||
|
// Boolean
|
||||||
|
const [active, toggle] = useToggle([false, true]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### useCounter
|
||||||
|
|
||||||
|
Numeric counter with min/max:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCounter } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const [count, { increment, decrement, set, reset }] = useCounter(0, { min: 0, max: 10 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### useListState
|
||||||
|
|
||||||
|
Array state management:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useListState } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const [values, handlers] = useListState([1, 2, 3]);
|
||||||
|
|
||||||
|
handlers.append(4); // Add to end
|
||||||
|
handlers.prepend(0); // Add to start
|
||||||
|
handlers.insert(2, 99); // Insert at index
|
||||||
|
handlers.remove(1); // Remove at index
|
||||||
|
handlers.reorder({ from: 0, to: 2 }); // Move item
|
||||||
|
handlers.filter((item) => item > 2); // Filter
|
||||||
|
handlers.apply((item) => item * 2); // Transform all
|
||||||
|
```
|
||||||
|
|
||||||
|
### useSetState
|
||||||
|
|
||||||
|
Object state with partial updates:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useSetState } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const [state, setState] = useSetState({ name: '', email: '', age: 0 });
|
||||||
|
setState({ name: 'John' }); // Partial update, others unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
### useLocalStorage / useSessionStorage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const [value, setValue, removeValue] = useLocalStorage({
|
||||||
|
key: 'my-key',
|
||||||
|
defaultValue: 'light',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-serializes objects
|
||||||
|
const [user, setUser] = useLocalStorage({
|
||||||
|
key: 'user',
|
||||||
|
defaultValue: { name: '', preferences: {} },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Input/Debounce
|
||||||
|
|
||||||
|
### useDebouncedValue
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [debounced] = useDebouncedValue(value, 300);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// API call with debounced value
|
||||||
|
}, [debounced]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### useDebouncedCallback
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDebouncedCallback } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const search = useDebouncedCallback(async (query: string) => {
|
||||||
|
await searchAPI(query);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
<TextInput onChange={(e) => search(e.target.value)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### useInputState
|
||||||
|
|
||||||
|
Simpler input handling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useInputState } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const [name, setName] = useInputState('');
|
||||||
|
<TextInput value={name} onChange={setName} />
|
||||||
|
|
||||||
|
const [checked, setChecked] = useInputState(false);
|
||||||
|
<Checkbox checked={checked} onChange={setChecked} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Interactions
|
||||||
|
|
||||||
|
### useClickOutside
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useClickOutside } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const ref = useClickOutside(() => close());
|
||||||
|
<Paper ref={ref}>Click outside to close</Paper>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useHover
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useHover } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const { hovered, ref } = useHover();
|
||||||
|
<Box ref={ref} bg={hovered ? 'blue' : 'gray'}>Hover me</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useFocusWithin
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useFocusWithin } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const { ref, focused } = useFocusWithin();
|
||||||
|
<Box ref={ref} style={{ outline: focused ? '2px solid blue' : 'none' }}>
|
||||||
|
<TextInput /><TextInput />
|
||||||
|
</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useMediaQuery
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMediaQuery } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
const matches = useMediaQuery('(min-width: 48em)'); // sm breakpoint
|
||||||
|
|
||||||
|
return isMobile ? <MobileNav /> : <DesktopNav />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### useViewportSize
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useViewportSize } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const { width, height } = useViewportSize();
|
||||||
|
```
|
||||||
|
|
||||||
|
### useElementSize
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useElementSize } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const { ref, width, height } = useElementSize();
|
||||||
|
<Box ref={ref}>Tracks this element's size</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useScrollIntoView
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useScrollIntoView } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const { scrollIntoView, targetRef } = useScrollIntoView<HTMLDivElement>({
|
||||||
|
offset: 60,
|
||||||
|
duration: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
<Button onClick={() => scrollIntoView()}>Scroll</Button>
|
||||||
|
<div ref={targetRef}>Target</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useIntersection / useInViewport
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useIntersection, useInViewport } from '@mantine/hooks';
|
||||||
|
|
||||||
|
// Detailed intersection info
|
||||||
|
const { ref, entry } = useIntersection({ threshold: 0.5 });
|
||||||
|
|
||||||
|
// Simple visibility check
|
||||||
|
const { ref, inViewport } = useInViewport();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilities
|
||||||
|
|
||||||
|
### useClipboard
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useClipboard } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const clipboard = useClipboard({ timeout: 500 });
|
||||||
|
|
||||||
|
<Button onClick={() => clipboard.copy('Text')} color={clipboard.copied ? 'teal' : 'blue'}>
|
||||||
|
{clipboard.copied ? 'Copied!' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useHotkeys
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useHotkeys, getHotkeyHandler } from '@mantine/hooks';
|
||||||
|
|
||||||
|
useHotkeys([
|
||||||
|
['mod+S', () => save()],
|
||||||
|
['ctrl+K', () => search()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// On specific input
|
||||||
|
<input onKeyDown={getHotkeyHandler([
|
||||||
|
['mod+Enter', submit],
|
||||||
|
['Escape', cancel],
|
||||||
|
])} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### useFullscreen
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useFullscreen } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const { toggle, fullscreen } = useFullscreen();
|
||||||
|
<Button onClick={toggle}>{fullscreen ? 'Exit' : 'Enter'} Fullscreen</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useIdle
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useIdle } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const idle = useIdle(5000); // 5 seconds
|
||||||
|
<Text>{idle ? 'User is idle' : 'User is active'}</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useInterval / useTimeout
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useInterval, useTimeout } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const interval = useInterval(() => tick(), 1000);
|
||||||
|
interval.start(); interval.stop(); interval.toggle();
|
||||||
|
|
||||||
|
const { start, clear } = useTimeout(() => action(), 3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### useDocumentTitle
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDocumentTitle } from '@mantine/hooks';
|
||||||
|
|
||||||
|
useDocumentTitle('My Page Title');
|
||||||
|
```
|
||||||
|
|
||||||
|
### useOs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useOs } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const os = useOs(); // 'macos' | 'ios' | 'windows' | 'android' | 'linux'
|
||||||
|
<Text>Shortcut: {os === 'macos' ? '⌘' : 'Ctrl'}</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useNetwork
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useNetwork } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const { online, downlink, effectiveType } = useNetwork();
|
||||||
|
return online ? <App /> : <OfflineMessage />;
|
||||||
|
```
|
||||||
|
|
||||||
|
### usePrevious
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { usePrevious } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
const previous = usePrevious(value);
|
||||||
|
```
|
||||||
|
|
||||||
|
### useMergedRef
|
||||||
|
|
||||||
|
Combine multiple refs:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMergedRef } from '@mantine/hooks';
|
||||||
|
|
||||||
|
const myRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { ref: hookRef } = useHover();
|
||||||
|
const mergedRef = useMergedRef(myRef, hookRef);
|
||||||
|
|
||||||
|
<Box ref={mergedRef}>Content</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Hook List
|
||||||
|
|
||||||
|
**State:** useDisclosure, useToggle, useCounter, useListState, useSetState, useQueue, usePagination
|
||||||
|
|
||||||
|
**Storage:** useLocalStorage, useSessionStorage, readLocalStorageValue
|
||||||
|
|
||||||
|
**Input:** useDebouncedValue, useDebouncedState, useDebouncedCallback, useInputState, useValidatedState, useUncontrolled
|
||||||
|
|
||||||
|
**UI:** useClickOutside, useHover, useFocusWithin, useFocusReturn, useFocusTrap, useMediaQuery, useViewportSize, useElementSize, useResizeObserver, useScrollIntoView, useIntersection, useInViewport, useWindowScroll, useMouse, useMove
|
||||||
|
|
||||||
|
**Utilities:** useClipboard, useHotkeys, useFullscreen, useIdle, useInterval, useTimeout, useDocumentTitle, useDocumentVisibility, useFavicon, useOs, useNetwork, usePrevious, useMergedRef, useId, useForceUpdate, useReducedMotion, useTextSelection, useWindowEvent, useEventListener, useEyeDropper, useHash, useHeadroom, useLogger, useMutationObserver, useOrientation, usePageLeave, usePinchToZoom, useStateHistory
|
||||||
472
.agents/skills/mantine-dev/references/styling.md
Normal file
472
.agents/skills/mantine-dev/references/styling.md
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
# Styling & Theming Reference
|
||||||
|
|
||||||
|
Mantine styling: MantineProvider, theme object, CSS modules, style props, and Styles API.
|
||||||
|
|
||||||
|
## MantineProvider
|
||||||
|
|
||||||
|
Required wrapper for all Mantine components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createTheme, MantineProvider } from '@mantine/core';
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
primaryColor: 'blue',
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
});
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<MantineProvider theme={theme}>
|
||||||
|
{/* App content */}
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `theme` | `MantineThemeOverride` | - | Theme customization |
|
||||||
|
| `defaultColorScheme` | `'light' \| 'dark' \| 'auto'` | `'light'` | Default color scheme |
|
||||||
|
| `forceColorScheme` | `'light' \| 'dark'` | - | Force specific scheme |
|
||||||
|
| `env` | `'default' \| 'test'` | `'default'` | Disable transitions for tests |
|
||||||
|
|
||||||
|
## Theme Object
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createTheme, rem } from '@mantine/core';
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
// Colors
|
||||||
|
primaryColor: 'blue',
|
||||||
|
primaryShade: { light: 6, dark: 8 },
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
headings: {
|
||||||
|
fontFamily: 'Greycliff CF, sans-serif',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Spacing & Sizing
|
||||||
|
spacing: { xs: rem(10), sm: rem(12), md: rem(16), lg: rem(20), xl: rem(32) },
|
||||||
|
radius: { xs: rem(2), sm: rem(4), md: rem(8), lg: rem(16), xl: rem(32) },
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
defaultRadius: 'md',
|
||||||
|
cursorType: 'pointer',
|
||||||
|
respectReducedMotion: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Colors
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createTheme, MantineColorsTuple } from '@mantine/core';
|
||||||
|
|
||||||
|
const brand: MantineColorsTuple = [
|
||||||
|
'#f0f9ff', '#e0f2fe', '#bae6fd', '#7dd3fc', '#38bdf8',
|
||||||
|
'#0ea5e9', '#0284c7', '#0369a1', '#075985', '#0c4a6e',
|
||||||
|
];
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
colors: { brand },
|
||||||
|
primaryColor: 'brand',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Color Scheme (Dark Mode)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
|
||||||
|
|
||||||
|
function ColorSchemeToggle() {
|
||||||
|
const { setColorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||||
|
const computed = useComputedColorScheme('light'); // Resolved value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setColorScheme('light')}>Light</Button>
|
||||||
|
<Button onClick={() => setColorScheme('dark')}>Dark</Button>
|
||||||
|
<Button onClick={toggleColorScheme}>Toggle</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSR: Prevent flash of wrong color scheme
|
||||||
|
import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core';
|
||||||
|
|
||||||
|
<html {...mantineHtmlProps}>
|
||||||
|
<head>
|
||||||
|
<ColorSchemeScript defaultColorScheme="auto" />
|
||||||
|
</head>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Style Props
|
||||||
|
|
||||||
|
All components accept style props for quick styling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Box, Text, Button } from '@mantine/core';
|
||||||
|
|
||||||
|
function Demo() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
p="md" // padding
|
||||||
|
m="lg" // margin
|
||||||
|
mt="xl" // margin-top
|
||||||
|
bg="blue.6" // background (color.shade)
|
||||||
|
c="white" // color
|
||||||
|
w={200} // width (number = px)
|
||||||
|
h="100%" // height
|
||||||
|
maw={500} // max-width
|
||||||
|
pos="relative" // position
|
||||||
|
ta="center" // text-align
|
||||||
|
fz="sm" // font-size
|
||||||
|
fw={700} // font-weight
|
||||||
|
ff="monospace" // font-family
|
||||||
|
lh={1.5} // line-height
|
||||||
|
style={{ borderRadius: 'var(--mantine-radius-md)' }}
|
||||||
|
>
|
||||||
|
<Text c="dimmed" fz="xs" tt="uppercase">
|
||||||
|
Uppercase dimmed text
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Style Props
|
||||||
|
|
||||||
|
| Prop | CSS Property | Example |
|
||||||
|
|------|--------------|---------|
|
||||||
|
| `m`, `mx`, `my`, `mt`, `mr`, `mb`, `ml` | margin | `m="md"`, `mt={20}` |
|
||||||
|
| `p`, `px`, `py`, `pt`, `pr`, `pb`, `pl` | padding | `p="lg"` |
|
||||||
|
| `w`, `h`, `maw`, `mah`, `miw`, `mih` | width, height, max/min | `w="100%"` |
|
||||||
|
| `c` | color | `c="blue.6"`, `c="dimmed"` |
|
||||||
|
| `bg` | background-color | `bg="gray.1"` |
|
||||||
|
| `fz` | font-size | `fz="sm"`, `fz={14}` |
|
||||||
|
| `fw` | font-weight | `fw={500}`, `fw="bold"` |
|
||||||
|
| `ta` | text-align | `ta="center"` |
|
||||||
|
| `td` | text-decoration | `td="underline"` |
|
||||||
|
| `tt` | text-transform | `tt="uppercase"` |
|
||||||
|
| `ff` | font-family | `ff="monospace"` |
|
||||||
|
| `lh` | line-height | `lh={1.5}` |
|
||||||
|
| `pos` | position | `pos="absolute"` |
|
||||||
|
| `top`, `left`, `right`, `bottom` | position offsets | `top={10}` |
|
||||||
|
| `display` | display | `display="flex"` |
|
||||||
|
| `opacity` | opacity | `opacity={0.5}` |
|
||||||
|
|
||||||
|
### Responsive Props
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Box
|
||||||
|
w={{ base: '100%', sm: '50%', md: 400 }}
|
||||||
|
p={{ base: 'xs', md: 'xl' }}
|
||||||
|
display={{ base: 'none', md: 'block' }}
|
||||||
|
>
|
||||||
|
Responsive box
|
||||||
|
</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Modules
|
||||||
|
|
||||||
|
Recommended styling approach. Create `.module.css` files:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Button.module.css */
|
||||||
|
.root {
|
||||||
|
background-color: var(--mantine-color-blue-6);
|
||||||
|
|
||||||
|
@mixin hover {
|
||||||
|
background-color: var(--mantine-color-blue-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@mixin smaller-than sm {
|
||||||
|
font-size: var(--mantine-font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin larger-than md {
|
||||||
|
padding: var(--mantine-spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-blue-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--mantine-color-blue-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--mantine-color-white);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@mantine/core';
|
||||||
|
import classes from './Button.module.css';
|
||||||
|
|
||||||
|
function Demo() {
|
||||||
|
return (
|
||||||
|
<Button classNames={classes}>
|
||||||
|
Styled button
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## PostCSS Preset
|
||||||
|
|
||||||
|
`postcss-preset-mantine` provides:
|
||||||
|
|
||||||
|
### Mixins
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Hover state */
|
||||||
|
@mixin hover {
|
||||||
|
/* Hover-only styles */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive breakpoints */
|
||||||
|
@mixin smaller-than sm { }
|
||||||
|
@mixin larger-than md { }
|
||||||
|
|
||||||
|
/* Color scheme */
|
||||||
|
@mixin light { }
|
||||||
|
@mixin dark { }
|
||||||
|
|
||||||
|
/* RTL support */
|
||||||
|
@mixin rtl { }
|
||||||
|
@mixin ltr { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
```css
|
||||||
|
.element {
|
||||||
|
/* rem() - convert to rem */
|
||||||
|
font-size: rem(16px); /* 1rem */
|
||||||
|
|
||||||
|
/* em() - convert to em */
|
||||||
|
padding: em(24px); /* 1.5em */
|
||||||
|
|
||||||
|
/* light-dark() - color scheme values */
|
||||||
|
background: light-dark(white, black);
|
||||||
|
|
||||||
|
/* alpha() - add opacity to color */
|
||||||
|
background: alpha(var(--mantine-color-blue-5), 0.5);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styles API
|
||||||
|
|
||||||
|
Override internal component styles:
|
||||||
|
|
||||||
|
### classNames Prop
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TextInput } from '@mantine/core';
|
||||||
|
import classes from './TextInput.module.css';
|
||||||
|
|
||||||
|
// CSS module with selectors matching Styles API
|
||||||
|
// .root, .input, .label, .error, etc.
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
classNames={{
|
||||||
|
root: classes.root,
|
||||||
|
input: classes.input,
|
||||||
|
label: classes.label,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### styles Prop (CSS-in-JS)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<TextInput
|
||||||
|
styles={{
|
||||||
|
root: { marginBottom: 20 },
|
||||||
|
input: { backgroundColor: 'var(--mantine-color-gray-0)' },
|
||||||
|
label: { fontWeight: 700 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### styles Function
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<TextInput
|
||||||
|
styles={(theme, props) => ({
|
||||||
|
input: {
|
||||||
|
borderColor: props.error
|
||||||
|
? theme.colors.red[6]
|
||||||
|
: theme.colors.gray[4],
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finding Selectors
|
||||||
|
|
||||||
|
All Styles API selectors are documented for each component. Common patterns:
|
||||||
|
|
||||||
|
- `root` - Root element
|
||||||
|
- `label` - Label text
|
||||||
|
- `input` - Input element
|
||||||
|
- `wrapper` - Input wrapper
|
||||||
|
- `error` - Error message
|
||||||
|
- `description` - Description text
|
||||||
|
- `required` - Required asterisk
|
||||||
|
- `section` - Input sections (left/right icons)
|
||||||
|
|
||||||
|
## hiddenFrom / visibleFrom
|
||||||
|
|
||||||
|
Hide/show at breakpoints:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Text } from '@mantine/core';
|
||||||
|
|
||||||
|
<Text hiddenFrom="sm">Hidden on sm and larger</Text>
|
||||||
|
<Text visibleFrom="md">Visible only on md and larger</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
## lightHidden / darkHidden
|
||||||
|
|
||||||
|
Hide based on color scheme:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Text lightHidden>Only in dark mode</Text>
|
||||||
|
<Text darkHidden>Only in light mode</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Box Component
|
||||||
|
|
||||||
|
Base component for custom styling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Box } from '@mantine/core';
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="section" // Render as different element
|
||||||
|
className={classes.wrapper}
|
||||||
|
p="md"
|
||||||
|
bg="gray.1"
|
||||||
|
style={{ borderRadius: 'var(--mantine-radius-md)' }}
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Polymorphic Components
|
||||||
|
|
||||||
|
Many components accept `component` prop:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@mantine/core';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
// Render Button as Link
|
||||||
|
<Button component={Link} to="/about">
|
||||||
|
About
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// Render as native anchor
|
||||||
|
<Button component="a" href="https://example.com">
|
||||||
|
External
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Variables in Styles
|
||||||
|
|
||||||
|
Access theme values:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-blue-6)',
|
||||||
|
padding: 'var(--mantine-spacing-md)',
|
||||||
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-md)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Styled with CSS variables
|
||||||
|
</Box>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Global Styles
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In your CSS
|
||||||
|
:root {
|
||||||
|
--my-custom-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target Mantine root element */
|
||||||
|
[data-mantine-color-scheme="dark"] {
|
||||||
|
--my-custom-color: #ff8787;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global component overrides */
|
||||||
|
.mantine-Button-root {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## rem() and em() Utilities
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { rem, em } from '@mantine/core';
|
||||||
|
|
||||||
|
// In styles or inline
|
||||||
|
<Box style={{ fontSize: rem(16), padding: rem(24) }} />
|
||||||
|
|
||||||
|
// rem(16) => '1rem'
|
||||||
|
// em(24) => '1.5em'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Style Props vs CSS Modules
|
||||||
|
|
||||||
|
| Use Case | Recommended |
|
||||||
|
|----------|-------------|
|
||||||
|
| Quick prototyping | Style props |
|
||||||
|
| Simple spacing/colors | Style props |
|
||||||
|
| Complex hover/focus states | CSS modules |
|
||||||
|
| Responsive layouts | CSS modules |
|
||||||
|
| Reusable component styles | CSS modules |
|
||||||
|
| Performance critical | CSS modules |
|
||||||
|
|
||||||
|
## Component Default Props (Theme)
|
||||||
|
|
||||||
|
Override defaults globally:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const theme = createTheme({
|
||||||
|
components: {
|
||||||
|
Button: Button.extend({
|
||||||
|
defaultProps: { variant: 'outline', size: 'md', radius: 'xl' },
|
||||||
|
}),
|
||||||
|
TextInput: TextInput.extend({
|
||||||
|
defaultProps: { size: 'md' },
|
||||||
|
classNames: { root: 'my-input-root', input: 'my-input' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Variables Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
--mantine-color-{color}-{shade} // Colors (0-9)
|
||||||
|
--mantine-primary-color-{shade} // Primary color
|
||||||
|
--mantine-spacing-{size} // xs, sm, md, lg, xl
|
||||||
|
--mantine-radius-{size} // xs, sm, md, lg, xl
|
||||||
|
--mantine-font-family // Main font
|
||||||
|
--mantine-font-size-{size} // xs, sm, md, lg, xl
|
||||||
|
--mantine-breakpoint-{size} // Responsive breakpoints
|
||||||
|
```
|
||||||
395
.agents/skills/mantine-dev/references/testing.md
Normal file
395
.agents/skills/mantine-dev/references/testing.md
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
# Testing Reference
|
||||||
|
|
||||||
|
Guide for testing Mantine applications with Vitest and React Testing Library.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D vitest jsdom @testing-library/dom @testing-library/jest-dom @testing-library/react @testing-library/user-event
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vitest Configuration
|
||||||
|
|
||||||
|
Add to `vite.config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './vitest.setup.mjs',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup File
|
||||||
|
|
||||||
|
Create `vitest.setup.mjs`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
// Fix for getComputedStyle
|
||||||
|
const { getComputedStyle } = window;
|
||||||
|
window.getComputedStyle = (elt) => getComputedStyle(elt);
|
||||||
|
|
||||||
|
// Mock scrollIntoView
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||||
|
|
||||||
|
// Mock matchMedia (required by Mantine)
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock ResizeObserver (required by some Mantine components)
|
||||||
|
class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
window.ResizeObserver = ResizeObserver;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Render Function
|
||||||
|
|
||||||
|
All Mantine components require MantineProvider. Create custom render:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// test-utils/render.tsx
|
||||||
|
import { render as testingLibraryRender } from '@testing-library/react';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import { theme } from '../src/theme'; // Your theme if any
|
||||||
|
|
||||||
|
export function render(ui: React.ReactNode) {
|
||||||
|
return testingLibraryRender(<>{ui}</>, {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<MantineProvider theme={theme} env="test">
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important: env="test"
|
||||||
|
|
||||||
|
Setting `env="test"` on MantineProvider:
|
||||||
|
- Disables CSS transitions (tests run faster)
|
||||||
|
- Disables portals (elements render in place, easier to query)
|
||||||
|
|
||||||
|
## Export Test Utilities
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// test-utils/index.ts
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
export * from '@testing-library/react';
|
||||||
|
export { render } from './render';
|
||||||
|
export { userEvent };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing Tests
|
||||||
|
|
||||||
|
### Basic Component Test
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Button.test.tsx
|
||||||
|
import { render, screen } from '../test-utils';
|
||||||
|
import { Button } from '@mantine/core';
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(<Button>Click me</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles click events', async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(<Button onClick={onClick}>Click</Button>);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button'));
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Form Inputs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from '../test-utils';
|
||||||
|
import { userEvent } from '../test-utils';
|
||||||
|
import { TextInput } from '@mantine/core';
|
||||||
|
|
||||||
|
describe('TextInput', () => {
|
||||||
|
it('accepts user input', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<TextInput onChange={onChange} label="Name" />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Name');
|
||||||
|
await userEvent.type(input, 'John Doe');
|
||||||
|
|
||||||
|
expect(input).toHaveValue('John Doe');
|
||||||
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error', () => {
|
||||||
|
render(<TextInput label="Email" error="Invalid email" />);
|
||||||
|
expect(screen.getByText('Invalid email')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing useForm
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen, waitFor } from '../test-utils';
|
||||||
|
import { userEvent } from '../test-utils';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { TextInput, Button } from '@mantine/core';
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const form = useForm({
|
||||||
|
mode: 'uncontrolled',
|
||||||
|
initialValues: { email: '' },
|
||||||
|
validate: {
|
||||||
|
email: (v) => (!v.includes('@') ? 'Invalid email' : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit((values) => console.log(values))}>
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
key={form.key('email')}
|
||||||
|
{...form.getInputProps('email')}
|
||||||
|
/>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Form', () => {
|
||||||
|
it('validates on submit', async () => {
|
||||||
|
render(<TestForm />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Invalid email')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears error on valid input', async () => {
|
||||||
|
render(<TestForm />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Email'), 'test@email.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
|
||||||
|
|
||||||
|
expect(screen.queryByText('Invalid email')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Modals
|
||||||
|
|
||||||
|
With `env="test"`, modals render in place (no portal):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from '../test-utils';
|
||||||
|
import { userEvent } from '../test-utils';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { Modal, Button } from '@mantine/core';
|
||||||
|
|
||||||
|
function ModalDemo() {
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={open}>Open</Button>
|
||||||
|
<Modal opened={opened} onClose={close} title="Test Modal">
|
||||||
|
Modal Content
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Modal', () => {
|
||||||
|
it('opens when button is clicked', async () => {
|
||||||
|
render(<ModalDemo />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Modal Content')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /open/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Modal Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Select/Dropdown
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from '../test-utils';
|
||||||
|
import { userEvent } from '../test-utils';
|
||||||
|
import { Select } from '@mantine/core';
|
||||||
|
|
||||||
|
describe('Select', () => {
|
||||||
|
it('selects an option', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<Select
|
||||||
|
label="Country"
|
||||||
|
data={['USA', 'Canada', 'UK']}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
await userEvent.click(screen.getByLabelText('Country'));
|
||||||
|
|
||||||
|
// Select option
|
||||||
|
await userEvent.click(screen.getByRole('option', { name: 'Canada' }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('Canada');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Color Scheme
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from '../test-utils';
|
||||||
|
import { useMantineColorScheme } from '@mantine/core';
|
||||||
|
|
||||||
|
function ColorSchemeToggle() {
|
||||||
|
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||||
|
return (
|
||||||
|
<button onClick={toggleColorScheme}>
|
||||||
|
Current: {colorScheme}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Color Scheme', () => {
|
||||||
|
it('toggles color scheme', async () => {
|
||||||
|
render(<ColorSchemeToggle />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/current: light/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(screen.getByText(/current: dark/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Notifications
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen, waitFor } from '../test-utils';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { Notifications } from '@mantine/notifications';
|
||||||
|
import { Button } from '@mantine/core';
|
||||||
|
|
||||||
|
function NotificationDemo() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Notifications />
|
||||||
|
<Button onClick={() => notifications.show({ message: 'Hello!' })}>
|
||||||
|
Show Notification
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Notifications', () => {
|
||||||
|
it('shows notification', async () => {
|
||||||
|
render(<NotificationDemo />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Hello!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Hooks
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
|
||||||
|
describe('useDisclosure', () => {
|
||||||
|
it('toggles state', () => {
|
||||||
|
const { result } = renderHook(() => useDisclosure(false));
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current[1].open();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current[1].close();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
Add to `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### matchMedia Error
|
||||||
|
Ensure `matchMedia` mock is in setup file.
|
||||||
|
|
||||||
|
### ResizeObserver Error
|
||||||
|
Ensure `ResizeObserver` mock is in setup file.
|
||||||
|
|
||||||
|
### Portal Elements Not Found
|
||||||
|
Use `env="test"` on MantineProvider to disable portals.
|
||||||
|
|
||||||
|
### Transitions Cause Timing Issues
|
||||||
|
Use `env="test"` to disable transitions.
|
||||||
|
|
||||||
|
### Elements Not in Document
|
||||||
|
Make sure to use custom `render` that includes MantineProvider.
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Setup file mocks matchMedia and ResizeObserver
|
||||||
|
- [ ] Custom render includes MantineProvider with `env="test"`
|
||||||
|
- [ ] Use `userEvent` for user interactions (not `fireEvent`)
|
||||||
|
- [ ] Use `waitFor` for async operations
|
||||||
|
- [ ] Use `getByRole` with accessible names when possible
|
||||||
|
- [ ] Test error states and edge cases
|
||||||
186
.agents/skills/prisma-database-setup/SKILL.md
Normal file
186
.agents/skills/prisma-database-setup/SKILL.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
---
|
||||||
|
name: prisma-database-setup
|
||||||
|
description: Guides for configuring Prisma with different database providers (PostgreSQL, MySQL, SQLite, MongoDB, etc.). Use when setting up a new project, changing databases, or troubleshooting connection issues. Triggers on "configure postgres", "connect to mysql", "setup mongodb", "sqlite setup".
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: prisma
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prisma Database Setup
|
||||||
|
|
||||||
|
Comprehensive guides for configuring Prisma ORM with various database providers.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Reference this skill when:
|
||||||
|
- Initializing a new Prisma project
|
||||||
|
- Switching database providers
|
||||||
|
- Configuring connection strings and environment variables
|
||||||
|
- Troubleshooting database connection issues
|
||||||
|
- Setting up database-specific features
|
||||||
|
- Generating and instantiating Prisma Client
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Impact | Prefix |
|
||||||
|
|----------|----------|--------|--------|
|
||||||
|
| 1 | Provider Guides | CRITICAL | provider names |
|
||||||
|
| 2 | Prisma Postgres | HIGH | `prisma-postgres` |
|
||||||
|
| 3 | Client Setup | CRITICAL | `prisma-client-setup` |
|
||||||
|
|
||||||
|
## System Prerequisites (Prisma ORM 7)
|
||||||
|
|
||||||
|
- **Node.js 20.19.0+**
|
||||||
|
- **TypeScript 5.4.0+**
|
||||||
|
|
||||||
|
## Bun Runtime
|
||||||
|
|
||||||
|
If you're using Bun, run Prisma CLI commands with `bunx --bun prisma ...` so Prisma uses the Bun runtime instead of falling back to Node.js.
|
||||||
|
|
||||||
|
## Supported Databases
|
||||||
|
|
||||||
|
| Database | Provider String | Notes |
|
||||||
|
|----------|-----------------|-------|
|
||||||
|
| PostgreSQL | `postgresql` | Default, full feature support |
|
||||||
|
| MySQL | `mysql` | Widespread support, some JSON diffs |
|
||||||
|
| SQLite | `sqlite` | Local file-based, no enum/scalar lists |
|
||||||
|
| MongoDB | `mongodb` | **NOT SUPPORTED IN v7** (Use v6) |
|
||||||
|
| SQL Server | `sqlserver` | Microsoft ecosystem |
|
||||||
|
| CockroachDB | `cockroachdb` | Distributed SQL, Postgres-compatible |
|
||||||
|
| Prisma Postgres | `postgresql` | Managed serverless database |
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
Prisma v7 uses two main files for configuration:
|
||||||
|
|
||||||
|
1. **`prisma/schema.prisma`**: Defines the `datasource` block.
|
||||||
|
2. **`prisma.config.ts`**: Configures the connection URL (replaces env loading in schema).
|
||||||
|
|
||||||
|
## Driver Adapters (Prisma ORM 7)
|
||||||
|
|
||||||
|
Prisma ORM 7 uses the query compiler by default, which **requires a driver adapter**. Choose the adapter and driver for your database and pass the adapter to `PrismaClient`.
|
||||||
|
|
||||||
|
| Database | Adapter | JS Driver |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| PostgreSQL | `@prisma/adapter-pg` | `pg` |
|
||||||
|
| CockroachDB | `@prisma/adapter-pg` | `pg` |
|
||||||
|
| Prisma Postgres | `@prisma/adapter-ppg` | `@prisma/ppg` |
|
||||||
|
| MySQL / MariaDB | `@prisma/adapter-mariadb` | `mariadb` |
|
||||||
|
| SQLite | `@prisma/adapter-better-sqlite3` | `better-sqlite3` |
|
||||||
|
| SQLite (Turso/LibSQL) | `@prisma/adapter-libsql` | `@libsql/client` |
|
||||||
|
| SQL Server | `@prisma/adapter-mssql` | `node-mssql` |
|
||||||
|
|
||||||
|
Example (PostgreSQL):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaClient } from '../generated/client'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prisma Client Setup (Required)
|
||||||
|
|
||||||
|
Prisma Client must be installed and generated for any database.
|
||||||
|
|
||||||
|
1. Install Prisma CLI and Prisma Client:
|
||||||
|
```bash
|
||||||
|
npm install prisma --save-dev
|
||||||
|
npm install @prisma/client
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Add a generator block (output is required in Prisma v7):
|
||||||
|
```prisma
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Generate Prisma Client:
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Instantiate Prisma Client with the database-specific driver adapter:
|
||||||
|
```typescript
|
||||||
|
import { PrismaClient } from '../generated/client'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Re-run `prisma generate` after every schema change.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MySQL
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SQLite
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MongoDB (Prisma v6 only)
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "mongodb"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rule Files
|
||||||
|
|
||||||
|
See individual rule files for detailed setup instructions:
|
||||||
|
|
||||||
|
```
|
||||||
|
references/postgresql.md
|
||||||
|
references/mysql.md
|
||||||
|
references/sqlite.md
|
||||||
|
references/mongodb.md
|
||||||
|
references/sqlserver.md
|
||||||
|
references/cockroachdb.md
|
||||||
|
references/prisma-postgres.md
|
||||||
|
references/prisma-client-setup.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Choose the provider reference file for your database, then apply `references/prisma-client-setup.md` to complete client generation and adapter setup.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# CockroachDB Setup
|
||||||
|
|
||||||
|
Configure Prisma with CockroachDB.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- CockroachDB cluster
|
||||||
|
|
||||||
|
## 1. Schema Configuration
|
||||||
|
|
||||||
|
In `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "cockroachdb"
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Config Configuration (v7)
|
||||||
|
|
||||||
|
In `prisma.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineConfig, env } from 'prisma/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
datasource: {
|
||||||
|
url: env('DATABASE_URL'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Environment Variable
|
||||||
|
|
||||||
|
In `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://user:password@host:26257/db?sslmode=verify-full"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: CockroachDB uses the PostgreSQL wire protocol, so the URL often looks like postgresql, but the provider **MUST** be `cockroachdb` in the schema to handle specific CRDB features correctly.
|
||||||
|
|
||||||
|
## Driver Adapter (Prisma ORM 7 required)
|
||||||
|
|
||||||
|
Prisma ORM 7 uses the query compiler by default, so you must use a driver adapter. CockroachDB is PostgreSQL-compatible, so use the PostgreSQL adapter.
|
||||||
|
|
||||||
|
1. Install adapter and driver:
|
||||||
|
```bash
|
||||||
|
npm install @prisma/adapter-pg pg
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Instantiate Prisma Client with the adapter:
|
||||||
|
```typescript
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaClient } from '../generated/client'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
```
|
||||||
|
|
||||||
|
## ID Generation
|
||||||
|
|
||||||
|
CockroachDB uses `BigInt` or `UUID` for IDs efficiently.
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model User {
|
||||||
|
id BigInt @id @default(autoincrement()) // Uses unique_rowid()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or using string UUIDs:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model User {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Schema Introspection
|
||||||
|
Always use `provider = "cockroachdb"` to ensure correct type mapping during `db pull`.
|
||||||
72
.agents/skills/prisma-database-setup/references/mongodb.md
Normal file
72
.agents/skills/prisma-database-setup/references/mongodb.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# MongoDB Setup
|
||||||
|
|
||||||
|
**⚠️ WARNING: MongoDB is NOT supported in Prisma ORM v7.**
|
||||||
|
|
||||||
|
Support for MongoDB is planned for a future v7 release. If you need MongoDB, you must use **Prisma ORM v6**.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- MongoDB 4.2+
|
||||||
|
- Replica Set configured (required for transactions)
|
||||||
|
- **Prisma ORM v6.x**
|
||||||
|
|
||||||
|
## 1. Schema Configuration (v6)
|
||||||
|
|
||||||
|
In `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "mongodb"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ID Field Requirement
|
||||||
|
|
||||||
|
MongoDB models **must** have a mapped `_id` field using `@id` and `@map("_id")`, usually of type `String` with `auto()` and `db.ObjectId`.
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model User {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relations
|
||||||
|
|
||||||
|
Relations in MongoDB expect IDs to be `db.ObjectId` type.
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Post {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
author User @relation(fields: [authorId], references: [id])
|
||||||
|
authorId String @db.ObjectId
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Environment Variable
|
||||||
|
|
||||||
|
In `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="mongodb+srv://user:password@cluster.mongodb.net/mydb?retryWrites=true&w=majority"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrations vs Introspection
|
||||||
|
|
||||||
|
- **No Migrations**: MongoDB is schema-less. `prisma migrate` commands **do not work**.
|
||||||
|
- **db push**: Use `prisma db push` to sync indexes and constraints.
|
||||||
|
- **db pull**: Use `prisma db pull` to generate schema from existing data (sampling).
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Transactions not supported"
|
||||||
|
Ensure your MongoDB instance is a **Replica Set**. Standalone instances do not support transactions. Atlas clusters are replica sets by default.
|
||||||
|
|
||||||
|
### "Invalid ObjectID"
|
||||||
|
Ensure fields referencing IDs are decorated with `@db.ObjectId` if the target is an ObjectID.
|
||||||
109
.agents/skills/prisma-database-setup/references/mysql.md
Normal file
109
.agents/skills/prisma-database-setup/references/mysql.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# MySQL Setup
|
||||||
|
|
||||||
|
Configure Prisma with MySQL (or MariaDB).
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- MySQL or MariaDB database
|
||||||
|
- Connection string
|
||||||
|
|
||||||
|
## 1. Schema Configuration
|
||||||
|
|
||||||
|
In `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Config Configuration (v7)
|
||||||
|
|
||||||
|
In `prisma.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineConfig, env } from 'prisma/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
datasource: {
|
||||||
|
url: env('DATABASE_URL'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Environment Variable
|
||||||
|
|
||||||
|
In `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="mysql://user:password@localhost:3306/mydb"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection String Format
|
||||||
|
|
||||||
|
```
|
||||||
|
mysql://USER:PASSWORD@HOST:PORT/DATABASE
|
||||||
|
```
|
||||||
|
|
||||||
|
- **USER**: Database user
|
||||||
|
- **PASSWORD**: Password
|
||||||
|
- **HOST**: Hostname
|
||||||
|
- **PORT**: Port (default 3306)
|
||||||
|
- **DATABASE**: Database name
|
||||||
|
|
||||||
|
## Driver Adapter (Prisma ORM 7 required)
|
||||||
|
|
||||||
|
Prisma ORM 7 uses the query compiler by default, so you must use a driver adapter.
|
||||||
|
|
||||||
|
1. Install adapter and driver:
|
||||||
|
```bash
|
||||||
|
npm install @prisma/adapter-mariadb mariadb
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Instantiate Prisma Client with the adapter:
|
||||||
|
```typescript
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaClient } from '../generated/client'
|
||||||
|
import { PrismaMariaDb } from '@prisma/adapter-mariadb'
|
||||||
|
|
||||||
|
const adapter = new PrismaMariaDb({
|
||||||
|
host: 'localhost',
|
||||||
|
port: 3306,
|
||||||
|
connectionLimit: 5,
|
||||||
|
user: process.env.MYSQL_USER,
|
||||||
|
password: process.env.MYSQL_PASSWORD,
|
||||||
|
database: process.env.MYSQL_DATABASE,
|
||||||
|
})
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
```
|
||||||
|
|
||||||
|
## PlanetScale Setup
|
||||||
|
|
||||||
|
PlanetScale uses MySQL but requires specific settings because it doesn't support foreign key constraints.
|
||||||
|
|
||||||
|
In `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
relationMode = "prisma" // Emulate foreign keys in Prisma
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Too many connections"
|
||||||
|
MySQL has a connection limit. Adjust connection pool size in URL:
|
||||||
|
```env
|
||||||
|
DATABASE_URL="mysql://...?connection_limit=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Support
|
||||||
|
MySQL 5.7+ supports JSON. MariaDB 10.2+ supports JSON (as an alias for LONGTEXT with check constraints). Prisma handles this, but verify your version.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# PostgreSQL Setup
|
||||||
|
|
||||||
|
Configure Prisma with PostgreSQL.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- PostgreSQL database (local or cloud)
|
||||||
|
- Connection string
|
||||||
|
|
||||||
|
## 1. Schema Configuration
|
||||||
|
|
||||||
|
In `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Config Configuration (v7)
|
||||||
|
|
||||||
|
In `prisma.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineConfig, env } from 'prisma/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
datasource: {
|
||||||
|
url: env('DATABASE_URL'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Environment Variable
|
||||||
|
|
||||||
|
In `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection String Format
|
||||||
|
|
||||||
|
```
|
||||||
|
postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=SCHEMA
|
||||||
|
```
|
||||||
|
|
||||||
|
- **USER**: Database user
|
||||||
|
- **PASSWORD**: Password (URL encoded if special chars)
|
||||||
|
- **HOST**: Hostname (localhost, IP, or domain)
|
||||||
|
- **PORT**: Port (default 5432)
|
||||||
|
- **DATABASE**: Database name
|
||||||
|
- **SCHEMA**: Schema name (default `public`)
|
||||||
|
|
||||||
|
## Driver Adapter (Prisma ORM 7 required)
|
||||||
|
|
||||||
|
Prisma ORM 7 uses the query compiler by default, so you must use a driver adapter.
|
||||||
|
|
||||||
|
1. Install adapter and driver:
|
||||||
|
```bash
|
||||||
|
npm install @prisma/adapter-pg pg
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Instantiate Prisma Client with the adapter:
|
||||||
|
```typescript
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaClient } from '../generated/client'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Can't reach database server"
|
||||||
|
- Check host and port
|
||||||
|
- Check firewall settings
|
||||||
|
- Ensure database is running
|
||||||
|
|
||||||
|
### "Authentication failed"
|
||||||
|
- Check user/password
|
||||||
|
- Special characters in password must be URL-encoded
|
||||||
|
|
||||||
|
### "Schema does not exist"
|
||||||
|
- Ensure `?schema=public` (or your schema) is in the URL
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Prisma Client Setup
|
||||||
|
|
||||||
|
Generate and instantiate Prisma Client for any database provider.
|
||||||
|
|
||||||
|
## 1. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install prisma --save-dev
|
||||||
|
npm install @prisma/client
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Add generator block
|
||||||
|
|
||||||
|
In `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prisma v7 requires an explicit `output` path and will not generate into `node_modules` by default.
|
||||||
|
|
||||||
|
## 3. Generate Prisma Client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-run `prisma generate` after every schema change to keep the client in sync.
|
||||||
|
|
||||||
|
## 4. Instantiate Prisma Client
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { PrismaClient } from '../generated/client'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
```
|
||||||
|
|
||||||
|
If you change the generator `output`, update the import path to match. In Prisma ORM 7, a **driver adapter is required** — replace `PrismaPg` with the adapter for your database.
|
||||||
|
|
||||||
|
## 5. Use a single instance
|
||||||
|
|
||||||
|
Each `PrismaClient` instance creates a connection pool. Reuse a single instance per app process to avoid exhausting database connections.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Prisma Postgres Setup
|
||||||
|
|
||||||
|
Configure Prisma with Prisma Postgres (Managed).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Prisma Postgres is a serverless, managed PostgreSQL database optimized for Prisma.
|
||||||
|
|
||||||
|
## Setup via CLI
|
||||||
|
|
||||||
|
You can provision a Prisma Postgres instance directly via the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prisma init --db
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Log you into Prisma Data Platform.
|
||||||
|
2. Create a new project and database instance.
|
||||||
|
3. Update your `.env` with the connection string.
|
||||||
|
|
||||||
|
## Connection String
|
||||||
|
|
||||||
|
The connection string starts with `prisma+postgres://`.
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="prisma+postgres://api_key@host.prisma-data.net/env_id"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Schema Configuration
|
||||||
|
|
||||||
|
In `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql" // Use postgresql provider
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Config Configuration
|
||||||
|
|
||||||
|
In `prisma.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineConfig, env } from 'prisma/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
datasource: {
|
||||||
|
url: env('DATABASE_URL'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Driver Adapter (Prisma ORM 7 required)
|
||||||
|
|
||||||
|
Prisma ORM 7 uses the query compiler by default, so you must use a driver adapter. For Prisma Postgres, use the Prisma Postgres serverless driver adapter.
|
||||||
|
|
||||||
|
1. Install adapter and driver:
|
||||||
|
```bash
|
||||||
|
npm install @prisma/adapter-ppg @prisma/ppg
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Use a **direct TCP** connection string for the adapter (from the Prisma Console) and instantiate Prisma Client:
|
||||||
|
```typescript
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaClient } from '../generated/client'
|
||||||
|
import { PrismaPostgresAdapter } from '@prisma/adapter-ppg'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
adapter: new PrismaPostgresAdapter({
|
||||||
|
connectionString: process.env.PRISMA_DIRECT_TCP_URL,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Serverless**: Scales to zero.
|
||||||
|
- **Caching**: Integrated query caching (Accelerate).
|
||||||
|
- **Real-time**: Database events (Pulse).
|
||||||
|
|
||||||
|
## Using with Prisma Client
|
||||||
|
|
||||||
|
Since Prisma ORM 7 requires a driver adapter, use the Prisma Postgres adapter shown above when instantiating Prisma Client.
|
||||||
106
.agents/skills/prisma-database-setup/references/sqlite.md
Normal file
106
.agents/skills/prisma-database-setup/references/sqlite.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# SQLite Setup
|
||||||
|
|
||||||
|
Configure Prisma with SQLite.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- None (file-based)
|
||||||
|
|
||||||
|
## 1. Schema Configuration
|
||||||
|
|
||||||
|
In `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Config Configuration (v7)
|
||||||
|
|
||||||
|
In `prisma.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineConfig, env } from 'prisma/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
datasource: {
|
||||||
|
url: env('DATABASE_URL'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Environment Variable
|
||||||
|
|
||||||
|
In `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection String Format
|
||||||
|
|
||||||
|
```
|
||||||
|
file:PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
- **PATH**: Relative path to the database file (from `prisma/schema.prisma` location usually, but in v7 check `prisma.config.ts` context). Usually relative to the schema file.
|
||||||
|
|
||||||
|
## Driver Adapter (Prisma ORM 7 required)
|
||||||
|
|
||||||
|
Prisma ORM 7 uses the query compiler by default, so you must use a driver adapter.
|
||||||
|
|
||||||
|
1. Install adapter and driver:
|
||||||
|
```bash
|
||||||
|
npm install @prisma/adapter-better-sqlite3 better-sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Instantiate Prisma Client with the adapter:
|
||||||
|
```typescript
|
||||||
|
import { PrismaClient } from '../generated/client'
|
||||||
|
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
||||||
|
|
||||||
|
const adapter = new PrismaBetterSqlite3({
|
||||||
|
url: process.env.DATABASE_URL ?? 'file:./dev.db',
|
||||||
|
})
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Driver Adapter (LibSQL / Turso)
|
||||||
|
|
||||||
|
For edge compatibility or Turso:
|
||||||
|
|
||||||
|
1. Install:
|
||||||
|
```bash
|
||||||
|
npm install @prisma/adapter-libsql @libsql/client
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Instantiate:
|
||||||
|
```typescript
|
||||||
|
import { PrismaClient } from '../generated/client'
|
||||||
|
import { PrismaLibSql } from '@prisma/adapter-libsql'
|
||||||
|
|
||||||
|
const adapter = new PrismaLibSql({
|
||||||
|
url: process.env.TURSO_DATABASE_URL,
|
||||||
|
authToken: process.env.TURSO_AUTH_TOKEN,
|
||||||
|
})
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- **No Enums**: SQLite doesn't support enums (Prisma polyfills them or treats as String).
|
||||||
|
- **No Scalar Lists**: `String[]` is not supported directly.
|
||||||
|
- **Concurrency**: Write operations lock the file.
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Database file not found"
|
||||||
|
Ensure the path in `DATABASE_URL` is correct relative to where Prisma is running or the schema file. `file:./dev.db` creates it next to schema.
|
||||||
94
.agents/skills/prisma-database-setup/references/sqlserver.md
Normal file
94
.agents/skills/prisma-database-setup/references/sqlserver.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# SQL Server Setup
|
||||||
|
|
||||||
|
Configure Prisma with Microsoft SQL Server.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- SQL Server 2017, 2019, 2022, or Azure SQL
|
||||||
|
- TCP/IP enabled
|
||||||
|
|
||||||
|
## 1. Schema Configuration
|
||||||
|
|
||||||
|
In `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlserver"
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../generated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Config Configuration (v7)
|
||||||
|
|
||||||
|
In `prisma.config.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineConfig, env } from 'prisma/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
datasource: {
|
||||||
|
url: env('DATABASE_URL'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Environment Variable
|
||||||
|
|
||||||
|
In `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="sqlserver://localhost:1433;database=mydb;user=sa;password=Password123;encrypt=true;trustServerCertificate=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection String Format
|
||||||
|
|
||||||
|
```
|
||||||
|
sqlserver://HOST:PORT;database=DB;user=USER;password=PASS;encrypt=true;trustServerCertificate=true
|
||||||
|
```
|
||||||
|
|
||||||
|
- **encrypt**: Required for Azure (true).
|
||||||
|
- **trustServerCertificate**: True for self-signed certs (local dev).
|
||||||
|
|
||||||
|
## Driver Adapter (Prisma ORM 7 required)
|
||||||
|
|
||||||
|
Prisma ORM 7 uses the query compiler by default, so you must use a driver adapter.
|
||||||
|
|
||||||
|
1. Install adapter and driver:
|
||||||
|
```bash
|
||||||
|
npm install @prisma/adapter-mssql mssql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Instantiate Prisma Client with the adapter:
|
||||||
|
```typescript
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaClient } from '../generated/client'
|
||||||
|
import { PrismaMssql } from '@prisma/adapter-mssql'
|
||||||
|
|
||||||
|
const adapter = new PrismaMssql({
|
||||||
|
server: 'localhost',
|
||||||
|
port: 1433,
|
||||||
|
database: 'mydb',
|
||||||
|
user: process.env.SQLSERVER_USER,
|
||||||
|
password: process.env.SQLSERVER_PASSWORD,
|
||||||
|
options: {
|
||||||
|
encrypt: true,
|
||||||
|
trustServerCertificate: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ adapter })
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Login failed for user"
|
||||||
|
- SQL Server auth vs Windows auth. Prisma typically uses SQL Server authentication (username/password).
|
||||||
|
- Ensure TCP/IP is enabled in SQL Server Configuration Manager.
|
||||||
|
|
||||||
|
### "Table not found" (dbo schema)
|
||||||
|
Prisma assumes `dbo` schema by default. If using another schema, update the model or connection string? SQL Server provider mostly sticks to default schema.
|
||||||
113
.agents/skills/tanstack-router-best-practices/SKILL.md
Normal file
113
.agents/skills/tanstack-router-best-practices/SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
name: tanstack-router-best-practices
|
||||||
|
description: TanStack Router best practices for type-safe routing, data loading, search params, and navigation. Activate when building React applications with complex routing needs.
|
||||||
|
---
|
||||||
|
|
||||||
|
# TanStack Router Best Practices
|
||||||
|
|
||||||
|
Comprehensive guidelines for implementing TanStack Router patterns in React applications. These rules optimize type safety, data loading, navigation, and code organization.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Setting up application routing
|
||||||
|
- Creating new routes and layouts
|
||||||
|
- Implementing search parameter handling
|
||||||
|
- Configuring data loaders
|
||||||
|
- Setting up code splitting
|
||||||
|
- Integrating with TanStack Query
|
||||||
|
- Refactoring navigation patterns
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Rules | Impact |
|
||||||
|
|----------|----------|-------|--------|
|
||||||
|
| CRITICAL | Type Safety | 4 rules | Prevents runtime errors and enables refactoring |
|
||||||
|
| CRITICAL | Route Organization | 5 rules | Ensures maintainable route structure |
|
||||||
|
| HIGH | Router Config | 1 rule | Global router defaults |
|
||||||
|
| HIGH | Data Loading | 6 rules | Optimizes data fetching and caching |
|
||||||
|
| HIGH | Search Params | 5 rules | Enables type-safe URL state |
|
||||||
|
| HIGH | Error Handling | 1 rule | Handles 404 and errors gracefully |
|
||||||
|
| MEDIUM | Navigation | 5 rules | Improves UX and accessibility |
|
||||||
|
| MEDIUM | Code Splitting | 3 rules | Reduces bundle size |
|
||||||
|
| MEDIUM | Preloading | 3 rules | Improves perceived performance |
|
||||||
|
| LOW | Route Context | 3 rules | Enables dependency injection |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Type Safety (Prefix: `ts-`)
|
||||||
|
|
||||||
|
- `ts-register-router` — Register router type for global inference
|
||||||
|
- `ts-use-from-param` — Use `from` parameter for type narrowing
|
||||||
|
- `ts-route-context-typing` — Type route context with createRootRouteWithContext
|
||||||
|
- `ts-query-options-loader` — Use queryOptions in loaders for type inference
|
||||||
|
|
||||||
|
### Router Config (Prefix: `router-`)
|
||||||
|
|
||||||
|
- `router-default-options` — Configure router defaults (scrollRestoration, defaultErrorComponent, etc.)
|
||||||
|
|
||||||
|
### Route Organization (Prefix: `org-`)
|
||||||
|
|
||||||
|
- `org-file-based-routing` — Prefer file-based routing for conventions
|
||||||
|
- `org-route-tree-structure` — Follow hierarchical route tree patterns
|
||||||
|
- `org-pathless-layouts` — Use pathless routes for shared layouts
|
||||||
|
- `org-index-routes` — Understand index vs layout routes
|
||||||
|
- `org-virtual-routes` — Understand virtual file routes
|
||||||
|
|
||||||
|
### Data Loading (Prefix: `load-`)
|
||||||
|
|
||||||
|
- `load-use-loaders` — Use route loaders for data fetching
|
||||||
|
- `load-loader-deps` — Define loaderDeps for cache control
|
||||||
|
- `load-ensure-query-data` — Use ensureQueryData with TanStack Query
|
||||||
|
- `load-deferred-data` — Split critical and non-critical data
|
||||||
|
- `load-error-handling` — Handle loader errors appropriately
|
||||||
|
- `load-parallel` — Leverage parallel route loading
|
||||||
|
|
||||||
|
### Search Params (Prefix: `search-`)
|
||||||
|
|
||||||
|
- `search-validation` — Always validate search params
|
||||||
|
- `search-type-inheritance` — Leverage parent search param types
|
||||||
|
- `search-middleware` — Use search param middleware
|
||||||
|
- `search-defaults` — Provide sensible defaults
|
||||||
|
- `search-custom-serializer` — Configure custom search param serializers
|
||||||
|
|
||||||
|
### Error Handling (Prefix: `err-`)
|
||||||
|
|
||||||
|
- `err-not-found` — Handle not-found routes properly
|
||||||
|
|
||||||
|
### Navigation (Prefix: `nav-`)
|
||||||
|
|
||||||
|
- `nav-link-component` — Prefer Link component for navigation
|
||||||
|
- `nav-active-states` — Configure active link states
|
||||||
|
- `nav-use-navigate` — Use useNavigate for programmatic navigation
|
||||||
|
- `nav-relative-paths` — Understand relative path navigation
|
||||||
|
- `nav-route-masks` — Use route masks for modal URLs
|
||||||
|
|
||||||
|
### Code Splitting (Prefix: `split-`)
|
||||||
|
|
||||||
|
- `split-lazy-routes` — Use .lazy.tsx for code splitting
|
||||||
|
- `split-critical-path` — Keep critical config in main route file
|
||||||
|
- `split-auto-splitting` — Enable autoCodeSplitting when possible
|
||||||
|
|
||||||
|
### Preloading (Prefix: `preload-`)
|
||||||
|
|
||||||
|
- `preload-intent` — Enable intent-based preloading
|
||||||
|
- `preload-stale-time` — Configure preload stale time
|
||||||
|
- `preload-manual` — Use manual preloading strategically
|
||||||
|
|
||||||
|
### Route Context (Prefix: `ctx-`)
|
||||||
|
|
||||||
|
- `ctx-root-context` — Define context at root route
|
||||||
|
- `ctx-before-load` — Extend context in beforeLoad
|
||||||
|
- `ctx-dependency-injection` — Use context for dependency injection
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Each rule file in the `rules/` directory contains:
|
||||||
|
1. **Explanation** — Why this pattern matters
|
||||||
|
2. **Bad Example** — Anti-pattern to avoid
|
||||||
|
3. **Good Example** — Recommended implementation
|
||||||
|
4. **Context** — When to apply or skip this rule
|
||||||
|
|
||||||
|
## Full Reference
|
||||||
|
|
||||||
|
See individual rule files in `rules/` directory for detailed guidance and code examples.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# ctx-root-context: Define Context at Root Route
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Use `createRootRouteWithContext` to define typed context that flows through your entire route tree. This enables dependency injection for things like query clients, auth state, and services.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No context - importing globals directly
|
||||||
|
// routes/__root.tsx
|
||||||
|
import { createRootRoute } from '@tanstack/react-router'
|
||||||
|
import { queryClient } from '@/lib/query-client' // Global import
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts.tsx
|
||||||
|
import { queryClient } from '@/lib/query-client' // Import again
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async () => {
|
||||||
|
// Using global - harder to test, couples to implementation
|
||||||
|
return queryClient.ensureQueryData(postQueries.list())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/__root.tsx
|
||||||
|
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
// Define the context interface
|
||||||
|
interface RouterContext {
|
||||||
|
queryClient: QueryClient
|
||||||
|
auth: {
|
||||||
|
user: User | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
|
component: RootComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// router.tsx - Provide context when creating router
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
export function getRouter(auth: RouterContext['auth'] = { user: null, isAuthenticated: false }) {
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: {
|
||||||
|
queryClient,
|
||||||
|
auth,
|
||||||
|
},
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
scrollRestoration: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
setupRouterSsrQueryIntegration({ router, queryClient })
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes/posts.tsx - Use context in loaders
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
// Context is typed and injected
|
||||||
|
return queryClient.ensureQueryData(postQueries.list())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Auth-Protected Routes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/__root.tsx
|
||||||
|
interface RouterContext {
|
||||||
|
queryClient: QueryClient
|
||||||
|
auth: AuthState
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
|
component: RootComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/_authenticated.tsx - Layout route for protected pages
|
||||||
|
export const Route = createFileRoute('/_authenticated')({
|
||||||
|
beforeLoad: async ({ context, location }) => {
|
||||||
|
if (!context.auth.isAuthenticated) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/login',
|
||||||
|
search: { redirect: location.href },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: AuthenticatedLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/_authenticated/dashboard.tsx
|
||||||
|
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||||
|
loader: async ({ context: { queryClient, auth } }) => {
|
||||||
|
// We know user is authenticated from parent beforeLoad
|
||||||
|
return queryClient.ensureQueryData(
|
||||||
|
dashboardQueries.forUser(auth.user!.id)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extending Context with beforeLoad
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
beforeLoad: async ({ context, params }) => {
|
||||||
|
// Extend context with route-specific data
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
post, // Available to this route and children
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
// context now includes 'post' from beforeLoad
|
||||||
|
const comments = await fetchComments(context.post.id)
|
||||||
|
return { comments }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context vs. Loader Data
|
||||||
|
|
||||||
|
| Context | Loader Data |
|
||||||
|
|---------|-------------|
|
||||||
|
| Available in beforeLoad, loader, and component | Only available in component |
|
||||||
|
| Set at router creation or in beforeLoad | Returned from loader |
|
||||||
|
| Good for services, clients, auth | Good for route-specific data |
|
||||||
|
| Flows down to all children | Specific to route |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Type the context interface in `createRootRouteWithContext<T>()`
|
||||||
|
- Provide context when calling `createRouter({ context: {...} })`
|
||||||
|
- Context flows from root to all nested routes
|
||||||
|
- Use `beforeLoad` to extend context for specific subtrees
|
||||||
|
- Enables testability - inject mocks in tests
|
||||||
|
- Avoids global imports and singletons
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# err-not-found: Handle Not-Found Routes Properly
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Configure `notFoundComponent` to handle 404 errors gracefully. TanStack Router provides not-found handling at multiple levels: root, route-specific, and programmatic via `notFound()`. Proper configuration prevents blank screens and improves UX.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No not-found handling - shows blank screen or error
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
// Missing defaultNotFoundComponent
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or throwing generic error
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
if (!post) {
|
||||||
|
throw new Error('Not found') // Generic error, not proper 404
|
||||||
|
}
|
||||||
|
return post
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Root-Level Not Found
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/__root.tsx
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootComponent,
|
||||||
|
notFoundComponent: GlobalNotFound,
|
||||||
|
})
|
||||||
|
|
||||||
|
function GlobalNotFound() {
|
||||||
|
return (
|
||||||
|
<div className="not-found">
|
||||||
|
<h1>404 - Page Not Found</h1>
|
||||||
|
<p>The page you're looking for doesn't exist.</p>
|
||||||
|
<Link to="/">Go Home</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// router.tsx - Can also set default
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultNotFoundComponent: () => (
|
||||||
|
<div>
|
||||||
|
<h1>404</h1>
|
||||||
|
<Link to="/">Return Home</Link>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Route-Specific Not Found
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
import { createFileRoute, notFound } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
if (!post) {
|
||||||
|
throw notFound() // Proper 404 handling
|
||||||
|
}
|
||||||
|
return post
|
||||||
|
},
|
||||||
|
notFoundComponent: PostNotFound, // Custom 404 for this route
|
||||||
|
component: PostPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostNotFound() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Post Not Found</h1>
|
||||||
|
<p>No post exists with ID: {postId}</p>
|
||||||
|
<Link to="/posts">Browse all posts</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Not Found with Data
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/users/$username')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const user = await fetchUser(params.username)
|
||||||
|
if (!user) {
|
||||||
|
throw notFound({
|
||||||
|
// Pass data to notFoundComponent
|
||||||
|
data: {
|
||||||
|
username: params.username,
|
||||||
|
suggestions: await fetchSimilarUsernames(params.username),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
notFoundComponent: UserNotFound,
|
||||||
|
})
|
||||||
|
|
||||||
|
function UserNotFound() {
|
||||||
|
const { data } = Route.useMatch()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>User @{data?.username} not found</h1>
|
||||||
|
{data?.suggestions?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p>Did you mean:</p>
|
||||||
|
<ul>
|
||||||
|
{data.suggestions.map((username) => (
|
||||||
|
<li key={username}>
|
||||||
|
<Link to="/users/$username" params={{ username }}>
|
||||||
|
@{username}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Catch-All Route
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/$.tsx - Catch-all splat route
|
||||||
|
export const Route = createFileRoute('/$')({
|
||||||
|
component: CatchAllNotFound,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CatchAllNotFound() {
|
||||||
|
const { _splat } = Route.useParams()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Page Not Found</h1>
|
||||||
|
<p>No page exists at: /{_splat}</p>
|
||||||
|
<Link to="/">Go to homepage</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Nested Not Found Bubbling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Not found bubbles up through route tree
|
||||||
|
// routes/posts.tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
notFoundComponent: PostsNotFound, // Catches child 404s too
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
if (!post) throw notFound()
|
||||||
|
return post
|
||||||
|
},
|
||||||
|
// No notFoundComponent - bubbles to parent
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts/$postId/comments.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId/comments')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const comments = await fetchComments(params.postId)
|
||||||
|
if (!comments) throw notFound() // Bubbles to /posts notFoundComponent
|
||||||
|
return comments
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `notFound()` throws a special error caught by nearest `notFoundComponent`
|
||||||
|
- Not found bubbles up the route tree if not handled locally
|
||||||
|
- Use `defaultNotFoundComponent` on router for global fallback
|
||||||
|
- Pass data to `notFound({ data })` for contextual 404 pages
|
||||||
|
- Catch-all routes (`/$`) can handle truly unknown paths
|
||||||
|
- Different from error boundaries - specifically for 404 cases
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# load-ensure-query-data: Use ensureQueryData with TanStack Query
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
When integrating TanStack Router with TanStack Query, use `queryClient.ensureQueryData()` in loaders instead of `prefetchQuery()`. This respects the cache, awaits data if missing, and returns the data for potential use.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using prefetchQuery - doesn't return data, can't await stale check
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// prefetchQuery never throws, swallows errors
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ['posts', params.postId],
|
||||||
|
queryFn: () => fetchPost(params.postId),
|
||||||
|
})
|
||||||
|
// No await - might not complete before render
|
||||||
|
// No return value to use
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetching directly - bypasses TanStack Query cache
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async () => {
|
||||||
|
const posts = await fetchPosts() // Not cached
|
||||||
|
return { posts }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Define queryOptions for reuse
|
||||||
|
const postQueryOptions = (postId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['posts', postId],
|
||||||
|
queryFn: () => fetchPost(postId),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// ensureQueryData:
|
||||||
|
// - Returns cached data if fresh
|
||||||
|
// - Fetches and caches if missing or stale
|
||||||
|
// - Awaits completion
|
||||||
|
// - Throws on error (caught by error boundary)
|
||||||
|
await queryClient.ensureQueryData(postQueryOptions(params.postId))
|
||||||
|
},
|
||||||
|
component: PostPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostPage() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
|
||||||
|
// Data guaranteed to exist from loader
|
||||||
|
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
|
||||||
|
|
||||||
|
return <PostContent post={post} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Multiple Parallel Queries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
// Parallel data fetching
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.ensureQueryData(statsQueries.overview()),
|
||||||
|
queryClient.ensureQueryData(activityQueries.recent()),
|
||||||
|
queryClient.ensureQueryData(notificationQueries.unread()),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Dependent Queries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/users/$userId/posts')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// First query needed for second
|
||||||
|
const user = await queryClient.ensureQueryData(
|
||||||
|
userQueries.detail(params.userId)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dependent query uses result
|
||||||
|
await queryClient.ensureQueryData(
|
||||||
|
postQueries.byAuthor(user.id)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Router Configuration for TanStack Query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// router.tsx
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute default
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { queryClient },
|
||||||
|
|
||||||
|
// Let TanStack Query manage caching
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
|
||||||
|
// SSR: Dehydrate query cache
|
||||||
|
dehydrate: () => ({
|
||||||
|
queryClientState: dehydrate(queryClient),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// SSR: Hydrate on client
|
||||||
|
hydrate: (dehydrated) => {
|
||||||
|
hydrate(queryClient, dehydrated.queryClientState)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wrap with QueryClientProvider
|
||||||
|
Wrap: ({ children }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## ensureQueryData vs prefetchQuery vs fetchQuery
|
||||||
|
|
||||||
|
| Method | Returns | Throws | Awaits | Use Case |
|
||||||
|
|--------|---------|--------|--------|----------|
|
||||||
|
| `ensureQueryData` | Data | Yes | Yes | Route loaders (recommended) |
|
||||||
|
| `prefetchQuery` | void | No | Yes | Background prefetching |
|
||||||
|
| `fetchQuery` | Data | Yes | Yes | When you need data immediately |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `ensureQueryData` is the recommended method for route loaders
|
||||||
|
- Respects `staleTime` - won't refetch fresh cached data
|
||||||
|
- Errors propagate to route error boundaries
|
||||||
|
- Use `queryOptions()` factory for type-safe, reusable query definitions
|
||||||
|
- Set `defaultPreloadStaleTime: 0` to let TanStack Query manage cache
|
||||||
|
- Pair with `useSuspenseQuery` in components for guaranteed data
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# load-parallel: Leverage Parallel Route Loading
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
TanStack Router loads nested route data in parallel, not sequentially. Structure your routes and loaders to maximize parallelization and avoid creating artificial waterfalls.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Creating waterfall with dependent beforeLoad
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const user = await fetchUser() // 200ms
|
||||||
|
const permissions = await fetchPermissions(user.id) // 200ms
|
||||||
|
const preferences = await fetchPreferences(user.id) // 200ms
|
||||||
|
// Total: 600ms (sequential)
|
||||||
|
|
||||||
|
return { user, permissions, preferences }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Or nesting data dependencies incorrectly
|
||||||
|
// routes/posts.tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async () => {
|
||||||
|
const posts = await fetchPosts() // 300ms
|
||||||
|
return { posts }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
// Waits for parent to complete first - waterfall!
|
||||||
|
const post = await fetchPost(params.postId) // +200ms
|
||||||
|
return { post }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Parallel in Single Loader
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
beforeLoad: async () => {
|
||||||
|
// All requests start simultaneously
|
||||||
|
const [user, config] = await Promise.all([
|
||||||
|
fetchUser(), // 200ms
|
||||||
|
fetchAppConfig(), // 150ms
|
||||||
|
])
|
||||||
|
// Total: 200ms (parallel)
|
||||||
|
|
||||||
|
return { user, config }
|
||||||
|
},
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
// These also run in parallel with each other
|
||||||
|
const [stats, activity, notifications] = await Promise.all([
|
||||||
|
fetchDashboardStats(context.user.id),
|
||||||
|
fetchRecentActivity(context.user.id),
|
||||||
|
fetchNotifications(context.user.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
return { stats, activity, notifications }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Parallel Nested Routes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Parent and child loaders run in PARALLEL
|
||||||
|
// routes/posts.tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async () => {
|
||||||
|
// This runs...
|
||||||
|
const categories = await fetchCategories()
|
||||||
|
return { categories }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
// ...at the SAME TIME as this!
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
const comments = await fetchComments(params.postId)
|
||||||
|
return { post, comments }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation to /posts/123:
|
||||||
|
// - Both loaders start simultaneously
|
||||||
|
// - Total time = max(categoriesTime, postTime + commentsTime)
|
||||||
|
// - NOT categoriesTime + postTime + commentsTime
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With TanStack Query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts.tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
// These all start in parallel
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.ensureQueryData(postQueries.list()),
|
||||||
|
queryClient.ensureQueryData(categoryQueries.all()),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// Runs in parallel with parent loader
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.ensureQueryData(postQueries.detail(params.postId)),
|
||||||
|
queryClient.ensureQueryData(commentQueries.forPost(params.postId)),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Streaming Non-Critical Data
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// Critical data - await
|
||||||
|
const post = await queryClient.ensureQueryData(
|
||||||
|
postQueries.detail(params.postId)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Non-critical - start but don't await (stream in later)
|
||||||
|
queryClient.prefetchQuery(commentQueries.forPost(params.postId))
|
||||||
|
queryClient.prefetchQuery(relatedQueries.forPost(params.postId))
|
||||||
|
|
||||||
|
return { post }
|
||||||
|
},
|
||||||
|
component: PostPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostPage() {
|
||||||
|
const { post } = Route.useLoaderData()
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
|
||||||
|
// Critical data ready immediately
|
||||||
|
// Non-critical loads in component with loading state
|
||||||
|
const { data: comments, isLoading } = useQuery(
|
||||||
|
commentQueries.forPost(postId)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<PostContent post={post} />
|
||||||
|
{isLoading ? <CommentsSkeleton /> : <Comments data={comments} />}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Loading Timeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Navigation to /posts/123
|
||||||
|
|
||||||
|
Without parallelization:
|
||||||
|
├─ beforeLoad (parent) ████████
|
||||||
|
├─ loader (parent) ████████
|
||||||
|
├─ beforeLoad (child) ████
|
||||||
|
├─ loader (child) ████████
|
||||||
|
└─ Render █
|
||||||
|
|
||||||
|
With parallelization:
|
||||||
|
├─ beforeLoad (parent) ████████
|
||||||
|
├─ beforeLoad (child) ████
|
||||||
|
├─ loader (parent) ████████
|
||||||
|
├─ loader (child) ████████████
|
||||||
|
└─ Render █
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Nested route loaders run in parallel by default
|
||||||
|
- `beforeLoad` runs before `loader` (for auth, context setup)
|
||||||
|
- Use `Promise.all` for parallel fetches within a single loader
|
||||||
|
- Parent context is available in child loaders (after beforeLoad)
|
||||||
|
- Prefetch non-critical data without awaiting for streaming
|
||||||
|
- Monitor network tab to verify parallelization
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# load-use-loaders: Use Route Loaders for Data Fetching
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Route loaders execute before the route renders, enabling data to be ready when the component mounts. This prevents loading waterfalls, enables preloading, and integrates with the router's caching layer.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Fetching in component - creates waterfall
|
||||||
|
function PostsPage() {
|
||||||
|
const [posts, setPosts] = useState<Post[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Route renders, THEN data fetches, THEN UI updates
|
||||||
|
fetchPosts().then((data) => {
|
||||||
|
setPosts(data)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <Loading />
|
||||||
|
return <PostList posts={posts} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// No preloading possible - user sees loading state on navigation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts.tsx
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async () => {
|
||||||
|
const posts = await fetchPosts()
|
||||||
|
return { posts }
|
||||||
|
},
|
||||||
|
component: PostsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostsPage() {
|
||||||
|
// Data is ready when component mounts - no loading state needed
|
||||||
|
const { posts } = Route.useLoaderData()
|
||||||
|
return <PostList posts={posts} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Parameters
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
// params are type-safe and guaranteed to exist
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
const comments = await fetchComments(params.postId)
|
||||||
|
return { post, comments }
|
||||||
|
},
|
||||||
|
component: PostDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostDetailPage() {
|
||||||
|
const { post, comments } = Route.useLoaderData()
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article>
|
||||||
|
<h1>{post.title}</h1>
|
||||||
|
<PostContent content={post.content} />
|
||||||
|
<CommentList comments={comments} />
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With TanStack Query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
const postQueryOptions = (postId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['posts', postId],
|
||||||
|
queryFn: () => fetchPost(postId),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// Ensure data is in cache before render
|
||||||
|
await queryClient.ensureQueryData(postQueryOptions(params.postId))
|
||||||
|
},
|
||||||
|
component: PostDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostDetailPage() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
// useSuspenseQuery because loader guarantees data exists
|
||||||
|
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
|
||||||
|
|
||||||
|
return <PostContent post={post} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loader Context Properties
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
loader: async ({
|
||||||
|
params, // Route path parameters
|
||||||
|
context, // Route context (queryClient, auth, etc.)
|
||||||
|
abortController, // For cancelling stale requests
|
||||||
|
cause, // 'enter' | 'preload' | 'stay'
|
||||||
|
deps, // Dependencies from loaderDeps
|
||||||
|
preload, // Boolean: true if preloading
|
||||||
|
}) => {
|
||||||
|
// Use abortController for fetch cancellation
|
||||||
|
const response = await fetch('/api/posts', {
|
||||||
|
signal: abortController.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Different behavior for preload vs navigation
|
||||||
|
if (preload) {
|
||||||
|
// Lighter data for preload
|
||||||
|
return { posts: await response.json() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full data for actual navigation
|
||||||
|
const posts = await response.json()
|
||||||
|
const stats = await fetchStats()
|
||||||
|
return { posts, stats }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Loaders run during route matching, before component render
|
||||||
|
- Supports parallel loading across nested routes
|
||||||
|
- Enables preloading on link hover/focus
|
||||||
|
- Built-in stale-while-revalidate caching
|
||||||
|
- For complex caching needs, integrate with TanStack Query
|
||||||
|
- Use `beforeLoad` for auth checks and redirects
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# nav-link-component: Prefer Link Component for Navigation
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Use the `<Link>` component for navigation instead of `useNavigate()` when possible. Links render proper `<a>` tags with valid `href` attributes, enabling right-click → open in new tab, better SEO, and accessibility.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using onClick with navigate - loses standard link behavior
|
||||||
|
function PostCard({ post }: { post: Post }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => navigate({ to: '/posts/$postId', params: { postId: post.id } })}
|
||||||
|
className="post-card"
|
||||||
|
>
|
||||||
|
<h2>{post.title}</h2>
|
||||||
|
<p>{post.excerpt}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Problems:
|
||||||
|
// - No right-click → open in new tab
|
||||||
|
// - No cmd/ctrl+click for new tab
|
||||||
|
// - Not announced as link to screen readers
|
||||||
|
// - No valid href for SEO
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
function PostCard({ post }: { post: Post }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/posts/$postId"
|
||||||
|
params={{ postId: post.id }}
|
||||||
|
className="post-card"
|
||||||
|
>
|
||||||
|
<h2>{post.title}</h2>
|
||||||
|
<p>{post.excerpt}</p>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Benefits:
|
||||||
|
// - Renders <a href="/posts/123">
|
||||||
|
// - Right-click menu works
|
||||||
|
// - Cmd/Ctrl+click opens new tab
|
||||||
|
// - Screen readers announce as link
|
||||||
|
// - Preloading works on hover
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Search Params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function FilteredLink() {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/products"
|
||||||
|
search={{ category: 'electronics', sort: 'price' }}
|
||||||
|
>
|
||||||
|
View Electronics
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserving existing search params
|
||||||
|
function SortLink({ sort }: { sort: 'asc' | 'desc' }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="." // Current route
|
||||||
|
search={(prev) => ({ ...prev, sort })}
|
||||||
|
>
|
||||||
|
Sort {sort === 'asc' ? 'Ascending' : 'Descending'}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Active States
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
activeProps={{
|
||||||
|
className: 'nav-link-active',
|
||||||
|
'aria-current': 'page',
|
||||||
|
}}
|
||||||
|
inactiveProps={{
|
||||||
|
className: 'nav-link',
|
||||||
|
}}
|
||||||
|
activeOptions={{
|
||||||
|
exact: true, // Only active on exact match
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or use render props for more control
|
||||||
|
function CustomNavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Link to={to}>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<span className={isActive ? 'text-blue-600 font-bold' : 'text-gray-600'}>
|
||||||
|
{children}
|
||||||
|
{isActive && <CheckIcon className="ml-2" />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Preloading
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostList({ posts }: { posts: Post[] }) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map(post => (
|
||||||
|
<li key={post.id}>
|
||||||
|
<Link
|
||||||
|
to="/posts/$postId"
|
||||||
|
params={{ postId: post.id }}
|
||||||
|
preload="intent" // Preload on hover/focus
|
||||||
|
preloadDelay={100} // Wait 100ms before preloading
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use useNavigate Instead
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 1. After form submission
|
||||||
|
const createPost = useMutation({
|
||||||
|
mutationFn: submitPost,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
navigate({ to: '/posts/$postId', params: { postId: data.id } })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. After authentication
|
||||||
|
async function handleLogin(credentials: Credentials) {
|
||||||
|
await login(credentials)
|
||||||
|
navigate({ to: '/dashboard' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Programmatic redirects
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
navigate({ to: '/login', search: { redirect: location.pathname } })
|
||||||
|
}
|
||||||
|
}, [isAuthenticated])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- `<Link>` renders actual `<a>` tags with proper `href`
|
||||||
|
- Supports all standard link behaviors (middle-click, cmd+click, etc.)
|
||||||
|
- Enables preloading on hover/focus
|
||||||
|
- Better for SEO - crawlers can follow links
|
||||||
|
- Reserve `useNavigate` for side effects and programmatic navigation
|
||||||
|
- Use `<Navigate>` component for immediate redirects on render
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# nav-route-masks: Use Route Masks for Modal URLs
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Route masks let you display one URL while internally routing to another. This is useful for modals, sheets, and overlays where you want a shareable URL that shows the modal, but navigating there directly should show the full page.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Modal without proper URL handling
|
||||||
|
function PostList() {
|
||||||
|
const [selectedPost, setSelectedPost] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{posts.map(post => (
|
||||||
|
<div key={post.id} onClick={() => setSelectedPost(post.id)}>
|
||||||
|
{post.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{selectedPost && (
|
||||||
|
<Modal onClose={() => setSelectedPost(null)}>
|
||||||
|
<PostDetail postId={selectedPost} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problems:
|
||||||
|
// - URL doesn't change when modal opens
|
||||||
|
// - Can't share link to modal
|
||||||
|
// - Back button doesn't close modal
|
||||||
|
// - Refresh loses modal state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Route Masks for Modal
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts.tsx
|
||||||
|
export const Route = createFileRoute('/posts')({
|
||||||
|
component: PostList,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostList() {
|
||||||
|
const posts = usePosts()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{posts.map(post => (
|
||||||
|
<Link
|
||||||
|
key={post.id}
|
||||||
|
to="/posts/$postId"
|
||||||
|
params={{ postId: post.id }}
|
||||||
|
mask={{
|
||||||
|
to: '/posts',
|
||||||
|
// URL shows /posts but routes to /posts/$postId
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<Outlet /> {/* Modal renders here */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
component: PostModal,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostModal() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={() => navigate({ to: '/posts' })}>
|
||||||
|
<PostDetail postId={postId} />
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// User clicks post:
|
||||||
|
// - URL stays /posts (masked)
|
||||||
|
// - PostModal renders
|
||||||
|
// - Share link goes to /posts/$postId (real URL)
|
||||||
|
// - Direct navigation to /posts/$postId shows full page (no mask)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Search Params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostList() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{posts.map(post => (
|
||||||
|
<Link
|
||||||
|
key={post.id}
|
||||||
|
to="/posts/$postId"
|
||||||
|
params={{ postId: post.id }}
|
||||||
|
mask={{
|
||||||
|
to: '/posts',
|
||||||
|
search: { modal: post.id }, // /posts?modal=123
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Programmatic Navigation with Mask
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostCard({ post }: { post: Post }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const openInModal = () => {
|
||||||
|
navigate({
|
||||||
|
to: '/posts/$postId',
|
||||||
|
params: { postId: post.id },
|
||||||
|
mask: {
|
||||||
|
to: '/posts',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFullPage = () => {
|
||||||
|
navigate({
|
||||||
|
to: '/posts/$postId',
|
||||||
|
params: { postId: post.id },
|
||||||
|
// No mask - shows real URL
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>{post.title}</h3>
|
||||||
|
<button onClick={openInModal}>Quick View</button>
|
||||||
|
<button onClick={openFullPage}>Full Page</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Unmask on Interaction
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function PostModal() {
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const expandToFullPage = () => {
|
||||||
|
// Navigate to real URL, removing mask
|
||||||
|
navigate({
|
||||||
|
to: '/posts/$postId',
|
||||||
|
params: { postId },
|
||||||
|
// No mask = real URL
|
||||||
|
replace: true, // Replace history entry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal>
|
||||||
|
<PostDetail postId={postId} />
|
||||||
|
<button onClick={expandToFullPage}>
|
||||||
|
Expand to full page
|
||||||
|
</button>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Mask Behavior
|
||||||
|
|
||||||
|
| Scenario | URL Shown | Actual Route |
|
||||||
|
|----------|-----------|--------------|
|
||||||
|
| Click masked link | Masked URL | Real route |
|
||||||
|
| Share/copy URL | Real URL | Real route |
|
||||||
|
| Direct navigation | Real URL | Real route |
|
||||||
|
| Browser refresh | Depends on URL in bar | Matches URL |
|
||||||
|
| Back button | Previous URL | Previous route |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Masks are client-side only - shared URLs are the real route
|
||||||
|
- Direct navigation to real URL bypasses mask (shows full page)
|
||||||
|
- Back button navigates through history correctly
|
||||||
|
- Use for modals, side panels, quick views
|
||||||
|
- Masks can include different search params
|
||||||
|
- Consider UX: users expect shared URLs to work
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# org-virtual-routes: Understand Virtual File Routes
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Virtual routes are automatically generated placeholder routes in the route tree when you have a `.lazy.tsx` file without a corresponding main route file. They provide the minimal configuration needed to anchor lazy-loaded components.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Creating unnecessary boilerplate main route files
|
||||||
|
// routes/settings.tsx - Just to have a file
|
||||||
|
export const Route = createFileRoute('/settings')({
|
||||||
|
// Empty - no loader, no beforeLoad, nothing
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/settings.lazy.tsx - Actual component
|
||||||
|
export const Route = createLazyFileRoute('/settings')({
|
||||||
|
component: SettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The main file is unnecessary boilerplate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Let Virtual Routes Handle It
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Delete routes/settings.tsx entirely!
|
||||||
|
|
||||||
|
// routes/settings.lazy.tsx - Only file needed
|
||||||
|
export const Route = createLazyFileRoute('/settings')({
|
||||||
|
component: SettingsPage,
|
||||||
|
pendingComponent: SettingsLoading,
|
||||||
|
errorComponent: SettingsError,
|
||||||
|
})
|
||||||
|
|
||||||
|
function SettingsPage() {
|
||||||
|
return <div>Settings Content</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// TanStack Router auto-generates a virtual route:
|
||||||
|
// {
|
||||||
|
// path: '/settings',
|
||||||
|
// // Minimal config to anchor the lazy file
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: When You DO Need Main Route File
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/dashboard.tsx - Need this for loader/beforeLoad
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
if (!context.auth.isAuthenticated) {
|
||||||
|
throw redirect({ to: '/login' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
await queryClient.ensureQueryData(dashboardQueries.stats())
|
||||||
|
},
|
||||||
|
// Component is in lazy file
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/dashboard.lazy.tsx
|
||||||
|
export const Route = createLazyFileRoute('/dashboard')({
|
||||||
|
component: DashboardPage,
|
||||||
|
pendingComponent: DashboardSkeleton,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Main file IS needed here because we have loader/beforeLoad
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decision Guide
|
||||||
|
|
||||||
|
| Route Has... | Need Main File? | Use Virtual? |
|
||||||
|
|--------------|-----------------|--------------|
|
||||||
|
| Only component | No | Yes |
|
||||||
|
| loader | Yes | No |
|
||||||
|
| beforeLoad | Yes | No |
|
||||||
|
| validateSearch | Yes | No |
|
||||||
|
| loaderDeps | Yes | No |
|
||||||
|
| Just pendingComponent/errorComponent | No | Yes |
|
||||||
|
|
||||||
|
## Good Example: File Structure with Virtual Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
routes/
|
||||||
|
├── __root.tsx # Always needed
|
||||||
|
├── index.tsx # Has loader
|
||||||
|
├── about.lazy.tsx # Virtual route (no main file)
|
||||||
|
├── contact.lazy.tsx # Virtual route (no main file)
|
||||||
|
├── dashboard.tsx # Has beforeLoad (auth)
|
||||||
|
├── dashboard.lazy.tsx # Component
|
||||||
|
├── posts.tsx # Has loader
|
||||||
|
├── posts.lazy.tsx # Component
|
||||||
|
├── posts/
|
||||||
|
│ ├── $postId.tsx # Has loader
|
||||||
|
│ └── $postId.lazy.tsx # Component
|
||||||
|
└── settings/
|
||||||
|
├── index.lazy.tsx # Virtual route
|
||||||
|
├── profile.lazy.tsx # Virtual route
|
||||||
|
└── security.tsx # Has beforeLoad (requires re-auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Generated Route Tree
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routeTree.gen.ts (auto-generated)
|
||||||
|
import { Route as rootRoute } from './routes/__root'
|
||||||
|
import { Route as aboutLazyRoute } from './routes/about.lazy' // Virtual parent
|
||||||
|
|
||||||
|
export const routeTree = rootRoute.addChildren([
|
||||||
|
// Virtual route created for about.lazy.tsx
|
||||||
|
createRoute({
|
||||||
|
path: '/about',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
}).lazy(() => import('./routes/about.lazy').then(m => m.Route)),
|
||||||
|
|
||||||
|
// Regular route with explicit main file
|
||||||
|
dashboardRoute.addChildren([...]),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Virtual routes reduce boilerplate for simple pages
|
||||||
|
- Only works with file-based routing
|
||||||
|
- Auto-generated in `routeTree.gen.ts`
|
||||||
|
- Main route file needed for any "critical path" config
|
||||||
|
- Critical: loader, beforeLoad, validateSearch, loaderDeps, context
|
||||||
|
- Non-critical (can be in lazy): component, pendingComponent, errorComponent
|
||||||
|
- Check generated route tree to verify virtual routes
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# preload-intent: Enable Intent-Based Preloading
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Configure `defaultPreload: 'intent'` to preload routes when users hover or focus links. This loads data before the click, making navigation feel instant.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No preloading configured - data loads after click
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
// No defaultPreload - user waits after every navigation
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each navigation shows loading state
|
||||||
|
function PostList({ posts }: { posts: Post[] }) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map(post => (
|
||||||
|
<li key={post.id}>
|
||||||
|
<Link to="/posts/$postId" params={{ postId: post.id }}>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
{/* Click → wait for data → render */}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// router.tsx - Enable preloading by default
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent', // Preload on hover/focus
|
||||||
|
defaultPreloadDelay: 50, // Wait 50ms before starting
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links automatically preload on hover
|
||||||
|
function PostList({ posts }: { posts: Post[] }) {
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{posts.map(post => (
|
||||||
|
<li key={post.id}>
|
||||||
|
<Link to="/posts/$postId" params={{ postId: post.id }}>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
{/* Hover → preload starts → click → instant navigation */}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preload Options
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Router-level defaults
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent', // 'intent' | 'render' | 'viewport' | false
|
||||||
|
defaultPreloadDelay: 50, // ms before preload starts
|
||||||
|
defaultPreloadStaleTime: 30000, // 30s - how long preloaded data stays fresh
|
||||||
|
})
|
||||||
|
|
||||||
|
// Link-level overrides
|
||||||
|
<Link
|
||||||
|
to="/heavy-page"
|
||||||
|
preload={false} // Disable for this specific link
|
||||||
|
>
|
||||||
|
Heavy Page
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/critical-page"
|
||||||
|
preload="render" // Preload immediately when Link renders
|
||||||
|
>
|
||||||
|
Critical Page
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preload Strategies
|
||||||
|
|
||||||
|
| Strategy | Behavior | Use Case |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| `'intent'` | Preload on hover/focus | Default for most links |
|
||||||
|
| `'render'` | Preload when Link mounts | Critical next pages |
|
||||||
|
| `'viewport'` | Preload when Link enters viewport | Below-fold content |
|
||||||
|
| `false` | No preloading | Heavy, rarely-visited pages |
|
||||||
|
|
||||||
|
## Good Example: With TanStack Query Integration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// When using TanStack Query, disable router cache
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadStaleTime: 0, // Let TanStack Query manage cache
|
||||||
|
context: {
|
||||||
|
queryClient,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Route loader uses TanStack Query
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params, context: { queryClient } }) => {
|
||||||
|
// ensureQueryData respects TanStack Query's staleTime
|
||||||
|
await queryClient.ensureQueryData(postQueries.detail(params.postId))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Preloading loads route code AND executes loaders
|
||||||
|
- `preloadDelay` prevents excessive requests on quick mouse movements
|
||||||
|
- Preloaded data is garbage collected after `preloadStaleTime`
|
||||||
|
- Works with both router caching and external caching (TanStack Query)
|
||||||
|
- Mobile: Consider `'viewport'` since hover isn't available
|
||||||
|
- Monitor network tab to verify preloading works correctly
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# router-default-options: Configure Router Default Options
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
TanStack Router's `createRouter` accepts several default options that apply globally. Configure these for consistent behavior across your application including error handling, scroll restoration, and performance optimizations.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Minimal router - missing useful defaults
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { queryClient },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each route must handle its own errors
|
||||||
|
// No scroll restoration on navigation
|
||||||
|
// No preloading configured
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Full Configuration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary'
|
||||||
|
import { DefaultNotFound } from '@/components/DefaultNotFound'
|
||||||
|
|
||||||
|
export function getRouter() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 1000 * 60 * 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { queryClient, user: null },
|
||||||
|
|
||||||
|
// Preloading
|
||||||
|
defaultPreload: 'intent', // Preload on hover/focus
|
||||||
|
defaultPreloadStaleTime: 0, // Let Query manage freshness
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
|
defaultNotFoundComponent: DefaultNotFound,
|
||||||
|
|
||||||
|
// UX
|
||||||
|
scrollRestoration: true, // Restore scroll on back/forward
|
||||||
|
|
||||||
|
// Performance
|
||||||
|
defaultStructuralSharing: true, // Optimize re-renders
|
||||||
|
})
|
||||||
|
|
||||||
|
setupRouterSsrQueryIntegration({
|
||||||
|
router,
|
||||||
|
queryClient,
|
||||||
|
})
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: DefaultCatchBoundary Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/DefaultCatchBoundary.tsx
|
||||||
|
import { ErrorComponent, useRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export function DefaultCatchBoundary({ error }: { error: Error }) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="error-container">
|
||||||
|
<h1>Something went wrong</h1>
|
||||||
|
<ErrorComponent error={error} />
|
||||||
|
<button onClick={() => router.invalidate()}>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: DefaultNotFound Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/DefaultNotFound.tsx
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export function DefaultNotFound() {
|
||||||
|
return (
|
||||||
|
<div className="not-found-container">
|
||||||
|
<h1>404 - Page Not Found</h1>
|
||||||
|
<p>The page you're looking for doesn't exist.</p>
|
||||||
|
<Link to="/">Go home</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Router Options Reference
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `defaultPreload` | `false \| 'intent' \| 'render' \| 'viewport'` | `false` | When to preload routes |
|
||||||
|
| `defaultPreloadStaleTime` | `number` | `30000` | How long preloaded data stays fresh (ms) |
|
||||||
|
| `defaultErrorComponent` | `Component` | Built-in | Global error boundary |
|
||||||
|
| `defaultNotFoundComponent` | `Component` | Built-in | Global 404 page |
|
||||||
|
| `scrollRestoration` | `boolean` | `false` | Restore scroll on navigation |
|
||||||
|
| `defaultStructuralSharing` | `boolean` | `true` | Optimize loader data re-renders |
|
||||||
|
|
||||||
|
## Good Example: Route-Level Overrides
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Routes can override defaults
|
||||||
|
export const Route = createFileRoute('/admin')({
|
||||||
|
// Custom error handling for admin section
|
||||||
|
errorComponent: AdminErrorBoundary,
|
||||||
|
notFoundComponent: AdminNotFound,
|
||||||
|
|
||||||
|
// Disable preload for sensitive routes
|
||||||
|
preload: false,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Pending Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { queryClient },
|
||||||
|
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
|
defaultNotFoundComponent: DefaultNotFound,
|
||||||
|
scrollRestoration: true,
|
||||||
|
|
||||||
|
// Show during route transitions
|
||||||
|
defaultPendingComponent: () => (
|
||||||
|
<div className="loading-bar" />
|
||||||
|
),
|
||||||
|
defaultPendingMinMs: 200, // Min time to show pending UI
|
||||||
|
defaultPendingMs: 1000, // Delay before showing pending UI
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Set `defaultPreloadStaleTime: 0` when using TanStack Query
|
||||||
|
- `scrollRestoration: true` improves back/forward navigation UX
|
||||||
|
- `defaultStructuralSharing` prevents unnecessary re-renders
|
||||||
|
- Route-level options override router defaults
|
||||||
|
- Error/NotFound components receive route context
|
||||||
|
- Pending components help with perceived performance
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# search-custom-serializer: Configure Custom Search Param Serializers
|
||||||
|
|
||||||
|
## Priority: LOW
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
By default, TanStack Router serializes search params as JSON. For cleaner URLs or compatibility with external systems, you can provide custom serializers using libraries like `qs`, `query-string`, or your own implementation.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Default JSON serialization creates ugly URLs
|
||||||
|
// URL: /products?filters=%7B%22category%22%3A%22electronics%22%2C%22inStock%22%3Atrue%7D
|
||||||
|
|
||||||
|
// Or manually parsing/serializing inconsistently
|
||||||
|
function ProductList() {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
const filters = JSON.parse(searchParams.get('filters') || '{}')
|
||||||
|
// Inconsistent with router's handling
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Using JSURL for Compact URLs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import JSURL from 'jsurl2'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
search: {
|
||||||
|
// Custom serializer for compact, URL-safe encoding
|
||||||
|
serialize: (search) => JSURL.stringify(search),
|
||||||
|
parse: (searchString) => JSURL.parse(searchString) || {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// URL: /products?~(category~'electronics~inStock~true)
|
||||||
|
// Much shorter than JSON!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Using query-string for Flat Params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import queryString from 'query-string'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
search: {
|
||||||
|
serialize: (search) =>
|
||||||
|
queryString.stringify(search, {
|
||||||
|
arrayFormat: 'bracket',
|
||||||
|
skipNull: true,
|
||||||
|
}),
|
||||||
|
parse: (searchString) =>
|
||||||
|
queryString.parse(searchString, {
|
||||||
|
arrayFormat: 'bracket',
|
||||||
|
parseBooleans: true,
|
||||||
|
parseNumbers: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// URL: /products?category=electronics&inStock=true&tags[]=sale&tags[]=new
|
||||||
|
// Traditional query string format
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Using qs for Nested Objects
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import qs from 'qs'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
search: {
|
||||||
|
serialize: (search) =>
|
||||||
|
qs.stringify(search, {
|
||||||
|
encodeValuesOnly: true,
|
||||||
|
arrayFormat: 'brackets',
|
||||||
|
}),
|
||||||
|
parse: (searchString) =>
|
||||||
|
qs.parse(searchString, {
|
||||||
|
ignoreQueryPrefix: true,
|
||||||
|
decoder(value) {
|
||||||
|
// Parse booleans and numbers
|
||||||
|
if (value === 'true') return true
|
||||||
|
if (value === 'false') return false
|
||||||
|
if (/^-?\d+$/.test(value)) return parseInt(value, 10)
|
||||||
|
return value
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// URL: /products?filters[category]=electronics&filters[price][min]=100&filters[price][max]=500
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Base64 for Complex State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
search: {
|
||||||
|
serialize: (search) => {
|
||||||
|
if (Object.keys(search).length === 0) return ''
|
||||||
|
const json = JSON.stringify(search)
|
||||||
|
return btoa(json) // Base64 encode
|
||||||
|
},
|
||||||
|
parse: (searchString) => {
|
||||||
|
if (!searchString) return {}
|
||||||
|
try {
|
||||||
|
return JSON.parse(atob(searchString)) // Base64 decode
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// URL: /products?eyJjYXRlZ29yeSI6ImVsZWN0cm9uaWNzIn0
|
||||||
|
// Opaque but compact
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Hybrid Approach
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Some params as regular query, complex ones as JSON
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
search: {
|
||||||
|
serialize: (search) => {
|
||||||
|
const { filters, ...simple } = search
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
|
// Simple values as regular params
|
||||||
|
Object.entries(simple).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) {
|
||||||
|
params.set(key, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Complex filters as JSON
|
||||||
|
if (filters && Object.keys(filters).length > 0) {
|
||||||
|
params.set('filters', JSON.stringify(filters))
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.toString()
|
||||||
|
},
|
||||||
|
parse: (searchString) => {
|
||||||
|
const params = new URLSearchParams(searchString)
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
params.forEach((value, key) => {
|
||||||
|
if (key === 'filters') {
|
||||||
|
result.filters = JSON.parse(value)
|
||||||
|
} else if (value === 'true') {
|
||||||
|
result[key] = true
|
||||||
|
} else if (value === 'false') {
|
||||||
|
result[key] = false
|
||||||
|
} else if (/^-?\d+$/.test(value)) {
|
||||||
|
result[key] = parseInt(value, 10)
|
||||||
|
} else {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// URL: /products?page=1&sort=price&filters={"category":"electronics","inStock":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serializer Comparison
|
||||||
|
|
||||||
|
| Library | URL Style | Best For |
|
||||||
|
|---------|-----------|----------|
|
||||||
|
| Default (JSON) | `?data=%7B...%7D` | TypeScript safety |
|
||||||
|
| jsurl2 | `?~(key~'value)` | Compact, readable |
|
||||||
|
| query-string | `?key=value&arr[]=1` | Traditional APIs |
|
||||||
|
| qs | `?obj[nested]=value` | Deep nesting |
|
||||||
|
| Base64 | `?eyJrZXkiOiJ2YWx1ZSJ9` | Opaque, compact |
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Custom serializers apply globally to all routes
|
||||||
|
- Route-level `validateSearch` still works after parsing
|
||||||
|
- Consider URL length limits (~2000 chars for safe cross-browser)
|
||||||
|
- SEO: Search engines may not understand custom formats
|
||||||
|
- Bookmarkability: Users can't easily modify opaque URLs
|
||||||
|
- Debugging: JSON is easier to read in browser devtools
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# search-validation: Always Validate Search Params
|
||||||
|
|
||||||
|
## Priority: HIGH
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Search params come from the URL - user-controlled input that must be validated. Use `validateSearch` to parse, validate, and provide defaults. This ensures type safety and prevents runtime errors from malformed URLs.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// No validation - trusting URL input directly
|
||||||
|
export const Route = createFileRoute('/products')({
|
||||||
|
component: ProductsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ProductsPage() {
|
||||||
|
// Accessing raw search params without validation
|
||||||
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
|
const page = parseInt(searchParams.get('page') || '1') // Could be NaN
|
||||||
|
const sort = searchParams.get('sort') as 'asc' | 'desc' // Could be anything
|
||||||
|
|
||||||
|
// Runtime errors possible if URL is malformed
|
||||||
|
return <ProductList page={page} sort={sort} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: Manual Validation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const Route = createFileRoute('/products')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => {
|
||||||
|
return {
|
||||||
|
page: Number(search.page) || 1,
|
||||||
|
sort: search.sort === 'desc' ? 'desc' : 'asc',
|
||||||
|
category: typeof search.category === 'string' ? search.category : undefined,
|
||||||
|
minPrice: Number(search.minPrice) || undefined,
|
||||||
|
maxPrice: Number(search.maxPrice) || undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: ProductsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ProductsPage() {
|
||||||
|
// Fully typed, validated search params
|
||||||
|
const { page, sort, category, minPrice, maxPrice } = Route.useSearch()
|
||||||
|
// page: number (default 1)
|
||||||
|
// sort: 'asc' | 'desc' (default 'asc')
|
||||||
|
// category: string | undefined
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Zod
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const productSearchSchema = z.object({
|
||||||
|
page: z.number().min(1).catch(1),
|
||||||
|
limit: z.number().min(1).max(100).catch(20),
|
||||||
|
sort: z.enum(['name', 'price', 'date']).catch('name'),
|
||||||
|
order: z.enum(['asc', 'desc']).catch('asc'),
|
||||||
|
category: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
minPrice: z.number().min(0).optional(),
|
||||||
|
maxPrice: z.number().min(0).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ProductSearch = z.infer<typeof productSearchSchema>
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/products')({
|
||||||
|
validateSearch: (search) => productSearchSchema.parse(search),
|
||||||
|
component: ProductsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ProductsPage() {
|
||||||
|
const search = Route.useSearch()
|
||||||
|
// search: ProductSearch - fully typed with defaults
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProductList
|
||||||
|
page={search.page}
|
||||||
|
limit={search.limit}
|
||||||
|
sort={search.sort}
|
||||||
|
order={search.order}
|
||||||
|
filters={{
|
||||||
|
category: search.category,
|
||||||
|
search: search.search,
|
||||||
|
priceRange: search.minPrice && search.maxPrice
|
||||||
|
? [search.minPrice, search.maxPrice]
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example: With Valibot
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as v from 'valibot'
|
||||||
|
import { valibotSearchValidator } from '@tanstack/router-valibot-adapter'
|
||||||
|
|
||||||
|
const searchSchema = v.object({
|
||||||
|
page: v.fallback(v.number(), 1),
|
||||||
|
query: v.fallback(v.string(), ''),
|
||||||
|
filters: v.fallback(
|
||||||
|
v.array(v.string()),
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/search')({
|
||||||
|
validateSearch: valibotSearchValidator(searchSchema),
|
||||||
|
component: SearchPage,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating Search Params
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function ProductFilters() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const search = Route.useSearch()
|
||||||
|
|
||||||
|
const updateFilters = (newFilters: Partial<ProductSearch>) => {
|
||||||
|
navigate({
|
||||||
|
to: '.', // Current route
|
||||||
|
search: (prev) => ({
|
||||||
|
...prev,
|
||||||
|
...newFilters,
|
||||||
|
page: 1, // Reset to page 1 when filters change
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={search.sort}
|
||||||
|
onChange={(e) => updateFilters({ sort: e.target.value as ProductSearch['sort'] })}
|
||||||
|
>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="price">Price</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Search params are user input - never trust them unvalidated
|
||||||
|
- Use `.catch()` in Zod or `fallback()` in Valibot for graceful defaults
|
||||||
|
- Validation runs on every navigation - keep it fast
|
||||||
|
- Search params are inherited by child routes
|
||||||
|
- Use `search` updater function to preserve other params
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# split-lazy-routes: Use .lazy.tsx for Code Splitting
|
||||||
|
|
||||||
|
## Priority: MEDIUM
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Split route components into `.lazy.tsx` files to reduce initial bundle size. The main route file keeps critical configuration (path, loaders, search validation), while lazy files contain components that load on-demand.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/dashboard.tsx - Everything in one file
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { HeavyChartLibrary } from 'heavy-chart-library'
|
||||||
|
import { ComplexDataGrid } from 'complex-data-grid'
|
||||||
|
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
return context.queryClient.ensureQueryData(dashboardQueries.stats())
|
||||||
|
},
|
||||||
|
component: DashboardPage, // Entire component in main bundle
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
// Heavy components loaded even if user never visits dashboard
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HeavyChartLibrary data={useLoaderData()} />
|
||||||
|
<ComplexDataGrid />
|
||||||
|
<AnalyticsWidgets />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/dashboard.tsx - Only critical config
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/dashboard')({
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
return context.queryClient.ensureQueryData(dashboardQueries.stats())
|
||||||
|
},
|
||||||
|
// No component - it's in the lazy file
|
||||||
|
})
|
||||||
|
|
||||||
|
// routes/dashboard.lazy.tsx - Lazy-loaded component
|
||||||
|
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||||
|
import { HeavyChartLibrary } from 'heavy-chart-library'
|
||||||
|
import { ComplexDataGrid } from 'complex-data-grid'
|
||||||
|
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/dashboard')({
|
||||||
|
component: DashboardPage,
|
||||||
|
pendingComponent: DashboardSkeleton,
|
||||||
|
errorComponent: DashboardError,
|
||||||
|
})
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
const data = Route.useLoaderData()
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<HeavyChartLibrary data={data} />
|
||||||
|
<ComplexDataGrid />
|
||||||
|
<AnalyticsWidgets />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return <div className="dashboard-skeleton">Loading dashboard...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardError({ error }: { error: Error }) {
|
||||||
|
return <div>Failed to load dashboard: {error.message}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What Goes Where
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Main route file (routes/example.tsx)
|
||||||
|
// - path configuration (implicit from file location)
|
||||||
|
// - validateSearch
|
||||||
|
// - beforeLoad (auth checks, redirects)
|
||||||
|
// - loader (data fetching)
|
||||||
|
// - loaderDeps
|
||||||
|
// - context manipulation
|
||||||
|
// - Static route data
|
||||||
|
|
||||||
|
// Lazy file (routes/example.lazy.tsx)
|
||||||
|
// - component
|
||||||
|
// - pendingComponent
|
||||||
|
// - errorComponent
|
||||||
|
// - notFoundComponent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using getRouteApi in Lazy Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.lazy.tsx
|
||||||
|
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
const route = getRouteApi('/posts/$postId')
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/posts/$postId')({
|
||||||
|
component: PostPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostPage() {
|
||||||
|
// Type-safe access without importing main route file
|
||||||
|
const { postId } = route.useParams()
|
||||||
|
const data = route.useLoaderData()
|
||||||
|
|
||||||
|
return <article>{/* ... */}</article>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automatic Code Splitting
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// vite.config.ts - Enable automatic splitting
|
||||||
|
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
TanStackRouterVite({
|
||||||
|
autoCodeSplitting: true, // Automatically splits all route components
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
// With autoCodeSplitting, you don't need .lazy.tsx files
|
||||||
|
// The plugin handles the splitting automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Lazy loading reduces initial bundle size significantly
|
||||||
|
- Loaders are NOT lazy - they need to run before rendering
|
||||||
|
- `createLazyFileRoute` only accepts component-related options
|
||||||
|
- Use `getRouteApi()` for type-safe hook access in lazy files
|
||||||
|
- Consider `autoCodeSplitting: true` for simpler setup
|
||||||
|
- Virtual routes auto-generate when only .lazy.tsx exists
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# ts-register-router: Register Router Type for Global Inference
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
Register your router instance with TypeScript's module declaration to enable type inference across your entire application. Without registration, hooks like `useNavigate`, `useParams`, and `useSearch` won't know your route structure.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// router.tsx - Missing type registration
|
||||||
|
import { createRouter, createRootRoute } from '@tanstack/react-router'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
export const router = createRouter({ routeTree })
|
||||||
|
|
||||||
|
// components/Navigation.tsx
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
function Navigation() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// TypeScript doesn't know valid routes - no autocomplete or type checking
|
||||||
|
navigate({ to: '/posts/$postId' }) // No error even if route doesn't exist
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// router.tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
export const router = createRouter({ routeTree })
|
||||||
|
|
||||||
|
// Register the router instance for type inference
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// components/Navigation.tsx
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
function Navigation() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// Full type safety - TypeScript knows all valid routes
|
||||||
|
navigate({ to: '/posts/$postId', params: { postId: '123' } })
|
||||||
|
|
||||||
|
// Type error if route doesn't exist
|
||||||
|
navigate({ to: '/invalid-route' }) // Error: Type '"/invalid-route"' is not assignable...
|
||||||
|
|
||||||
|
// Autocomplete for params
|
||||||
|
navigate({
|
||||||
|
to: '/users/$userId/posts/$postId',
|
||||||
|
params: { userId: '1', postId: '2' }, // Both required
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits of Registration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// After registration, all these get full type inference:
|
||||||
|
|
||||||
|
// 1. Navigation
|
||||||
|
const navigate = useNavigate()
|
||||||
|
navigate({ to: '/posts/$postId', params: { postId: '123' } })
|
||||||
|
|
||||||
|
// 2. Link component
|
||||||
|
<Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>
|
||||||
|
|
||||||
|
// 3. useParams hook
|
||||||
|
const { postId } = useParams({ from: '/posts/$postId' }) // postId: string
|
||||||
|
|
||||||
|
// 4. useSearch hook
|
||||||
|
const search = useSearch({ from: '/posts' }) // Knows search param types
|
||||||
|
|
||||||
|
// 5. useLoaderData hook
|
||||||
|
const data = useLoaderData({ from: '/posts/$postId' }) // Knows loader return type
|
||||||
|
```
|
||||||
|
|
||||||
|
## File-Based Routing Setup
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// With file-based routing, routeTree is auto-generated
|
||||||
|
// router.tsx
|
||||||
|
import { createRouter } from '@tanstack/react-router'
|
||||||
|
import { routeTree } from './routeTree.gen' // Generated file
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Must be done once, typically in your router configuration file
|
||||||
|
- Enables IDE autocomplete for routes, params, and search params
|
||||||
|
- Catches invalid routes at compile time
|
||||||
|
- Works with both file-based and code-based routing
|
||||||
|
- Required for full TypeScript benefits of TanStack Router
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# ts-use-from-param: Use `from` Parameter for Type Narrowing
|
||||||
|
|
||||||
|
## Priority: CRITICAL
|
||||||
|
|
||||||
|
## Explanation
|
||||||
|
|
||||||
|
When using hooks like `useParams`, `useSearch`, or `useLoaderData`, provide the `from` parameter to get exact types for that route. Without it, TypeScript returns a union of all possible types across all routes.
|
||||||
|
|
||||||
|
## Bad Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Without 'from' - TypeScript doesn't know which route's types to use
|
||||||
|
function PostDetail() {
|
||||||
|
// params could be from ANY route - types are unioned
|
||||||
|
const params = useParams()
|
||||||
|
// params: { postId?: string; userId?: string; categoryId?: string; ... }
|
||||||
|
|
||||||
|
// TypeScript can't guarantee postId exists
|
||||||
|
console.log(params.postId) // postId: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similarly for search params
|
||||||
|
function SearchResults() {
|
||||||
|
const search = useSearch()
|
||||||
|
// search: union of ALL routes' search params
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Good Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// With 'from' - exact types for this specific route
|
||||||
|
function PostDetail() {
|
||||||
|
const params = useParams({ from: '/posts/$postId' })
|
||||||
|
// params: { postId: string } - exactly what this route provides
|
||||||
|
|
||||||
|
console.log(params.postId) // postId: string (guaranteed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full path matching
|
||||||
|
function UserPost() {
|
||||||
|
const params = useParams({ from: '/users/$userId/posts/$postId' })
|
||||||
|
// params: { userId: string; postId: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search params with type narrowing
|
||||||
|
function SearchResults() {
|
||||||
|
const search = useSearch({ from: '/search' })
|
||||||
|
// search: exactly the validated search params for /search route
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loader data with type inference
|
||||||
|
function PostPage() {
|
||||||
|
const { post, comments } = useLoaderData({ from: '/posts/$postId' })
|
||||||
|
// Exact types from your loader function
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Route.fullPath for Type Safety
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// routes/posts/$postId.tsx
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/posts/$postId')({
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const post = await fetchPost(params.postId)
|
||||||
|
return { post }
|
||||||
|
},
|
||||||
|
component: PostComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PostComponent() {
|
||||||
|
// Use Route.fullPath for guaranteed type matching
|
||||||
|
const params = useParams({ from: Route.fullPath })
|
||||||
|
const { post } = useLoaderData({ from: Route.fullPath })
|
||||||
|
|
||||||
|
// Or use route-specific helper (preferred in same file)
|
||||||
|
const { postId } = Route.useParams()
|
||||||
|
const data = Route.useLoaderData()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using getRouteApi for Code-Split Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/PostDetail.tsx (separate file from route)
|
||||||
|
import { getRouteApi } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
// Get type-safe access without importing the route
|
||||||
|
const postRoute = getRouteApi('/posts/$postId')
|
||||||
|
|
||||||
|
export function PostDetail() {
|
||||||
|
const params = postRoute.useParams()
|
||||||
|
// params: { postId: string }
|
||||||
|
|
||||||
|
const data = postRoute.useLoaderData()
|
||||||
|
// data: exact loader return type
|
||||||
|
|
||||||
|
const search = postRoute.useSearch()
|
||||||
|
// search: exact search param types
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use strict: false
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In shared components that work across multiple routes
|
||||||
|
function Breadcrumbs() {
|
||||||
|
// strict: false returns union types but allows component reuse
|
||||||
|
const params = useParams({ strict: false })
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// params may or may not have certain values
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
{params.userId && <span>User: {params.userId}</span>}
|
||||||
|
{params.postId && <span>Post: {params.postId}</span>}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Always use `from` in route-specific components for exact types
|
||||||
|
- Use `Route.useParams()` / `Route.useLoaderData()` within route files
|
||||||
|
- Use `getRouteApi()` in components split from route files
|
||||||
|
- Use `strict: false` only in truly generic, cross-route components
|
||||||
|
- The `from` path must match exactly (including params like `$postId`)
|
||||||
3648
.agents/skills/vercel-react-best-practices/AGENTS.md
Normal file
3648
.agents/skills/vercel-react-best-practices/AGENTS.md
Normal file
File diff suppressed because it is too large
Load Diff
123
.agents/skills/vercel-react-best-practices/README.md
Normal file
123
.agents/skills/vercel-react-best-practices/README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# React Best Practices
|
||||||
|
|
||||||
|
A structured repository for creating and maintaining React Best Practices optimized for agents and LLMs.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `rules/` - Individual rule files (one per rule)
|
||||||
|
- `_sections.md` - Section metadata (titles, impacts, descriptions)
|
||||||
|
- `_template.md` - Template for creating new rules
|
||||||
|
- `area-description.md` - Individual rule files
|
||||||
|
- `src/` - Build scripts and utilities
|
||||||
|
- `metadata.json` - Document metadata (version, organization, abstract)
|
||||||
|
- __`AGENTS.md`__ - Compiled output (generated)
|
||||||
|
- __`test-cases.json`__ - Test cases for LLM evaluation (generated)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build AGENTS.md from rules:
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Validate rule files:
|
||||||
|
```bash
|
||||||
|
pnpm validate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Extract test cases:
|
||||||
|
```bash
|
||||||
|
pnpm extract-tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a New Rule
|
||||||
|
|
||||||
|
1. Copy `rules/_template.md` to `rules/area-description.md`
|
||||||
|
2. Choose the appropriate area prefix:
|
||||||
|
- `async-` for Eliminating Waterfalls (Section 1)
|
||||||
|
- `bundle-` for Bundle Size Optimization (Section 2)
|
||||||
|
- `server-` for Server-Side Performance (Section 3)
|
||||||
|
- `client-` for Client-Side Data Fetching (Section 4)
|
||||||
|
- `rerender-` for Re-render Optimization (Section 5)
|
||||||
|
- `rendering-` for Rendering Performance (Section 6)
|
||||||
|
- `js-` for JavaScript Performance (Section 7)
|
||||||
|
- `advanced-` for Advanced Patterns (Section 8)
|
||||||
|
3. Fill in the frontmatter and content
|
||||||
|
4. Ensure you have clear examples with explanations
|
||||||
|
5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
|
||||||
|
|
||||||
|
## Rule File Structure
|
||||||
|
|
||||||
|
Each rule file should follow this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: Rule Title Here
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: Optional description
|
||||||
|
tags: tag1, tag2, tag3
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Title Here
|
||||||
|
|
||||||
|
Brief explanation of the rule and why it matters.
|
||||||
|
|
||||||
|
**Incorrect (description of what's wrong):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad code example
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (description of what's right):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good code example
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional explanatory text after examples.
|
||||||
|
|
||||||
|
Reference: [Link](https://example.com)
|
||||||
|
|
||||||
|
## File Naming Convention
|
||||||
|
|
||||||
|
- Files starting with `_` are special (excluded from build)
|
||||||
|
- Rule files: `area-description.md` (e.g., `async-parallel.md`)
|
||||||
|
- Section is automatically inferred from filename prefix
|
||||||
|
- Rules are sorted alphabetically by title within each section
|
||||||
|
- IDs (e.g., 1.1, 1.2) are auto-generated during build
|
||||||
|
|
||||||
|
## Impact Levels
|
||||||
|
|
||||||
|
- `CRITICAL` - Highest priority, major performance gains
|
||||||
|
- `HIGH` - Significant performance improvements
|
||||||
|
- `MEDIUM-HIGH` - Moderate-high gains
|
||||||
|
- `MEDIUM` - Moderate performance improvements
|
||||||
|
- `LOW-MEDIUM` - Low-medium gains
|
||||||
|
- `LOW` - Incremental improvements
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `pnpm build` - Compile rules into AGENTS.md
|
||||||
|
- `pnpm validate` - Validate all rule files
|
||||||
|
- `pnpm extract-tests` - Extract test cases for LLM evaluation
|
||||||
|
- `pnpm dev` - Build and validate
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding or modifying rules:
|
||||||
|
|
||||||
|
1. Use the correct filename prefix for your section
|
||||||
|
2. Follow the `_template.md` structure
|
||||||
|
3. Include clear bad/good examples with explanations
|
||||||
|
4. Add appropriate tags
|
||||||
|
5. Run `pnpm build` to regenerate AGENTS.md and test-cases.json
|
||||||
|
6. Rules are automatically sorted by title - no need to manage numbers!
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
Originally created by [@shuding](https://x.com/shuding) at [Vercel](https://vercel.com).
|
||||||
146
.agents/skills/vercel-react-best-practices/SKILL.md
Normal file
146
.agents/skills/vercel-react-best-practices/SKILL.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
name: vercel-react-best-practices
|
||||||
|
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: vercel
|
||||||
|
version: "1.0.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vercel React Best Practices
|
||||||
|
|
||||||
|
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 67 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
Reference these guidelines when:
|
||||||
|
- Writing new React components or Next.js pages
|
||||||
|
- Implementing data fetching (client or server-side)
|
||||||
|
- Reviewing code for performance issues
|
||||||
|
- Refactoring existing React/Next.js code
|
||||||
|
- Optimizing bundle size or load times
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
| Priority | Category | Impact | Prefix |
|
||||||
|
|----------|----------|--------|--------|
|
||||||
|
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
|
||||||
|
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
|
||||||
|
| 3 | Server-Side Performance | HIGH | `server-` |
|
||||||
|
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
|
||||||
|
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
|
||||||
|
| 6 | Rendering Performance | MEDIUM | `rendering-` |
|
||||||
|
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
|
||||||
|
| 8 | Advanced Patterns | LOW | `advanced-` |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### 1. Eliminating Waterfalls (CRITICAL)
|
||||||
|
|
||||||
|
- `async-cheap-condition-before-await` - Check cheap sync conditions before awaiting flags or remote values
|
||||||
|
- `async-defer-await` - Move await into branches where actually used
|
||||||
|
- `async-parallel` - Use Promise.all() for independent operations
|
||||||
|
- `async-dependencies` - Use better-all for partial dependencies
|
||||||
|
- `async-api-routes` - Start promises early, await late in API routes
|
||||||
|
- `async-suspense-boundaries` - Use Suspense to stream content
|
||||||
|
|
||||||
|
### 2. Bundle Size Optimization (CRITICAL)
|
||||||
|
|
||||||
|
- `bundle-barrel-imports` - Import directly, avoid barrel files
|
||||||
|
- `bundle-dynamic-imports` - Use next/dynamic for heavy components
|
||||||
|
- `bundle-defer-third-party` - Load analytics/logging after hydration
|
||||||
|
- `bundle-conditional` - Load modules only when feature is activated
|
||||||
|
- `bundle-preload` - Preload on hover/focus for perceived speed
|
||||||
|
|
||||||
|
### 3. Server-Side Performance (HIGH)
|
||||||
|
|
||||||
|
- `server-auth-actions` - Authenticate server actions like API routes
|
||||||
|
- `server-cache-react` - Use React.cache() for per-request deduplication
|
||||||
|
- `server-cache-lru` - Use LRU cache for cross-request caching
|
||||||
|
- `server-dedup-props` - Avoid duplicate serialization in RSC props
|
||||||
|
- `server-hoist-static-io` - Hoist static I/O (fonts, logos) to module level
|
||||||
|
- `server-serialization` - Minimize data passed to client components
|
||||||
|
- `server-parallel-fetching` - Restructure components to parallelize fetches
|
||||||
|
- `server-parallel-nested-fetching` - Chain nested fetches per item in Promise.all
|
||||||
|
- `server-after-nonblocking` - Use after() for non-blocking operations
|
||||||
|
|
||||||
|
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
|
||||||
|
|
||||||
|
- `client-swr-dedup` - Use SWR for automatic request deduplication
|
||||||
|
- `client-event-listeners` - Deduplicate global event listeners
|
||||||
|
- `client-passive-event-listeners` - Use passive listeners for scroll
|
||||||
|
- `client-localstorage-schema` - Version and minimize localStorage data
|
||||||
|
|
||||||
|
### 5. Re-render Optimization (MEDIUM)
|
||||||
|
|
||||||
|
- `rerender-defer-reads` - Don't subscribe to state only used in callbacks
|
||||||
|
- `rerender-memo` - Extract expensive work into memoized components
|
||||||
|
- `rerender-memo-with-default-value` - Hoist default non-primitive props
|
||||||
|
- `rerender-dependencies` - Use primitive dependencies in effects
|
||||||
|
- `rerender-derived-state` - Subscribe to derived booleans, not raw values
|
||||||
|
- `rerender-derived-state-no-effect` - Derive state during render, not effects
|
||||||
|
- `rerender-functional-setstate` - Use functional setState for stable callbacks
|
||||||
|
- `rerender-lazy-state-init` - Pass function to useState for expensive values
|
||||||
|
- `rerender-simple-expression-in-memo` - Avoid memo for simple primitives
|
||||||
|
- `rerender-split-combined-hooks` - Split hooks with independent dependencies
|
||||||
|
- `rerender-move-effect-to-event` - Put interaction logic in event handlers
|
||||||
|
- `rerender-transitions` - Use startTransition for non-urgent updates
|
||||||
|
- `rerender-use-deferred-value` - Defer expensive renders to keep input responsive
|
||||||
|
- `rerender-use-ref-transient-values` - Use refs for transient frequent values
|
||||||
|
- `rerender-no-inline-components` - Don't define components inside components
|
||||||
|
|
||||||
|
### 6. Rendering Performance (MEDIUM)
|
||||||
|
|
||||||
|
- `rendering-animate-svg-wrapper` - Animate div wrapper, not SVG element
|
||||||
|
- `rendering-content-visibility` - Use content-visibility for long lists
|
||||||
|
- `rendering-hoist-jsx` - Extract static JSX outside components
|
||||||
|
- `rendering-svg-precision` - Reduce SVG coordinate precision
|
||||||
|
- `rendering-hydration-no-flicker` - Use inline script for client-only data
|
||||||
|
- `rendering-hydration-suppress-warning` - Suppress expected mismatches
|
||||||
|
- `rendering-activity` - Use Activity component for show/hide
|
||||||
|
- `rendering-conditional-render` - Use ternary, not && for conditionals
|
||||||
|
- `rendering-usetransition-loading` - Prefer useTransition for loading state
|
||||||
|
- `rendering-resource-hints` - Use React DOM resource hints for preloading
|
||||||
|
- `rendering-script-defer-async` - Use defer or async on script tags
|
||||||
|
|
||||||
|
### 7. JavaScript Performance (LOW-MEDIUM)
|
||||||
|
|
||||||
|
- `js-batch-dom-css` - Group CSS changes via classes or cssText
|
||||||
|
- `js-index-maps` - Build Map for repeated lookups
|
||||||
|
- `js-cache-property-access` - Cache object properties in loops
|
||||||
|
- `js-cache-function-results` - Cache function results in module-level Map
|
||||||
|
- `js-cache-storage` - Cache localStorage/sessionStorage reads
|
||||||
|
- `js-combine-iterations` - Combine multiple filter/map into one loop
|
||||||
|
- `js-length-check-first` - Check array length before expensive comparison
|
||||||
|
- `js-early-exit` - Return early from functions
|
||||||
|
- `js-hoist-regexp` - Hoist RegExp creation outside loops
|
||||||
|
- `js-min-max-loop` - Use loop for min/max instead of sort
|
||||||
|
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
|
||||||
|
- `js-tosorted-immutable` - Use toSorted() for immutability
|
||||||
|
- `js-flatmap-filter` - Use flatMap to map and filter in one pass
|
||||||
|
- `js-request-idle-callback` - Defer non-critical work to browser idle time
|
||||||
|
|
||||||
|
### 8. Advanced Patterns (LOW)
|
||||||
|
|
||||||
|
- `advanced-event-handler-refs` - Store event handlers in refs
|
||||||
|
- `advanced-init-once` - Initialize app once per app load
|
||||||
|
- `advanced-use-latest` - useLatest for stable callback refs
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Read individual rule files for detailed explanations and code examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
rules/async-parallel.md
|
||||||
|
rules/bundle-barrel-imports.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Each rule file contains:
|
||||||
|
- Brief explanation of why it matters
|
||||||
|
- Incorrect code example with explanation
|
||||||
|
- Correct code example with explanation
|
||||||
|
- Additional context and references
|
||||||
|
|
||||||
|
## Full Compiled Document
|
||||||
|
|
||||||
|
For the complete guide with all rules expanded: `AGENTS.md`
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Sections
|
||||||
|
|
||||||
|
This file defines all sections, their ordering, impact levels, and descriptions.
|
||||||
|
The section ID (in parentheses) is the filename prefix used to group rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Eliminating Waterfalls (async)
|
||||||
|
|
||||||
|
**Impact:** CRITICAL
|
||||||
|
**Description:** Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains.
|
||||||
|
|
||||||
|
## 2. Bundle Size Optimization (bundle)
|
||||||
|
|
||||||
|
**Impact:** CRITICAL
|
||||||
|
**Description:** Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint.
|
||||||
|
|
||||||
|
## 3. Server-Side Performance (server)
|
||||||
|
|
||||||
|
**Impact:** HIGH
|
||||||
|
**Description:** Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times.
|
||||||
|
|
||||||
|
## 4. Client-Side Data Fetching (client)
|
||||||
|
|
||||||
|
**Impact:** MEDIUM-HIGH
|
||||||
|
**Description:** Automatic deduplication and efficient data fetching patterns reduce redundant network requests.
|
||||||
|
|
||||||
|
## 5. Re-render Optimization (rerender)
|
||||||
|
|
||||||
|
**Impact:** MEDIUM
|
||||||
|
**Description:** Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness.
|
||||||
|
|
||||||
|
## 6. Rendering Performance (rendering)
|
||||||
|
|
||||||
|
**Impact:** MEDIUM
|
||||||
|
**Description:** Optimizing the rendering process reduces the work the browser needs to do.
|
||||||
|
|
||||||
|
## 7. JavaScript Performance (js)
|
||||||
|
|
||||||
|
**Impact:** LOW-MEDIUM
|
||||||
|
**Description:** Micro-optimizations for hot paths can add up to meaningful improvements.
|
||||||
|
|
||||||
|
## 8. Advanced Patterns (advanced)
|
||||||
|
|
||||||
|
**Impact:** LOW
|
||||||
|
**Description:** Advanced patterns for specific cases that require careful implementation.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
title: Rule Title Here
|
||||||
|
impact: MEDIUM
|
||||||
|
impactDescription: Optional description of impact (e.g., "20-50% improvement")
|
||||||
|
tags: tag1, tag2
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rule Title Here
|
||||||
|
|
||||||
|
**Impact: MEDIUM (optional impact description)**
|
||||||
|
|
||||||
|
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
|
||||||
|
|
||||||
|
**Incorrect (description of what's wrong):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad code example here
|
||||||
|
const bad = example()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (description of what's right):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good code example here
|
||||||
|
const good = example()
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [Link to documentation or resource](https://example.com)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
title: Store Event Handlers in Refs
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: stable subscriptions
|
||||||
|
tags: advanced, hooks, refs, event-handlers, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Store Event Handlers in Refs
|
||||||
|
|
||||||
|
Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes.
|
||||||
|
|
||||||
|
**Incorrect (re-subscribes on every render):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(event, handler)
|
||||||
|
return () => window.removeEventListener(event, handler)
|
||||||
|
}, [event, handler])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (stable subscription):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
const handlerRef = useRef(handler)
|
||||||
|
useEffect(() => {
|
||||||
|
handlerRef.current = handler
|
||||||
|
}, [handler])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (e) => handlerRef.current(e)
|
||||||
|
window.addEventListener(event, listener)
|
||||||
|
return () => window.removeEventListener(event, listener)
|
||||||
|
}, [event])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative: use `useEffectEvent` if you're on latest React:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffectEvent } from 'react'
|
||||||
|
|
||||||
|
function useWindowEvent(event: string, handler: (e) => void) {
|
||||||
|
const onEvent = useEffectEvent(handler)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(event, onEvent)
|
||||||
|
return () => window.removeEventListener(event, onEvent)
|
||||||
|
}, [event])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
title: Initialize App Once, Not Per Mount
|
||||||
|
impact: LOW-MEDIUM
|
||||||
|
impactDescription: avoids duplicate init in development
|
||||||
|
tags: initialization, useEffect, app-startup, side-effects
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initialize App Once, Not Per Mount
|
||||||
|
|
||||||
|
Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead.
|
||||||
|
|
||||||
|
**Incorrect (runs twice in dev, re-runs on remount):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function Comp() {
|
||||||
|
useEffect(() => {
|
||||||
|
loadFromStorage()
|
||||||
|
checkAuthToken()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (once per app load):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
let didInit = false
|
||||||
|
|
||||||
|
function Comp() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (didInit) return
|
||||||
|
didInit = true
|
||||||
|
loadFromStorage()
|
||||||
|
checkAuthToken()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
title: useEffectEvent for Stable Callback Refs
|
||||||
|
impact: LOW
|
||||||
|
impactDescription: prevents effect re-runs
|
||||||
|
tags: advanced, hooks, useEffectEvent, refs, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## useEffectEvent for Stable Callback Refs
|
||||||
|
|
||||||
|
Access latest values in callbacks without adding them to dependency arrays. Prevents effect re-runs while avoiding stale closures.
|
||||||
|
|
||||||
|
**Incorrect (effect re-runs on every callback change):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => onSearch(query), 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [query, onSearch])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (using React's useEffectEvent):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffectEvent } from 'react';
|
||||||
|
|
||||||
|
function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const onSearchEvent = useEffectEvent(onSearch)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => onSearchEvent(query), 300)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [query])
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: Prevent Waterfall Chains in API Routes
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: 2-10× improvement
|
||||||
|
tags: api-routes, server-actions, waterfalls, parallelization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prevent Waterfall Chains in API Routes
|
||||||
|
|
||||||
|
In API routes and Server Actions, start independent operations immediately, even if you don't await them yet.
|
||||||
|
|
||||||
|
**Incorrect (config waits for auth, data waits for both):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await auth()
|
||||||
|
const config = await fetchConfig()
|
||||||
|
const data = await fetchData(session.user.id)
|
||||||
|
return Response.json({ data, config })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (auth and config start immediately):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const sessionPromise = auth()
|
||||||
|
const configPromise = fetchConfig()
|
||||||
|
const session = await sessionPromise
|
||||||
|
const [config, data] = await Promise.all([
|
||||||
|
configPromise,
|
||||||
|
fetchData(session.user.id)
|
||||||
|
])
|
||||||
|
return Response.json({ data, config })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Check Cheap Conditions Before Async Flags
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: avoids unnecessary async work when a synchronous guard already fails
|
||||||
|
tags: async, await, feature-flags, short-circuit, conditional
|
||||||
|
---
|
||||||
|
|
||||||
|
## Check Cheap Conditions Before Async Flags
|
||||||
|
|
||||||
|
When a branch uses `await` for a flag or remote value and also requires a **cheap synchronous** condition (local props, request metadata, already-loaded state), evaluate the cheap condition **first**. Otherwise you pay for the async call even when the compound condition can never be true.
|
||||||
|
|
||||||
|
This is a specialization of [Defer Await Until Needed](./async-defer-await.md) for `flag && cheapCondition` style checks.
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const someFlag = await getFlag()
|
||||||
|
|
||||||
|
if (someFlag && someCondition) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (someCondition) {
|
||||||
|
const someFlag = await getFlag()
|
||||||
|
if (someFlag) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This matters when `getFlag` hits the network, a feature-flag service, or `React.cache` / DB work: skipping it when `someCondition` is false removes that cost on the cold path.
|
||||||
|
|
||||||
|
Keep the original order if `someCondition` is expensive, depends on the flag, or you must run side effects in a fixed order.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
title: Defer Await Until Needed
|
||||||
|
impact: HIGH
|
||||||
|
impactDescription: avoids blocking unused code paths
|
||||||
|
tags: async, await, conditional, optimization
|
||||||
|
---
|
||||||
|
|
||||||
|
## Defer Await Until Needed
|
||||||
|
|
||||||
|
Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them.
|
||||||
|
|
||||||
|
**Incorrect (blocks both branches):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function handleRequest(userId: string, skipProcessing: boolean) {
|
||||||
|
const userData = await fetchUserData(userId)
|
||||||
|
|
||||||
|
if (skipProcessing) {
|
||||||
|
// Returns immediately but still waited for userData
|
||||||
|
return { skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only this branch uses userData
|
||||||
|
return processUserData(userData)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (only blocks when needed):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function handleRequest(userId: string, skipProcessing: boolean) {
|
||||||
|
if (skipProcessing) {
|
||||||
|
// Returns immediately without waiting
|
||||||
|
return { skipped: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch only when needed
|
||||||
|
const userData = await fetchUserData(userId)
|
||||||
|
return processUserData(userData)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Another example (early return optimization):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Incorrect: always fetches permissions
|
||||||
|
async function updateResource(resourceId: string, userId: string) {
|
||||||
|
const permissions = await fetchPermissions(userId)
|
||||||
|
const resource = await getResource(resourceId)
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return { error: 'Not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissions.canEdit) {
|
||||||
|
return { error: 'Forbidden' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await updateResourceData(resource, permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct: fetches only when needed
|
||||||
|
async function updateResource(resourceId: string, userId: string) {
|
||||||
|
const resource = await getResource(resourceId)
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return { error: 'Not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = await fetchPermissions(userId)
|
||||||
|
|
||||||
|
if (!permissions.canEdit) {
|
||||||
|
return { error: 'Forbidden' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return await updateResourceData(resource, permissions)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive.
|
||||||
|
|
||||||
|
For `await getFlag()` combined with a cheap synchronous guard (`flag && someCondition`), see [Check Cheap Conditions Before Async Flags](./async-cheap-condition-before-await.md).
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
title: Dependency-Based Parallelization
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: 2-10× improvement
|
||||||
|
tags: async, parallelization, dependencies, better-all
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency-Based Parallelization
|
||||||
|
|
||||||
|
For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment.
|
||||||
|
|
||||||
|
**Incorrect (profile waits for config unnecessarily):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [user, config] = await Promise.all([
|
||||||
|
fetchUser(),
|
||||||
|
fetchConfig()
|
||||||
|
])
|
||||||
|
const profile = await fetchProfile(user.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct (config and profile run in parallel):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { all } from 'better-all'
|
||||||
|
|
||||||
|
const { user, config, profile } = await all({
|
||||||
|
async user() { return fetchUser() },
|
||||||
|
async config() { return fetchConfig() },
|
||||||
|
async profile() {
|
||||||
|
return fetchProfile((await this.$.user).id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alternative without extra dependencies:**
|
||||||
|
|
||||||
|
We can also create all the promises first, and do `Promise.all()` at the end.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const userPromise = fetchUser()
|
||||||
|
const profilePromise = userPromise.then(user => fetchProfile(user.id))
|
||||||
|
|
||||||
|
const [user, config, profile] = await Promise.all([
|
||||||
|
userPromise,
|
||||||
|
fetchConfig(),
|
||||||
|
profilePromise
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user