📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
---
|
||||
name: bun-dev
|
||||
description: This skill should be used when working with Bun runtime, bun:sqlite, Bun.serve, bun:test, or when "Bun", "bun:test", or Bun-specific patterns are mentioned.
|
||||
metadata:
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# Bun Development
|
||||
|
||||
Bun runtime → native APIs → zero-dependency patterns.
|
||||
|
||||
<when_to_use>
|
||||
|
||||
- Bun runtime development
|
||||
- SQLite database with bun:sqlite
|
||||
- HTTP server with Bun.serve
|
||||
- Testing with bun:test
|
||||
- File operations with Bun.file/Bun.write
|
||||
- Shell operations with $ template
|
||||
- Password hashing with Bun.password
|
||||
- Environment variable handling
|
||||
- Building and bundling
|
||||
|
||||
NOT for: Node.js-only patterns, cross-runtime libraries, non-Bun projects
|
||||
|
||||
</when_to_use>
|
||||
|
||||
<runtime_basics>
|
||||
|
||||
**Package management**:
|
||||
|
||||
```bash
|
||||
bun install # Install deps
|
||||
bun add zod # Add package
|
||||
bun remove zod # Remove package
|
||||
bun update # Update all
|
||||
```
|
||||
|
||||
**Script execution**:
|
||||
|
||||
```bash
|
||||
bun run dev # Run package.json script
|
||||
bun run src/index.ts # Execute TypeScript directly
|
||||
bun --watch index.ts # Watch mode
|
||||
```
|
||||
|
||||
**Testing**:
|
||||
|
||||
```bash
|
||||
bun test # All tests
|
||||
bun test src/ # Directory
|
||||
bun test --watch # Watch mode
|
||||
bun test --coverage # With coverage
|
||||
```
|
||||
|
||||
**Building**:
|
||||
|
||||
```bash
|
||||
bun build ./index.ts --outfile dist/bundle.js
|
||||
bun build ./index.ts --compile --outfile myapp # Standalone executable
|
||||
```
|
||||
|
||||
</runtime_basics>
|
||||
|
||||
## File Operations
|
||||
|
||||
<file_operations>
|
||||
|
||||
```typescript
|
||||
// Read file (lazy, efficient)
|
||||
const file = Bun.file('./data.json');
|
||||
if (!(await file.exists())) throw new Error('File not found');
|
||||
|
||||
// Read formats
|
||||
const text = await file.text();
|
||||
const json = await file.json();
|
||||
const buffer = await file.arrayBuffer();
|
||||
const stream = file.stream(); // Large files
|
||||
|
||||
// Metadata
|
||||
console.log(file.size, file.type);
|
||||
|
||||
// Write
|
||||
await Bun.write('./output.txt', 'content');
|
||||
await Bun.write('./data.json', JSON.stringify(data));
|
||||
await Bun.write('./blob.txt', new Blob(['data']));
|
||||
```
|
||||
|
||||
</file_operations>
|
||||
|
||||
## SQLite (bun:sqlite)
|
||||
|
||||
<sqlite>
|
||||
|
||||
```typescript
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
const db = new Database('app.db', { create: true, readwrite: true, strict: true });
|
||||
|
||||
// Create tables
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Prepared statements (always use these)
|
||||
const getUser = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||
const createUser = db.prepare('INSERT INTO users (id, email, name) VALUES (?, ?, ?) RETURNING *');
|
||||
|
||||
// Execution
|
||||
const user = getUser.get('user-123'); // Single row
|
||||
const all = db.prepare('SELECT * FROM users').all(); // All rows
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run('id'); // No return
|
||||
|
||||
// Named parameters
|
||||
const stmt = db.prepare('SELECT * FROM users WHERE email = $email');
|
||||
stmt.get({ $email: 'alice@example.com' });
|
||||
|
||||
// Transactions (atomic, auto-rollback on error)
|
||||
const transfer = db.transaction((fromId: string, toId: string, amount: number) => {
|
||||
db.run('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId]);
|
||||
db.run('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId]);
|
||||
});
|
||||
transfer('alice', 'bob', 100);
|
||||
|
||||
db.close(); // When done
|
||||
```
|
||||
|
||||
See [sqlite-patterns.md](references/sqlite-patterns.md) for migrations, pooling, repository pattern.
|
||||
|
||||
</sqlite>
|
||||
|
||||
## Password Hashing
|
||||
|
||||
<password>
|
||||
|
||||
```typescript
|
||||
// Hash (argon2id recommended)
|
||||
const hash = await Bun.password.hash('password123', {
|
||||
algorithm: 'argon2id',
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3
|
||||
});
|
||||
|
||||
// Or bcrypt
|
||||
const bcryptHash = await Bun.password.hash('password123', {
|
||||
algorithm: 'bcrypt',
|
||||
cost: 12
|
||||
});
|
||||
|
||||
// Verify
|
||||
const isValid = await Bun.password.verify('password123', hash);
|
||||
if (!isValid) throw new Error('Invalid password');
|
||||
```
|
||||
|
||||
**Auth flow example**:
|
||||
|
||||
```typescript
|
||||
app.post('/auth/register', zValidator('json', RegisterSchema), async (c) => {
|
||||
const { email, password } = c.req.valid('json');
|
||||
const db = c.get('db');
|
||||
|
||||
if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) {
|
||||
throw new HTTPException(409, { message: 'Email already registered' });
|
||||
}
|
||||
|
||||
const hashedPassword = await Bun.password.hash(password, { algorithm: 'argon2id' });
|
||||
const user = db.prepare(`
|
||||
INSERT INTO users (id, email, password) VALUES (?, ?, ?) RETURNING id, email
|
||||
`).get(crypto.randomUUID(), email, hashedPassword);
|
||||
|
||||
return c.json({ user }, 201);
|
||||
});
|
||||
```
|
||||
|
||||
</password>
|
||||
|
||||
## HTTP Server
|
||||
|
||||
<http_server>
|
||||
|
||||
```typescript
|
||||
Bun.serve({
|
||||
port: 3000,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === '/') return new Response('Hello');
|
||||
if (url.pathname === '/json') return Response.json({ ok: true });
|
||||
return new Response('Not found', { status: 404 });
|
||||
},
|
||||
error(err) {
|
||||
return new Response(`Error: ${err.message}`, { status: 500 });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**With Hono** (recommended for APIs):
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const app = new Hono()
|
||||
.get('/', (c) => c.text('Hello'))
|
||||
.get('/json', (c) => c.json({ ok: true }));
|
||||
|
||||
Bun.serve({ port: 3000, fetch: app.fetch });
|
||||
```
|
||||
|
||||
See [server-patterns.md](references/server-patterns.md) for routing, middleware, file serving, streaming.
|
||||
|
||||
</http_server>
|
||||
|
||||
## WebSocket
|
||||
|
||||
<websocket>
|
||||
|
||||
```typescript
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
|
||||
type WsData = { userId: string };
|
||||
|
||||
Bun.serve<WsData>({
|
||||
port: 3000,
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === '/ws') {
|
||||
const userId = url.searchParams.get('userId') || 'anon';
|
||||
return server.upgrade(req, { data: { userId } }) ? undefined
|
||||
: new Response('Upgrade failed', { status: 400 });
|
||||
}
|
||||
return new Response('Hello');
|
||||
},
|
||||
websocket: {
|
||||
open(ws: ServerWebSocket<WsData>) {
|
||||
ws.subscribe('chat');
|
||||
ws.send(JSON.stringify({ type: 'connected' }));
|
||||
},
|
||||
message(ws: ServerWebSocket<WsData>, msg: string | Buffer) {
|
||||
ws.publish('chat', msg);
|
||||
},
|
||||
close(ws: ServerWebSocket<WsData>) {
|
||||
ws.unsubscribe('chat');
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
See [server-patterns.md](references/server-patterns.md) for client tracking, rooms, reconnection.
|
||||
|
||||
</websocket>
|
||||
|
||||
## Shell Operations
|
||||
|
||||
<shell>
|
||||
|
||||
```typescript
|
||||
import { $ } from 'bun';
|
||||
|
||||
// Run commands
|
||||
const result = await $`ls -la`;
|
||||
console.log(result.text());
|
||||
|
||||
// Variables (auto-escaped)
|
||||
const dir = './src';
|
||||
await $`find ${dir} -name "*.ts"`;
|
||||
|
||||
// Check exit code
|
||||
const { exitCode } = await $`npm test`.nothrow();
|
||||
if (exitCode !== 0) console.error('Tests failed');
|
||||
|
||||
// Spawn process
|
||||
const proc = Bun.spawn(['ls', '-la']);
|
||||
await proc.exited;
|
||||
|
||||
// Capture output
|
||||
const proc2 = Bun.spawn(['echo', 'Hello'], { stdout: 'pipe' });
|
||||
const output = await new Response(proc2.stdout).text();
|
||||
```
|
||||
|
||||
</shell>
|
||||
|
||||
## Testing (bun:test)
|
||||
|
||||
<testing>
|
||||
|
||||
```typescript
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
describe('feature', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => { db = new Database(':memory:'); });
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
test('behavior', () => {
|
||||
expect(result).toBe(expected);
|
||||
expect(arr).toContain(item);
|
||||
expect(fn).toThrow();
|
||||
expect(obj).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
test('async', async () => {
|
||||
const result = await asyncFn();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test.todo('pending feature');
|
||||
test.skip('temporarily disabled');
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
bun test # All tests
|
||||
bun test src/api.test.ts # Specific file
|
||||
bun test --watch # Watch mode
|
||||
bun test --coverage # With coverage
|
||||
```
|
||||
|
||||
See [testing.md](references/testing.md) for assertions, mocking, snapshots, best practices.
|
||||
|
||||
</testing>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
<environment>
|
||||
|
||||
```typescript
|
||||
// Access
|
||||
console.log(Bun.env.NODE_ENV);
|
||||
console.log(Bun.env.DATABASE_URL);
|
||||
|
||||
// Zod validation
|
||||
import { z } from 'zod';
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
DATABASE_URL: z.string(),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
API_KEY: z.string().min(32)
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(Bun.env);
|
||||
```
|
||||
|
||||
Bun auto-loads `.env`, `.env.local`, `.env.production`.
|
||||
|
||||
</environment>
|
||||
|
||||
## Performance Utilities
|
||||
|
||||
<performance>
|
||||
|
||||
```typescript
|
||||
// High-resolution timing
|
||||
const start = Bun.nanoseconds();
|
||||
await doWork();
|
||||
console.log(`Took ${(Bun.nanoseconds() - start) / 1_000_000}ms`);
|
||||
|
||||
// Hashing
|
||||
const hash = Bun.hash(data);
|
||||
const crc32 = Bun.hash.crc32(data);
|
||||
const sha256 = Bun.CryptoHasher.hash('sha256', data);
|
||||
|
||||
// Sleep
|
||||
await Bun.sleep(1000);
|
||||
|
||||
// Memory
|
||||
const { rss, heapUsed } = process.memoryUsage();
|
||||
console.log('RSS:', rss / 1024 / 1024, 'MB');
|
||||
```
|
||||
|
||||
</performance>
|
||||
|
||||
## Building & Bundling
|
||||
|
||||
<building>
|
||||
|
||||
```bash
|
||||
# Production bundle
|
||||
bun build ./index.ts --outfile dist/bundle.js --minify --sourcemap
|
||||
|
||||
# External deps
|
||||
bun build ./index.ts --outfile dist/bundle.js --external hono --external zod
|
||||
|
||||
# Standalone executable
|
||||
bun build ./index.ts --compile --outfile myapp
|
||||
|
||||
# Cross-compile
|
||||
bun build ./index.ts --compile --target=bun-linux-x64 --outfile myapp-linux
|
||||
bun build ./index.ts --compile --target=bun-darwin-arm64 --outfile myapp-macos
|
||||
bun build ./index.ts --compile --target=bun-windows-x64 --outfile myapp.exe
|
||||
```
|
||||
|
||||
</building>
|
||||
|
||||
<rules>
|
||||
|
||||
ALWAYS:
|
||||
- Use Bun APIs when available (faster, native)
|
||||
- Prepared statements for database queries
|
||||
- Transactions for multi-statement operations
|
||||
- argon2id for password hashing
|
||||
- Validate environment variables at startup
|
||||
- Close database connections when done
|
||||
|
||||
NEVER:
|
||||
- String interpolation in SQL (use parameters)
|
||||
- Plaintext passwords
|
||||
- Ignore async disposal cleanup
|
||||
- Deprecated Node.js APIs when Bun native exists
|
||||
|
||||
PREFER:
|
||||
- Bun.file over fs.readFile
|
||||
- Bun.write over fs.writeFile
|
||||
- bun:sqlite over external SQLite libraries
|
||||
- Bun.password over bcrypt/argon2 packages
|
||||
- $ shell template over child_process
|
||||
|
||||
</rules>
|
||||
|
||||
<references>
|
||||
|
||||
- [sqlite-patterns.md](references/sqlite-patterns.md) — migrations, pooling, repository, FTS
|
||||
- [server-patterns.md](references/server-patterns.md) — HTTP, WebSocket, streaming, compression
|
||||
- [testing.md](references/testing.md) — assertions, mocking, snapshots, best practices
|
||||
|
||||
**Examples:**
|
||||
- [database-crud.md](examples/database-crud.md) — SQLite CRUD patterns
|
||||
- [file-uploads.md](examples/file-uploads.md) — streaming file handling
|
||||
|
||||
</references>
|
||||
@@ -0,0 +1,446 @@
|
||||
# SQLite CRUD Patterns
|
||||
|
||||
Complete examples for database operations with bun:sqlite.
|
||||
|
||||
## Basic Repository
|
||||
|
||||
```typescript
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
class UserRepository {
|
||||
private stmt: {
|
||||
findById: ReturnType<Database['prepare']>;
|
||||
findByEmail: ReturnType<Database['prepare']>;
|
||||
findAll: ReturnType<Database['prepare']>;
|
||||
create: ReturnType<Database['prepare']>;
|
||||
update: ReturnType<Database['prepare']>;
|
||||
delete: ReturnType<Database['prepare']>;
|
||||
};
|
||||
|
||||
constructor(private db: Database) {
|
||||
// Initialize schema
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Prepare statements once
|
||||
this.stmt = {
|
||||
findById: this.db.prepare('SELECT * FROM users WHERE id = ?'),
|
||||
findByEmail: this.db.prepare('SELECT * FROM users WHERE email = ?'),
|
||||
findAll: this.db.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT ?'),
|
||||
create: this.db.prepare('INSERT INTO users (id, email, name) VALUES (?, ?, ?) RETURNING *'),
|
||||
update: this.db.prepare('UPDATE users SET email = ?, name = ? WHERE id = ? RETURNING *'),
|
||||
delete: this.db.prepare('DELETE FROM users WHERE id = ? RETURNING *')
|
||||
};
|
||||
}
|
||||
|
||||
findById(id: string): User | null {
|
||||
const row = this.stmt.findById.get(id) as UserRow | null;
|
||||
return row ? this.mapRow(row) : null;
|
||||
}
|
||||
|
||||
findByEmail(email: string): User | null {
|
||||
const row = this.stmt.findByEmail.get(email) as UserRow | null;
|
||||
return row ? this.mapRow(row) : null;
|
||||
}
|
||||
|
||||
findAll(limit = 100): User[] {
|
||||
const rows = this.stmt.findAll.all(limit) as UserRow[];
|
||||
return rows.map(row => this.mapRow(row));
|
||||
}
|
||||
|
||||
create(data: { email: string; name: string }): User {
|
||||
const id = crypto.randomUUID();
|
||||
const row = this.stmt.create.get(id, data.email, data.name) as UserRow;
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
update(id: string, data: { email?: string; name?: string }): User | null {
|
||||
const existing = this.findById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const row = this.stmt.update.get(
|
||||
data.email ?? existing.email,
|
||||
data.name ?? existing.name,
|
||||
id
|
||||
) as UserRow;
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const row = this.stmt.delete.get(id);
|
||||
return row !== null;
|
||||
}
|
||||
|
||||
private mapRow(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
createdAt: new Date(row.created_at)
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
const db = new Database('app.db');
|
||||
const users = new UserRepository(db);
|
||||
|
||||
// Create
|
||||
const user = users.create({
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice'
|
||||
});
|
||||
console.log('Created:', user.id);
|
||||
|
||||
// Read
|
||||
const found = users.findById(user.id);
|
||||
console.log('Found:', found?.email);
|
||||
|
||||
// Update
|
||||
const updated = users.update(user.id, { name: 'Alice Smith' });
|
||||
console.log('Updated:', updated?.name);
|
||||
|
||||
// Delete
|
||||
const deleted = users.delete(user.id);
|
||||
console.log('Deleted:', deleted);
|
||||
|
||||
// List
|
||||
const allUsers = users.findAll(10);
|
||||
console.log('All users:', allUsers.length);
|
||||
|
||||
db.close();
|
||||
```
|
||||
|
||||
## With Transactions
|
||||
|
||||
```typescript
|
||||
class AccountRepository {
|
||||
constructor(private db: Database) {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
balance INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
transfer = this.db.transaction((fromId: string, toId: string, amount: number) => {
|
||||
// Check balance
|
||||
const from = this.db.prepare('SELECT balance FROM accounts WHERE id = ?').get(fromId) as { balance: number } | null;
|
||||
|
||||
if (!from) {
|
||||
throw new Error('Source account not found');
|
||||
}
|
||||
|
||||
if (from.balance < amount) {
|
||||
throw new Error('Insufficient funds');
|
||||
}
|
||||
|
||||
// Debit
|
||||
this.db.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?').run(amount, fromId);
|
||||
|
||||
// Credit
|
||||
this.db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?').run(amount, toId);
|
||||
|
||||
return { fromId, toId, amount };
|
||||
});
|
||||
|
||||
bulkCreate = this.db.transaction((accounts: Array<{ userId: string; balance: number }>) => {
|
||||
const stmt = this.db.prepare('INSERT INTO accounts (id, user_id, balance) VALUES (?, ?, ?)');
|
||||
|
||||
const created = [];
|
||||
for (const account of accounts) {
|
||||
const id = crypto.randomUUID();
|
||||
stmt.run(id, account.userId, account.balance);
|
||||
created.push(id);
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Pagination
|
||||
|
||||
```typescript
|
||||
type PaginatedResult<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
class UserRepository {
|
||||
// ... other methods
|
||||
|
||||
findPaginated(page: number, pageSize: number): PaginatedResult<User> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const countResult = this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
|
||||
const total = countResult.count;
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(pageSize, offset) as UserRow[];
|
||||
|
||||
return {
|
||||
items: rows.map(row => this.mapRow(row)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const result = users.findPaginated(1, 20);
|
||||
console.log(`Page ${result.page} of ${result.totalPages}`);
|
||||
console.log(`Showing ${result.items.length} of ${result.total} users`);
|
||||
```
|
||||
|
||||
## Search with Full-Text
|
||||
|
||||
```typescript
|
||||
class PostRepository {
|
||||
constructor(private db: Database) {
|
||||
// Create main table
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create FTS index
|
||||
this.db.run(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
title,
|
||||
content,
|
||||
content='posts',
|
||||
content_rowid='rowid'
|
||||
)
|
||||
`);
|
||||
|
||||
// Triggers to keep FTS in sync
|
||||
this.db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN
|
||||
INSERT INTO posts_fts(rowid, title, content)
|
||||
VALUES (new.rowid, new.title, new.content);
|
||||
END
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS posts_ad AFTER DELETE ON posts BEGIN
|
||||
INSERT INTO posts_fts(posts_fts, rowid, title, content)
|
||||
VALUES ('delete', old.rowid, old.title, old.content);
|
||||
END
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS posts_au AFTER UPDATE ON posts BEGIN
|
||||
INSERT INTO posts_fts(posts_fts, rowid, title, content)
|
||||
VALUES ('delete', old.rowid, old.title, old.content);
|
||||
INSERT INTO posts_fts(rowid, title, content)
|
||||
VALUES (new.rowid, new.title, new.content);
|
||||
END
|
||||
`);
|
||||
}
|
||||
|
||||
search(query: string, limit = 20) {
|
||||
return this.db.prepare(`
|
||||
SELECT posts.*, bm25(posts_fts) as rank
|
||||
FROM posts
|
||||
JOIN posts_fts ON posts.rowid = posts_fts.rowid
|
||||
WHERE posts_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`).all(query, limit);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const posts = new PostRepository(db);
|
||||
const results = posts.search('typescript tutorial');
|
||||
```
|
||||
|
||||
## JSON Storage
|
||||
|
||||
```typescript
|
||||
type Settings = {
|
||||
theme: 'light' | 'dark';
|
||||
notifications: boolean;
|
||||
language: string;
|
||||
};
|
||||
|
||||
class SettingsRepository {
|
||||
constructor(private db: Database) {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
get(userId: string): Settings | null {
|
||||
const row = this.db.prepare('SELECT data FROM settings WHERE user_id = ?').get(userId) as { data: string } | null;
|
||||
|
||||
if (!row) return null;
|
||||
return JSON.parse(row.data);
|
||||
}
|
||||
|
||||
set(userId: string, settings: Settings): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO settings (user_id, data) VALUES (?, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET data = excluded.data
|
||||
`).run(userId, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
update(userId: string, partial: Partial<Settings>): Settings | null {
|
||||
const existing = this.get(userId);
|
||||
if (!existing) return null;
|
||||
|
||||
const updated = { ...existing, ...partial };
|
||||
this.set(userId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Query JSON fields directly
|
||||
findByTheme(theme: 'light' | 'dark') {
|
||||
return this.db.prepare(`
|
||||
SELECT user_id FROM settings
|
||||
WHERE json_extract(data, '$.theme') = ?
|
||||
`).all(theme);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## With Hono API
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { createFactory } from 'hono/factory';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
type Env = {
|
||||
Variables: {
|
||||
db: Database;
|
||||
users: UserRepository;
|
||||
};
|
||||
};
|
||||
|
||||
const factory = createFactory<Env>();
|
||||
|
||||
const dbMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const db = new Database('app.db');
|
||||
const users = new UserRepository(db);
|
||||
|
||||
c.set('db', db);
|
||||
c.set('users', users);
|
||||
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1).max(100)
|
||||
});
|
||||
|
||||
const UpdateUserSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().min(1).max(100).optional()
|
||||
});
|
||||
|
||||
const app = factory.createApp()
|
||||
.use('*', dbMiddleware)
|
||||
.get('/users', (c) => {
|
||||
const users = c.get('users');
|
||||
const limit = Number(c.req.query('limit')) || 20;
|
||||
return c.json({ users: users.findAll(limit) });
|
||||
})
|
||||
.get('/users/:id', (c) => {
|
||||
const users = c.get('users');
|
||||
const user = users.findById(c.req.param('id'));
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({ user });
|
||||
})
|
||||
.post('/users', zValidator('json', CreateUserSchema), (c) => {
|
||||
const users = c.get('users');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const existing = users.findByEmail(data.email);
|
||||
if (existing) {
|
||||
throw new HTTPException(409, { message: 'Email already registered' });
|
||||
}
|
||||
|
||||
const user = users.create(data);
|
||||
return c.json({ user }, 201);
|
||||
})
|
||||
.patch('/users/:id', zValidator('json', UpdateUserSchema), (c) => {
|
||||
const users = c.get('users');
|
||||
const data = c.req.valid('json');
|
||||
|
||||
const user = users.update(c.req.param('id'), data);
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({ user });
|
||||
})
|
||||
.delete('/users/:id', (c) => {
|
||||
const users = c.get('users');
|
||||
const deleted = users.delete(c.req.param('id'));
|
||||
|
||||
if (!deleted) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
export default app;
|
||||
```
|
||||
@@ -0,0 +1,427 @@
|
||||
# File Upload Patterns
|
||||
|
||||
Streaming file handling with Bun.file and Bun.write.
|
||||
|
||||
## Basic Upload
|
||||
|
||||
```typescript
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.post('/upload', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file as File;
|
||||
|
||||
if (!file) {
|
||||
throw new HTTPException(400, { message: 'File is required' });
|
||||
}
|
||||
|
||||
const filename = `${crypto.randomUUID()}-${file.name}`;
|
||||
const filepath = `./uploads/${filename}`;
|
||||
|
||||
await Bun.write(filepath, file);
|
||||
|
||||
return c.json({
|
||||
filename,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## With Validation
|
||||
|
||||
```typescript
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
app.post('/upload/image', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file as File;
|
||||
|
||||
if (!file) {
|
||||
throw new HTTPException(400, { message: 'File is required' });
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
throw new HTTPException(400, {
|
||||
message: `Invalid file type. Allowed: ${ALLOWED_TYPES.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
// Validate size
|
||||
if (file.size > MAX_SIZE) {
|
||||
throw new HTTPException(400, {
|
||||
message: `File too large. Max size: ${MAX_SIZE / 1024 / 1024}MB`
|
||||
});
|
||||
}
|
||||
|
||||
// Generate safe filename
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || 'bin';
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const filepath = `./uploads/${filename}`;
|
||||
|
||||
await Bun.write(filepath, file);
|
||||
|
||||
return c.json({ filename, size: file.size, type: file.type }, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## Multiple Files
|
||||
|
||||
```typescript
|
||||
app.post('/upload/multiple', async (c) => {
|
||||
const body = await c.req.parseBody({ all: true });
|
||||
const files = body.files as File[];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
throw new HTTPException(400, { message: 'At least one file is required' });
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_SIZE) {
|
||||
throw new HTTPException(400, {
|
||||
message: `File ${file.name} exceeds max size`
|
||||
});
|
||||
}
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || 'bin';
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const filepath = `./uploads/${filename}`;
|
||||
|
||||
await Bun.write(filepath, file);
|
||||
|
||||
results.push({
|
||||
original: file.name,
|
||||
filename,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ files: results }, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## Streaming Large Files
|
||||
|
||||
```typescript
|
||||
app.post('/upload/large', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file as File;
|
||||
|
||||
if (!file) {
|
||||
throw new HTTPException(400, { message: 'File is required' });
|
||||
}
|
||||
|
||||
const filename = `${crypto.randomUUID()}.bin`;
|
||||
const filepath = `./uploads/${filename}`;
|
||||
|
||||
// Stream directly to disk — efficient for large files
|
||||
await Bun.write(filepath, file.stream());
|
||||
|
||||
return c.json({ filename, size: file.size }, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## Download Files
|
||||
|
||||
```typescript
|
||||
app.get('/files/:filename', async (c) => {
|
||||
const filename = c.req.param('filename');
|
||||
|
||||
// Prevent directory traversal
|
||||
if (filename.includes('..') || filename.includes('/')) {
|
||||
throw new HTTPException(400, { message: 'Invalid filename' });
|
||||
}
|
||||
|
||||
const filepath = `./uploads/${filename}`;
|
||||
const file = Bun.file(filepath);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
throw new HTTPException(404, { message: 'File not found' });
|
||||
}
|
||||
|
||||
return c.body(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
'Content-Length': file.size.toString(),
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Inline Display (Images)
|
||||
|
||||
```typescript
|
||||
app.get('/images/:filename', async (c) => {
|
||||
const filename = c.req.param('filename');
|
||||
|
||||
if (filename.includes('..') || filename.includes('/')) {
|
||||
throw new HTTPException(400, { message: 'Invalid filename' });
|
||||
}
|
||||
|
||||
const filepath = `./uploads/${filename}`;
|
||||
const file = Bun.file(filepath);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
throw new HTTPException(404, { message: 'File not found' });
|
||||
}
|
||||
|
||||
// Verify it's an image
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new HTTPException(400, { message: 'Not an image' });
|
||||
}
|
||||
|
||||
return c.body(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
'Content-Length': file.size.toString(),
|
||||
'Cache-Control': 'public, max-age=31536000' // 1 year cache
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## With Database Metadata
|
||||
|
||||
```typescript
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
type FileRecord = {
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
class FileRepository {
|
||||
constructor(private db: Database) {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT UNIQUE NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
uploaded_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
user_id TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
create(data: Omit<FileRecord, 'id' | 'uploadedAt'>): FileRecord {
|
||||
const id = crypto.randomUUID();
|
||||
return this.db.prepare(`
|
||||
INSERT INTO files (id, filename, original_name, mime_type, size, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING *
|
||||
`).get(id, data.filename, data.originalName, data.mimeType, data.size, data.userId) as FileRecord;
|
||||
}
|
||||
|
||||
findById(id: string): FileRecord | null {
|
||||
return this.db.prepare('SELECT * FROM files WHERE id = ?').get(id) as FileRecord | null;
|
||||
}
|
||||
|
||||
findByUser(userId: string): FileRecord[] {
|
||||
return this.db.prepare('SELECT * FROM files WHERE user_id = ? ORDER BY uploaded_at DESC').all(userId) as FileRecord[];
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const result = this.db.prepare('DELETE FROM files WHERE id = ? RETURNING filename').get(id) as { filename: string } | null;
|
||||
return result !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// API with metadata
|
||||
app.post('/files', async (c) => {
|
||||
const userId = c.get('userId'); // From auth middleware
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file as File;
|
||||
|
||||
if (!file) {
|
||||
throw new HTTPException(400, { message: 'File is required' });
|
||||
}
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || 'bin';
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const filepath = `./uploads/${filename}`;
|
||||
|
||||
await Bun.write(filepath, file);
|
||||
|
||||
const files = c.get('files') as FileRepository;
|
||||
const record = files.create({
|
||||
filename,
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
userId
|
||||
});
|
||||
|
||||
return c.json({ file: record }, 201);
|
||||
});
|
||||
|
||||
app.delete('/files/:id', async (c) => {
|
||||
const files = c.get('files') as FileRepository;
|
||||
const record = files.findById(c.req.param('id'));
|
||||
|
||||
if (!record) {
|
||||
throw new HTTPException(404, { message: 'File not found' });
|
||||
}
|
||||
|
||||
// Delete from disk
|
||||
const filepath = `./uploads/${record.filename}`;
|
||||
const file = Bun.file(filepath);
|
||||
if (await file.exists()) {
|
||||
await Bun.write(filepath, ''); // Clear file
|
||||
// Or use node:fs for actual deletion
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
files.delete(record.id);
|
||||
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Image Processing
|
||||
|
||||
```typescript
|
||||
import sharp from 'sharp'; // npm install sharp
|
||||
|
||||
const THUMBNAIL_SIZE = 200;
|
||||
|
||||
app.post('/upload/image-with-thumbnail', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.file as File;
|
||||
|
||||
if (!file) {
|
||||
throw new HTTPException(400, { message: 'File is required' });
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new HTTPException(400, { message: 'Must be an image' });
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
|
||||
// Save original
|
||||
const originalPath = `./uploads/${id}.${ext}`;
|
||||
const buffer = await file.arrayBuffer();
|
||||
await Bun.write(originalPath, buffer);
|
||||
|
||||
// Create thumbnail
|
||||
const thumbnailPath = `./uploads/${id}-thumb.${ext}`;
|
||||
await sharp(Buffer.from(buffer))
|
||||
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: 'cover' })
|
||||
.toFile(thumbnailPath);
|
||||
|
||||
return c.json({
|
||||
id,
|
||||
original: `${id}.${ext}`,
|
||||
thumbnail: `${id}-thumb.${ext}`,
|
||||
size: file.size,
|
||||
type: file.type
|
||||
}, 201);
|
||||
});
|
||||
```
|
||||
|
||||
## Presigned URLs (S3-style)
|
||||
|
||||
```typescript
|
||||
import { sign, verify } from 'hono/jwt';
|
||||
|
||||
const SECRET = Bun.env.JWT_SECRET!;
|
||||
const EXPIRY = 3600; // 1 hour
|
||||
|
||||
// Generate presigned URL
|
||||
app.post('/files/:id/presign', async (c) => {
|
||||
const fileId = c.req.param('id');
|
||||
const files = c.get('files') as FileRepository;
|
||||
|
||||
const record = files.findById(fileId);
|
||||
if (!record) {
|
||||
throw new HTTPException(404, { message: 'File not found' });
|
||||
}
|
||||
|
||||
const token = await sign({
|
||||
fileId,
|
||||
exp: Math.floor(Date.now() / 1000) + EXPIRY
|
||||
}, SECRET);
|
||||
|
||||
const url = `${c.req.url.split('/files')[0]}/download?token=${token}`;
|
||||
|
||||
return c.json({ url, expiresIn: EXPIRY });
|
||||
});
|
||||
|
||||
// Download with presigned URL
|
||||
app.get('/download', async (c) => {
|
||||
const token = c.req.query('token');
|
||||
|
||||
if (!token) {
|
||||
throw new HTTPException(401, { message: 'Token required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await verify(token, SECRET);
|
||||
const fileId = payload.fileId as string;
|
||||
|
||||
const files = c.get('files') as FileRepository;
|
||||
const record = files.findById(fileId);
|
||||
|
||||
if (!record) {
|
||||
throw new HTTPException(404, { message: 'File not found' });
|
||||
}
|
||||
|
||||
const filepath = `./uploads/${record.filename}`;
|
||||
const file = Bun.file(filepath);
|
||||
|
||||
return c.body(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': record.mimeType,
|
||||
'Content-Length': record.size.toString(),
|
||||
'Content-Disposition': `attachment; filename="${record.originalName}"`
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
throw new HTTPException(401, { message: 'Invalid or expired token' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Cleanup Old Files
|
||||
|
||||
```typescript
|
||||
import { readdir, unlink, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
async function cleanupOldFiles(directory: string, maxAgeDays: number) {
|
||||
const files = await readdir(directory);
|
||||
const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const filename of files) {
|
||||
const filepath = join(directory, filename);
|
||||
const stats = await stat(filepath);
|
||||
|
||||
if (stats.mtimeMs < cutoff) {
|
||||
await unlink(filepath);
|
||||
console.log(`Deleted old file: ${filename}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run cleanup every hour
|
||||
setInterval(() => {
|
||||
cleanupOldFiles('./uploads', 30); // Delete files older than 30 days
|
||||
}, 60 * 60 * 1000);
|
||||
```
|
||||
@@ -0,0 +1,409 @@
|
||||
# Bun Server Patterns
|
||||
|
||||
HTTP, WebSocket, and streaming patterns with Bun.serve.
|
||||
|
||||
## Basic HTTP Server
|
||||
|
||||
```typescript
|
||||
Bun.serve({
|
||||
port: 3000,
|
||||
hostname: '0.0.0.0', // Listen on all interfaces
|
||||
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
switch (url.pathname) {
|
||||
case '/':
|
||||
return new Response('Hello, World!');
|
||||
case '/json':
|
||||
return Response.json({ ok: true });
|
||||
case '/html':
|
||||
return new Response('<h1>Hello</h1>', {
|
||||
headers: { 'Content-Type': 'text/html' }
|
||||
});
|
||||
default:
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
},
|
||||
|
||||
error(err) {
|
||||
console.error('Server error:', err);
|
||||
return new Response(`Error: ${err.message}`, { status: 500 });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Request Handling
|
||||
|
||||
```typescript
|
||||
Bun.serve({
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Method routing
|
||||
if (req.method === 'POST' && url.pathname === '/users') {
|
||||
const body = await req.json();
|
||||
// Process body...
|
||||
return Response.json({ id: '123', ...body }, { status: 201 });
|
||||
}
|
||||
|
||||
// Query parameters
|
||||
if (url.pathname === '/search') {
|
||||
const query = url.searchParams.get('q');
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
// Search logic...
|
||||
}
|
||||
|
||||
// Headers
|
||||
const auth = req.headers.get('Authorization');
|
||||
const contentType = req.headers.get('Content-Type');
|
||||
|
||||
// URL parameters (manual parsing)
|
||||
const match = url.pathname.match(/^\/users\/([^/]+)$/);
|
||||
if (match) {
|
||||
const userId = match[1];
|
||||
// Fetch user...
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Response Patterns
|
||||
|
||||
```typescript
|
||||
// Plain text
|
||||
new Response('Hello')
|
||||
|
||||
// JSON
|
||||
Response.json({ data: 'value' })
|
||||
|
||||
// With status
|
||||
new Response('Created', { status: 201 })
|
||||
Response.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
// With headers
|
||||
new Response('data', {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Cache-Control': 'max-age=3600',
|
||||
'X-Custom-Header': 'value'
|
||||
}
|
||||
})
|
||||
|
||||
// Redirect
|
||||
Response.redirect('/new-location', 302)
|
||||
|
||||
// Stream
|
||||
new Response(readableStream, {
|
||||
headers: { 'Content-Type': 'application/octet-stream' }
|
||||
})
|
||||
```
|
||||
|
||||
## File Serving
|
||||
|
||||
```typescript
|
||||
Bun.serve({
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Serve static files
|
||||
if (url.pathname.startsWith('/static/')) {
|
||||
const filepath = `./public${url.pathname}`;
|
||||
const file = Bun.file(filepath);
|
||||
|
||||
if (!(await file.exists())) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
'Content-Length': file.size.toString(),
|
||||
'Cache-Control': 'public, max-age=31536000'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// File download
|
||||
if (url.pathname.startsWith('/download/')) {
|
||||
const filename = url.pathname.split('/').pop();
|
||||
const file = Bun.file(`./files/${filename}`);
|
||||
|
||||
return new Response(file.stream(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## WebSocket Server
|
||||
|
||||
```typescript
|
||||
type WebSocketData = {
|
||||
id: string;
|
||||
userId: string;
|
||||
joinedAt: Date;
|
||||
};
|
||||
|
||||
const clients = new Map<string, ServerWebSocket<WebSocketData>>();
|
||||
|
||||
Bun.serve<WebSocketData>({
|
||||
port: 3000,
|
||||
|
||||
fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === '/ws') {
|
||||
const userId = url.searchParams.get('userId');
|
||||
if (!userId) {
|
||||
return new Response('userId required', { status: 400 });
|
||||
}
|
||||
|
||||
const success = server.upgrade(req, {
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
joinedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return success ? undefined : new Response('Upgrade failed', { status: 500 });
|
||||
}
|
||||
|
||||
return new Response('Hello');
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws) {
|
||||
clients.set(ws.data.id, ws);
|
||||
ws.subscribe('broadcast');
|
||||
ws.send(JSON.stringify({ type: 'connected', id: ws.data.id }));
|
||||
},
|
||||
|
||||
message(ws, message) {
|
||||
const data = JSON.parse(message.toString());
|
||||
|
||||
switch (data.type) {
|
||||
case 'broadcast':
|
||||
ws.publish('broadcast', JSON.stringify({
|
||||
from: ws.data.userId,
|
||||
message: data.message
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'direct':
|
||||
const target = clients.get(data.targetId);
|
||||
target?.send(JSON.stringify({
|
||||
from: ws.data.userId,
|
||||
message: data.message
|
||||
}));
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
close(ws) {
|
||||
clients.delete(ws.data.id);
|
||||
ws.unsubscribe('broadcast');
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Streaming Responses
|
||||
|
||||
```typescript
|
||||
// Server-Sent Events
|
||||
Bun.serve({
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === '/events') {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const event = `data: ${JSON.stringify({ time: Date.now() })}\n\n`;
|
||||
controller.enqueue(encoder.encode(event));
|
||||
}, 1000);
|
||||
|
||||
// Cleanup on close
|
||||
req.signal.addEventListener('abort', () => {
|
||||
clearInterval(interval);
|
||||
controller.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Chunked transfer
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
yield `Chunk ${i}\n`;
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
const response = new Response(
|
||||
new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
for await (const chunk of generateChunks()) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
}
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Middleware Pattern
|
||||
|
||||
```typescript
|
||||
type Handler = (req: Request) => Response | Promise<Response>;
|
||||
type Middleware = (req: Request, next: Handler) => Response | Promise<Response>;
|
||||
|
||||
function compose(...middlewares: Middleware[]): Handler {
|
||||
return (req) => {
|
||||
let index = 0;
|
||||
|
||||
const next: Handler = (req) => {
|
||||
if (index >= middlewares.length) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
const middleware = middlewares[index++];
|
||||
return middleware(req, next);
|
||||
};
|
||||
|
||||
return next(req);
|
||||
};
|
||||
}
|
||||
|
||||
// Logging middleware
|
||||
const logging: Middleware = async (req, next) => {
|
||||
const start = Bun.nanoseconds();
|
||||
const response = await next(req);
|
||||
const duration = (Bun.nanoseconds() - start) / 1_000_000;
|
||||
console.log(`${req.method} ${new URL(req.url).pathname} - ${duration.toFixed(2)}ms`);
|
||||
return response;
|
||||
};
|
||||
|
||||
// Auth middleware
|
||||
const auth: Middleware = async (req, next) => {
|
||||
const token = req.headers.get('Authorization')?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
// Validate token...
|
||||
return next(req);
|
||||
};
|
||||
|
||||
// CORS middleware
|
||||
const cors: Middleware = async (req, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await next(req);
|
||||
response.headers.set('Access-Control-Allow-Origin', '*');
|
||||
return response;
|
||||
};
|
||||
|
||||
const handler = compose(cors, logging, auth);
|
||||
|
||||
Bun.serve({
|
||||
fetch: handler
|
||||
});
|
||||
```
|
||||
|
||||
## Graceful Shutdown
|
||||
|
||||
```typescript
|
||||
const server = Bun.serve({
|
||||
port: 3000,
|
||||
fetch(req) {
|
||||
return new Response('Hello');
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Shutting down...');
|
||||
server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Interrupted, shutting down...');
|
||||
server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Compression
|
||||
|
||||
```typescript
|
||||
import { gzipSync, gunzipSync, deflateSync, inflateSync } from 'bun';
|
||||
|
||||
// Gzip compression
|
||||
const data = 'Large data string...'.repeat(1000);
|
||||
const compressed = gzipSync(data);
|
||||
const decompressed = gunzipSync(compressed);
|
||||
|
||||
// Deflate
|
||||
const deflated = deflateSync('data');
|
||||
const inflated = inflateSync(deflated);
|
||||
|
||||
// Gzip HTTP response
|
||||
app.get('/large-data', (c) => {
|
||||
const data = generateLargeDataset();
|
||||
const json = JSON.stringify(data);
|
||||
const acceptEncoding = c.req.header('accept-encoding') || '';
|
||||
|
||||
if (acceptEncoding.includes('gzip')) {
|
||||
return c.body(gzipSync(json), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Encoding': 'gzip'
|
||||
}
|
||||
});
|
||||
}
|
||||
return c.json(data);
|
||||
});
|
||||
```
|
||||
|
||||
## TLS/HTTPS
|
||||
|
||||
```typescript
|
||||
Bun.serve({
|
||||
port: 443,
|
||||
tls: {
|
||||
key: Bun.file('./key.pem'),
|
||||
cert: Bun.file('./cert.pem'),
|
||||
},
|
||||
fetch(req) {
|
||||
return new Response('Secure!');
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,323 @@
|
||||
# SQLite Patterns with bun:sqlite
|
||||
|
||||
Advanced patterns for database operations.
|
||||
|
||||
## Migrations
|
||||
|
||||
```typescript
|
||||
const migrations = [
|
||||
`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`,
|
||||
`CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT UNIQUE)`,
|
||||
`ALTER TABLE users ADD COLUMN name TEXT`,
|
||||
`CREATE INDEX idx_users_email ON users(email)`
|
||||
];
|
||||
|
||||
function getCurrentVersion(db: Database): number {
|
||||
try {
|
||||
const result = db.query('SELECT version FROM schema_version').get() as { version: number } | undefined;
|
||||
return result?.version || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function runMigrations(db: Database) {
|
||||
const currentVersion = getCurrentVersion(db);
|
||||
|
||||
db.transaction(() => {
|
||||
for (let i = currentVersion; i < migrations.length; i++) {
|
||||
console.log(`Running migration ${i + 1}...`);
|
||||
db.run(migrations[i]);
|
||||
}
|
||||
|
||||
db.run('DELETE FROM schema_version');
|
||||
db.run('INSERT INTO schema_version (version) VALUES (?)', [migrations.length]);
|
||||
})();
|
||||
|
||||
console.log(`Migrated to version ${migrations.length}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Connection Pool Pattern
|
||||
|
||||
```typescript
|
||||
class DatabasePool {
|
||||
private pools = new Map<string, Database>();
|
||||
|
||||
get(name: string = 'default'): Database {
|
||||
if (!this.pools.has(name)) {
|
||||
this.pools.set(name, new Database(`${name}.db`));
|
||||
}
|
||||
return this.pools.get(name)!;
|
||||
}
|
||||
|
||||
close(name?: string) {
|
||||
if (name) {
|
||||
this.pools.get(name)?.close();
|
||||
this.pools.delete(name);
|
||||
} else {
|
||||
for (const db of this.pools.values()) {
|
||||
db.close();
|
||||
}
|
||||
this.pools.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dbPool = new DatabasePool();
|
||||
```
|
||||
|
||||
## Middleware Pattern (Hono)
|
||||
|
||||
```typescript
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { createFactory } from 'hono/factory';
|
||||
|
||||
type Env = {
|
||||
Variables: {
|
||||
db: Database;
|
||||
};
|
||||
};
|
||||
|
||||
const factory = createFactory<Env>();
|
||||
|
||||
// Option 1: Per-request connection
|
||||
const dbMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const db = new Database('app.db');
|
||||
c.set('db', db);
|
||||
|
||||
try {
|
||||
await next();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Option 2: Pooled connection (preferred for performance)
|
||||
const dbPoolMiddleware = factory.createMiddleware(async (c, next) => {
|
||||
const db = dbPool.get();
|
||||
c.set('db', db);
|
||||
await next();
|
||||
// Don't close — reuse connection
|
||||
});
|
||||
```
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
```typescript
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
class UserRepository {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
private stmt = {
|
||||
findById: this.db.prepare('SELECT * FROM users WHERE id = ?'),
|
||||
findByEmail: this.db.prepare('SELECT * FROM users WHERE email = ?'),
|
||||
findAll: this.db.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT ?'),
|
||||
create: this.db.prepare('INSERT INTO users (id, email, name) VALUES (?, ?, ?) RETURNING *'),
|
||||
update: this.db.prepare('UPDATE users SET email = ?, name = ? WHERE id = ? RETURNING *'),
|
||||
delete: this.db.prepare('DELETE FROM users WHERE id = ? RETURNING *')
|
||||
};
|
||||
|
||||
findById(id: string): User | null {
|
||||
const row = this.stmt.findById.get(id);
|
||||
return row ? this.mapRow(row) : null;
|
||||
}
|
||||
|
||||
findByEmail(email: string): User | null {
|
||||
const row = this.stmt.findByEmail.get(email);
|
||||
return row ? this.mapRow(row) : null;
|
||||
}
|
||||
|
||||
findAll(limit = 100): User[] {
|
||||
const rows = this.stmt.findAll.all(limit);
|
||||
return rows.map(this.mapRow);
|
||||
}
|
||||
|
||||
create(data: { email: string; name: string }): User {
|
||||
const id = crypto.randomUUID();
|
||||
const row = this.stmt.create.get(id, data.email, data.name);
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
update(id: string, data: { email?: string; name?: string }): User | null {
|
||||
const existing = this.findById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const row = this.stmt.update.get(
|
||||
data.email ?? existing.email,
|
||||
data.name ?? existing.name,
|
||||
id
|
||||
);
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const row = this.stmt.delete.get(id);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
private mapRow(row: any): User {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
createdAt: new Date(row.created_at)
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Transaction Patterns
|
||||
|
||||
```typescript
|
||||
// Simple transaction
|
||||
const transferFunds = db.transaction((fromId: string, toId: string, amount: number) => {
|
||||
const from = db.prepare('SELECT balance FROM accounts WHERE id = ?').get(fromId);
|
||||
if (!from || from.balance < amount) {
|
||||
throw new Error('Insufficient funds');
|
||||
}
|
||||
|
||||
db.run('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId]);
|
||||
db.run('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId]);
|
||||
|
||||
return { fromId, toId, amount };
|
||||
});
|
||||
|
||||
// Nested transaction (savepoint)
|
||||
const complexOperation = db.transaction(() => {
|
||||
db.run('INSERT INTO orders (id) VALUES (?)', [orderId]);
|
||||
|
||||
const addItem = db.transaction((itemId: string) => {
|
||||
db.run('INSERT INTO order_items (order_id, item_id) VALUES (?, ?)', [orderId, itemId]);
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
addItem(item.id); // Each runs in savepoint
|
||||
}
|
||||
});
|
||||
|
||||
// Deferred vs immediate
|
||||
const deferredTx = db.transaction(() => {
|
||||
// Locks acquired on first write
|
||||
}).deferred();
|
||||
|
||||
const immediateTx = db.transaction(() => {
|
||||
// Locks acquired immediately
|
||||
}).immediate();
|
||||
|
||||
const exclusiveTx = db.transaction(() => {
|
||||
// Exclusive write lock
|
||||
}).exclusive();
|
||||
```
|
||||
|
||||
## Query Helpers
|
||||
|
||||
```typescript
|
||||
// Reusable query object
|
||||
const getUserQuery = db.query('SELECT * FROM users WHERE id = ?');
|
||||
const user1 = getUserQuery.get('1');
|
||||
const user2 = getUserQuery.get('2');
|
||||
|
||||
// Values (array results)
|
||||
const allEmails = db.query('SELECT email FROM users').values();
|
||||
// [['alice@example.com'], ['bob@example.com'], ...]
|
||||
|
||||
// Named columns
|
||||
const users = db.query('SELECT id, email FROM users').all();
|
||||
// [{ id: '1', email: 'alice@example.com' }, ...]
|
||||
```
|
||||
|
||||
## Full-Text Search
|
||||
|
||||
```typescript
|
||||
// Create FTS table
|
||||
db.run(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||
title,
|
||||
content,
|
||||
content='posts',
|
||||
content_rowid='id'
|
||||
)
|
||||
`);
|
||||
|
||||
// Triggers to keep FTS in sync
|
||||
db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN
|
||||
INSERT INTO posts_fts(rowid, title, content)
|
||||
VALUES (new.id, new.title, new.content);
|
||||
END
|
||||
`);
|
||||
|
||||
// Search
|
||||
function searchPosts(query: string) {
|
||||
return db.prepare(`
|
||||
SELECT posts.*
|
||||
FROM posts
|
||||
JOIN posts_fts ON posts.id = posts_fts.rowid
|
||||
WHERE posts_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT 20
|
||||
`).all(query);
|
||||
}
|
||||
```
|
||||
|
||||
## JSON Support
|
||||
|
||||
```typescript
|
||||
// Store JSON
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
preferences TEXT -- JSON stored as text
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert JSON
|
||||
db.prepare('INSERT INTO settings VALUES (?, ?)').run(
|
||||
userId,
|
||||
JSON.stringify({ theme: 'dark', notifications: true })
|
||||
);
|
||||
|
||||
// Query JSON (SQLite JSON functions)
|
||||
const darkUsers = db.prepare(`
|
||||
SELECT user_id FROM settings
|
||||
WHERE json_extract(preferences, '$.theme') = 'dark'
|
||||
`).all();
|
||||
|
||||
// Extract JSON field
|
||||
const theme = db.prepare(`
|
||||
SELECT json_extract(preferences, '$.theme') as theme
|
||||
FROM settings
|
||||
WHERE user_id = ?
|
||||
`).get(userId);
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
```typescript
|
||||
// WAL mode for better concurrency
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
|
||||
// Increase cache size
|
||||
db.run('PRAGMA cache_size = -64000'); // 64MB
|
||||
|
||||
// Batch inserts
|
||||
const insertMany = db.transaction((users: User[]) => {
|
||||
const stmt = db.prepare('INSERT INTO users VALUES (?, ?, ?)');
|
||||
for (const user of users) {
|
||||
stmt.run(user.id, user.email, user.name);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(thousandsOfUsers); // Much faster than individual inserts
|
||||
|
||||
// Index for common queries
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id)');
|
||||
```
|
||||
@@ -0,0 +1,415 @@
|
||||
# Testing with bun:test
|
||||
|
||||
Bun's built-in test runner patterns and lifecycle hooks.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```typescript
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
describe('feature', () => {
|
||||
let resource: Resource;
|
||||
|
||||
beforeAll(() => {
|
||||
// Suite setup — runs once before all tests
|
||||
console.log('Setup test suite');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Suite cleanup — runs once after all tests
|
||||
console.log('Cleanup test suite');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Test setup — runs before each test
|
||||
resource = createResource();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Test cleanup — runs after each test
|
||||
resource.dispose();
|
||||
});
|
||||
|
||||
test('behavior', () => {
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Assertions
|
||||
|
||||
```typescript
|
||||
// Equality
|
||||
expect(value).toBe(expected); // Strict equality (===)
|
||||
expect(obj).toEqual({ foo: 'bar' }); // Deep equality
|
||||
expect(arr).toContain(item); // Array/string contains
|
||||
expect(obj).toMatchObject({ key: 'value' }); // Partial object match
|
||||
|
||||
// Truthiness
|
||||
expect(value).toBeTruthy();
|
||||
expect(value).toBeFalsy();
|
||||
expect(value).toBeDefined();
|
||||
expect(value).toBeUndefined();
|
||||
expect(value).toBeNull();
|
||||
|
||||
// Numbers
|
||||
expect(num).toBeGreaterThan(0);
|
||||
expect(num).toBeGreaterThanOrEqual(0);
|
||||
expect(num).toBeLessThan(100);
|
||||
expect(num).toBeLessThanOrEqual(100);
|
||||
expect(num).toBeCloseTo(0.3, 5); // Float comparison
|
||||
|
||||
// Strings
|
||||
expect(str).toMatch(/pattern/);
|
||||
expect(str).toStartWith('prefix');
|
||||
expect(str).toEndWith('suffix');
|
||||
|
||||
// Arrays
|
||||
expect(arr).toHaveLength(3);
|
||||
expect(arr).toContainEqual({ id: 1 });
|
||||
|
||||
// Exceptions
|
||||
expect(fn).toThrow();
|
||||
expect(fn).toThrow('error message');
|
||||
expect(fn).toThrow(ErrorType);
|
||||
|
||||
// Negation
|
||||
expect(value).not.toBe(other);
|
||||
expect(arr).not.toContain(item);
|
||||
```
|
||||
|
||||
## Async Tests
|
||||
|
||||
```typescript
|
||||
// Async/await
|
||||
test('async operation', async () => {
|
||||
const result = await fetchData();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
// Promise resolution
|
||||
test('promise resolves', async () => {
|
||||
await expect(asyncFn()).resolves.toBe('success');
|
||||
});
|
||||
|
||||
// Promise rejection
|
||||
test('promise rejects', async () => {
|
||||
await expect(asyncFn()).rejects.toThrow('error');
|
||||
});
|
||||
|
||||
// Timeout (default 5000ms)
|
||||
test('slow operation', async () => {
|
||||
const result = await slowOperation();
|
||||
expect(result).toBeDefined();
|
||||
}, 10000); // 10 second timeout
|
||||
```
|
||||
|
||||
## Database Testing
|
||||
|
||||
```typescript
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
describe('Database operations', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
// Fresh in-memory database per test
|
||||
db = new Database(':memory:');
|
||||
db.run(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('insert user', () => {
|
||||
const user = db.prepare(`
|
||||
INSERT INTO users (id, email, name)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING *
|
||||
`).get('1', 'alice@example.com', 'Alice');
|
||||
|
||||
expect(user).toMatchObject({
|
||||
id: '1',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice'
|
||||
});
|
||||
});
|
||||
|
||||
test('query user', () => {
|
||||
db.run("INSERT INTO users VALUES ('1', 'alice@example.com', 'Alice')");
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get('1');
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.email).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
test('unique constraint', () => {
|
||||
db.run("INSERT INTO users VALUES ('1', 'alice@example.com', 'Alice')");
|
||||
|
||||
expect(() => {
|
||||
db.run("INSERT INTO users VALUES ('2', 'alice@example.com', 'Alice2')");
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## File System Testing
|
||||
|
||||
```typescript
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('File operations', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
test('write and read file', async () => {
|
||||
const filepath = join(tempDir, 'test.txt');
|
||||
|
||||
await Bun.write(filepath, 'Hello, world!');
|
||||
|
||||
const file = Bun.file(filepath);
|
||||
expect(await file.exists()).toBe(true);
|
||||
expect(await file.text()).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
test('write JSON', async () => {
|
||||
const filepath = join(tempDir, 'data.json');
|
||||
const data = { name: 'test', value: 42 };
|
||||
|
||||
await Bun.write(filepath, JSON.stringify(data));
|
||||
|
||||
const file = Bun.file(filepath);
|
||||
expect(await file.json()).toEqual(data);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
```typescript
|
||||
import { mock, spyOn } from 'bun:test';
|
||||
|
||||
describe('Mocking', () => {
|
||||
test('mock function', () => {
|
||||
const mockFn = mock(() => 'mocked');
|
||||
|
||||
expect(mockFn()).toBe('mocked');
|
||||
expect(mockFn).toHaveBeenCalled();
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('mock with arguments', () => {
|
||||
const mockFn = mock((x: number) => x * 2);
|
||||
|
||||
mockFn(5);
|
||||
|
||||
expect(mockFn).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
test('spy on method', () => {
|
||||
const obj = {
|
||||
method: (x: number) => x * 2
|
||||
};
|
||||
|
||||
const spy = spyOn(obj, 'method');
|
||||
|
||||
obj.method(5);
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
expect(spy).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
test('mock return value', () => {
|
||||
const mockFn = mock(() => 'original');
|
||||
|
||||
mockFn.mockReturnValue('mocked');
|
||||
expect(mockFn()).toBe('mocked');
|
||||
|
||||
mockFn.mockReturnValueOnce('once');
|
||||
expect(mockFn()).toBe('once');
|
||||
expect(mockFn()).toBe('mocked');
|
||||
});
|
||||
|
||||
test('mock implementation', () => {
|
||||
const mockFn = mock(() => 'original');
|
||||
|
||||
mockFn.mockImplementation(() => 'new implementation');
|
||||
expect(mockFn()).toBe('new implementation');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Mock fetch
|
||||
|
||||
```typescript
|
||||
describe('External API calls', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test('mock API response', async () => {
|
||||
global.fetch = mock(async () =>
|
||||
new Response(JSON.stringify({ data: 'mocked' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
);
|
||||
|
||||
const res = await fetch('https://api.example.com/data');
|
||||
const data = await res.json();
|
||||
|
||||
expect(data).toEqual({ data: 'mocked' });
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data');
|
||||
});
|
||||
|
||||
test('mock API error', async () => {
|
||||
global.fetch = mock(async () =>
|
||||
new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
|
||||
);
|
||||
|
||||
const res = await fetch('https://api.example.com/missing');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
```typescript
|
||||
// Skip tests
|
||||
test.skip('work in progress', () => {
|
||||
// Not executed
|
||||
});
|
||||
|
||||
// Mark as todo
|
||||
test.todo('future feature');
|
||||
|
||||
// Only run specific test
|
||||
test.only('focus on this', () => {
|
||||
// Only this test runs in file
|
||||
});
|
||||
|
||||
// Conditional skip
|
||||
const isCI = process.env.CI === 'true';
|
||||
test.skipIf(isCI)('skip in CI', () => {
|
||||
// Skipped when CI=true
|
||||
});
|
||||
|
||||
// Run if condition
|
||||
test.if(!isCI)('local only', () => {
|
||||
// Only runs locally
|
||||
});
|
||||
```
|
||||
|
||||
## Snapshot Testing
|
||||
|
||||
```typescript
|
||||
import { expect, test } from 'bun:test';
|
||||
|
||||
test('snapshot', () => {
|
||||
const result = generateOutput();
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('inline snapshot', () => {
|
||||
const result = { name: 'test', value: 42 };
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"name": "test",
|
||||
"value": 42
|
||||
}
|
||||
`);
|
||||
});
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
bun test
|
||||
|
||||
# Specific file
|
||||
bun test src/utils.test.ts
|
||||
|
||||
# Specific directory
|
||||
bun test src/api/
|
||||
|
||||
# Pattern matching
|
||||
bun test --test-name-pattern "should create"
|
||||
|
||||
# Watch mode
|
||||
bun test --watch
|
||||
|
||||
# Coverage
|
||||
bun test --coverage
|
||||
|
||||
# Timeout (ms)
|
||||
bun test --timeout 10000
|
||||
|
||||
# Bail on first failure
|
||||
bun test --bail
|
||||
|
||||
# Rerun only failed tests
|
||||
bun test --rerun-each 3
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
```typescript
|
||||
// ✅ Isolated tests — each test sets up its own data
|
||||
describe('User service', () => {
|
||||
let db: Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
setupSchema(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('creates user', () => {
|
||||
const user = createUser(db, { email: 'test@example.com' });
|
||||
expect(user.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ Descriptive test names
|
||||
test('returns 404 when user not found', async () => { ... });
|
||||
test('validates email format before creating user', async () => { ... });
|
||||
|
||||
// ✅ Single assertion focus
|
||||
test('user has correct email', () => {
|
||||
const user = createUser({ email: 'test@example.com' });
|
||||
expect(user.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
// ❌ Avoid shared mutable state between tests
|
||||
let sharedUser; // Don't do this
|
||||
beforeAll(() => {
|
||||
sharedUser = createUser(); // Tests may interfere
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user