weifuwu 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,10 +28,13 @@ Everything follows the same `(req, ctx) => Response` contract. The Router handle
28
28
  - **Data** — Redis client, job queue with cron scheduling
29
29
  - **Multi-tenant BaaS** — dynamic tables, auto REST + GraphQL, row-level isolation
30
30
  - **Deploy** — self-hosted PaaS: multi-app proxy, zero-downtime updates, auto SSL
31
+ - **Security** — `helmet()` security headers, request ID tracing, rate limiting, CORS, auth
32
+ - **SEO** — `robots.txt`, `sitemap.xml`, `X-Robots-Tag` middleware, `seoTags()` for meta / OG / Twitter Card
31
33
  - **i18n** — locale detection, JSON translations, `ctx.t()`
32
34
  - **Email** — SMTP or custom transport
33
35
  - **Health check** — configurable `/health` endpoint
34
36
  - **Environment** — `loadEnv()` — `.env` file loader into `process.env`
37
+ - **iii** — optional module bringing Worker/Function/Trigger service paradigm, `registerWorker()` WebSocket SDK, and built-in `stream::*` functions
35
38
  - **Test utilities** — `createTestServer()` — one-line test server setup
36
39
 
37
40
  ## Quick start
@@ -81,34 +84,16 @@ serve(app.handler(), { port: 3000 })
81
84
  node app.ts
82
85
  ```
83
86
 
84
- ## Documentation
85
-
86
- | Module | Doc | Description |
87
- |--------|-----|-------------|
88
- | **Router** | [docs/router.md](./docs/router.md) | Routes, middleware, WebSocket, error handling |
89
- | **Middleware** | [docs/middleware.md](./docs/middleware.md) | auth, cors, logger, rateLimit, compress, validate, upload, cookie, static |
90
- | **PostgreSQL** | [docs/postgres.md](./docs/postgres.md) | Schema builder, CRUD, DDL, transactions, PgModule |
91
- | **Auth & User** | [docs/user.md](./docs/user.md) | Password, JWT, OAuth2 Server, Social Login cookbook |
92
- | **React SSR** | [docs/tsx.md](./docs/tsx.md) | pages, layouts, loaders, Tailwind, shadcn/ui |
93
- | **AI** | [docs/ai.md](./docs/ai.md) | `aiStream()`, `runWorkflow()` |
94
- | **AI Agent** | [docs/agent.md](./docs/agent.md) | Chat, tool-use, RAG knowledge |
95
- | **Opencode** | [docs/opencode.md](./docs/opencode.md) | Programming assistant, skills, sessions, permissions |
96
- | **Messager** | [docs/messager.md](./docs/messager.md) | Real-time chat, channels, WebSocket, agent routing |
97
- | **GraphQL** | [docs/graphql.md](./docs/graphql.md) | GraphQL endpoint with GraphiQL |
98
- | **Tenant BaaS** | [docs/tenant.md](./docs/tenant.md) | Dynamic tables, auto REST + GraphQL, row isolation |
99
- | **LogDB** | [docs/logdb.md](./docs/logdb.md) | Structured event logging with partitioning, metadata search, REST API |
100
- | **Extra** | [docs/extra.md](./docs/extra.md) | Health check, i18n, email, test utilities |
101
-
102
- ### Infrastructure
87
+ ## Infrastructure
103
88
 
104
89
  | Module | Import | What it gives you |
105
90
  |--------|--------|-------------------|
106
91
  | PostgreSQL | `postgres(options?)` | Connection pool + schema builder + CRUD (`read`/`readMany`, `insertMany`, `update`/`updateMany`, `delete`/`deleteMany`) + where helpers (`eq`, `gte`, `contains`, `and`, `or`) + transactions |
107
92
  | Redis | `redis(options?)` | ioredis client injected as `ctx.redis` |
108
93
  | Queue | `queue(options?)` | Redis-backed job queue with cron scheduling |
109
- | Deploy | `deploy(config)` | Self-hosted PaaS, see [deploy.md](./deploy.md) |
94
+ | Deploy | `deploy(config)` | Self-hosted PaaS: multi-app proxy, zero-downtime updates, auto SSL |
110
95
 
111
- ### Mountable modules
96
+ ## Mountable modules
112
97
 
113
98
  All use the same pattern — `const m = module(options)` → `app.use('/path', m.router())`:
114
99
 
@@ -123,8 +108,11 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
123
108
  | `graphql(handler)` | GraphQL endpoint | — |
124
109
  | `logdb(options)` | Structured event logging | `log()`, `migrate()`, `clean()`, `close()` |
125
110
  | `health(options?)` | Health check | — |
111
+ | `seo(options?)` | `robots.txt`, `sitemap.xml`, indexing control | `seoMiddleware()`, `seoTags()` |
112
+ | `iii(options?)` | Worker/Function/Trigger service paradigm | `migrate()`, `trigger()`, `addWorker()`, `listWorkers()`, `listFunctions()`, `listTriggers()`, `shutdown()` |
113
+ | `registerWorker(url)` | Pure WebSocket SDK (browser/Node) | `registerFunction()`, `registerTrigger()`, `trigger()`, `shutdown()` |
126
114
 
127
- ### Middleware (all `(req, ctx, next) => Response`)
115
+ ## Middleware (all `(req, ctx, next) => Response`)
128
116
 
129
117
  | Middleware | Description |
130
118
  |-----------|-------------|
@@ -136,8 +124,11 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
136
124
  | `validate(schemas)` | Zod validation (body, query, params) |
137
125
  | `upload(options?)` | Multipart file upload |
138
126
  | `i18n(options)` | Internationalization — `ctx.t()`, locale detection |
127
+ | `seoMiddleware(options?)` | `X-Robots-Tag` header — string or path-based function |
128
+ | `helmet(options?)` | Security headers — CSP, HSTS, X-Frame-Options, etc. |
129
+ | `requestId(options?)` | `X-Request-ID` header + `ctx.requestId` |
139
130
 
140
- ### Utility functions
131
+ ## Utility functions
141
132
 
142
133
  | Function | Description |
143
134
  |----------|-------------|
@@ -146,6 +137,10 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
146
137
  | `getCookies(req)` / `setCookie(res, ...)` / `deleteCookie(res, ...)` | Cookie helpers |
147
138
  | `mailer(options)` | Email sender (SMTP or custom) |
148
139
  | `createTestServer(handler)` | Start test server → `{ server, url }` |
140
+ | `seoTags(config)` | Generate `<title>`, `<meta>`, Open Graph, Twitter Card, canonical tags |
141
+ | `createSSEStream(iterable, opts?)` | SSE response from `AsyncIterable` |
142
+ | `formatSSE(event, data)` | Format SSE event string |
143
+ | `formatSSEData(data)` | Format SSE data string |
149
144
  | `runWorkflow(options)` | DAG execution engine as AI SDK `Tool` |
150
145
  | `pgTable(name, columns)` | Type-safe table schema builder |
151
146
  | `pg.table(name, columns)` | Pre-bound table (no `sql` param needed) |
@@ -153,6 +148,1591 @@ All use the same pattern — `const m = module(options)` → `app.use('/path', m
153
148
  | `eq()`, `gte()`, `contains()`, `and()` ... | WHERE clause helpers — same API as Drizzle |
154
149
  | `PgModule` | Base class for DB-backed modules |
155
150
 
151
+ ---
152
+
153
+ # iii — Worker / Function / Trigger
154
+
155
+ Optional module that organizes service logic as **Worker + Function + Trigger**, plus a pure WebSocket SDK for connecting remote workers. Built-in `stream::*` functions for hierarchical real-time data.
156
+
157
+ ```ts
158
+ import { serve, Router, iii, createWorker as Worker, registerWorker } from 'weifuwu'
159
+
160
+ // Engine
161
+ const engine = iii({ pg, redis })
162
+ const app = new Router()
163
+ app.use('/iii', engine.router())
164
+ serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
165
+
166
+ // Local worker
167
+ const w = new Worker('orders')
168
+ w.registerFunction('orders::create', async (payload) => {
169
+ return db.query('INSERT INTO orders ...', [payload.items])
170
+ })
171
+ w.registerTrigger({
172
+ type: 'http', function_id: 'orders::create',
173
+ config: { method: 'POST', path: '/orders' },
174
+ })
175
+ engine.addWorker(w)
176
+
177
+ // Invoke via Engine
178
+ await engine.trigger({ function_id: 'orders::create', payload: { items: ['apple'] } })
179
+
180
+ // Remote worker (browser or another process)
181
+ const rw = registerWorker('ws://host:3000/iii/worker')
182
+ rw.registerFunction('ui::notify', (p) => new Notification(p.title))
183
+ ```
184
+
185
+ ## Built-in functions
186
+
187
+ | Function | Description |
188
+ |----------|-------------|
189
+ | `stream::set(stream_name, group_id, item_id, data)` | Write + persist + notify subscribers |
190
+ | `stream::get(stream_name, group_id, item_id)` | Read single item |
191
+ | `stream::delete(stream_name, group_id, item_id)` | Delete + notify |
192
+ | `stream::list(stream_name, group_id)` | List all items in a group |
193
+ | `stream::list_groups(stream_name)` | List all groups in a stream |
194
+ | `stream::list_all()` | List all streams with metadata |
195
+ | `stream::send(stream_name, group_id, type, data, id?)` | Push event without persisting |
196
+ | `stream::update(stream_name, group_id, item_id, ops)` | Atomic operations (set/merge/increment/decrement/append/remove) |
197
+
198
+ ## Storage backends
199
+
200
+ | Config | Persistence | Cross-process broadcast |
201
+ |--------|-------------|------------------------|
202
+ | `iii({})` | In-memory Map | — |
203
+ | `iii({ pg })` | PG table `_iii_stream` | — |
204
+ | `iii({ redis })` | Redis Hash | Redis pub/sub |
205
+ | `iii({ pg, redis })` | PG table | Redis pub/sub |
206
+
207
+ ## Trigger actions
208
+
209
+ | Action | Behavior |
210
+ |--------|----------|
211
+ | `'sync'` (default) | Wait for result |
212
+ | `'void'` | Fire-and-forget, no result |
213
+
214
+ ```ts
215
+ // Sync
216
+ const result = await engine.trigger({ function_id: 'math::add', payload: { a: 2, b: 3 } })
217
+ // → { c: 5 }
218
+
219
+ // Void
220
+ await engine.trigger({ function_id: 'notifications::send', payload: {...}, action: 'void' })
221
+ ```
222
+
223
+ ## REST API (mounted at `/iii`)
224
+
225
+ | Path | Description |
226
+ |------|-------------|
227
+ | `GET /iii/workers` | List connected workers |
228
+ | `GET /iii/functions` | List registered functions |
229
+ | `GET /iii/triggers` | List registered triggers |
230
+ | `POST /iii/trigger/:fnId` | Invoke a function |
231
+ | `WS /iii/worker` | Remote worker connection |
232
+
233
+ ---
234
+
235
+ # Router
236
+
237
+ ```ts
238
+ import { serve, Router } from 'weifuwu'
239
+
240
+ const app = new Router()
241
+ .use((req, ctx, next) => {
242
+ console.log(`${req.method} ${new URL(req.url).pathname}`)
243
+ return next(req, ctx)
244
+ })
245
+ .get('/hello/:name', (req, ctx) =>
246
+ Response.json({ message: `Hello, ${ctx.params.name}!` }),
247
+ )
248
+ .post('/data', async (req, ctx) => {
249
+ const body = await req.json()
250
+ return Response.json(body, { status: 201 })
251
+ })
252
+
253
+ serve(app.handler(), { port: 3000 })
254
+ ```
255
+
256
+ ## WebSocket
257
+
258
+ ```json
259
+ { "type": "message", "channel_id": 1, "content": "Hi" }
260
+ { "type": "typing", "channel_id": 1, "is_typing": true }
261
+ { "type": "read", "channel_id": 1, "last_message_id": 42 }
262
+ ```
263
+
264
+ ## Error handling
265
+
266
+ ```ts
267
+ const app = new Router()
268
+ .onError((err, req, ctx) =>
269
+ Response.json({ error: err.message }, { status: 500 }),
270
+ )
271
+ .get('/crash', () => { throw new Error('boom') })
272
+ ```
273
+
274
+ ## Graceful shutdown
275
+
276
+ ```ts
277
+ import { serve } from 'weifuwu'
278
+ import type { Server } from 'weifuwu'
279
+
280
+ const ac = new AbortController()
281
+ let server: Server
282
+
283
+ process.on('SIGTERM', () => {
284
+ ac.abort()
285
+ server.stop()
286
+ })
287
+
288
+ server = serve((req, ctx) => new Response('Hello'), {
289
+ port: 3000,
290
+ signal: ac.signal,
291
+ })
292
+ await server.ready
293
+ ```
294
+
295
+ ### Using with WebSocket
296
+
297
+ ```ts
298
+ const app = new Router().ws('/chat', { … })
299
+ const server = serve(app.handler(), {
300
+ port: 3000,
301
+ signal: ac.signal,
302
+ websocket: app.websocketHandler(),
303
+ })
304
+ ```
305
+
306
+ ---
307
+
308
+ # Middleware
309
+
310
+ ## Auth
311
+
312
+ ```ts
313
+ import { auth } from 'weifuwu'
314
+
315
+ // Static bearer token
316
+ app.use(auth({ token: 'sk-123' }))
317
+
318
+ // Custom verify (JWT, DB, etc.) — return object to set ctx.user
319
+ app.use(auth({
320
+ verify: async (token) => {
321
+ const user = await db.findUserByToken(token)
322
+ return user ? { sub: user.id, role: user.role } : null
323
+ },
324
+ }))
325
+
326
+ // Proxy validation to external auth service
327
+ app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
328
+
329
+ // Custom header
330
+ app.use(auth({ header: 'X-API-Key', token: 'my-key' }))
331
+ ```
332
+
333
+ ## CORS
334
+
335
+ ```ts
336
+ import { cors } from 'weifuwu'
337
+
338
+ app.use(cors()) // allow all
339
+ app.use(cors({ origin: ['https://example.com'] })) // whitelist
340
+ app.use(cors({ origin: (o) => o.endsWith('.trusted.com') ? o : false }))
341
+ app.use(cors({ credentials: true, maxAge: 3600 }))
342
+ ```
343
+
344
+ ## Logger
345
+
346
+ ```ts
347
+ import { logger } from 'weifuwu'
348
+
349
+ app.use(logger()) // GET /hello 200 5ms
350
+ app.use(logger({ format: 'combined' })) // with query params
351
+ ```
352
+
353
+ ## Rate limit
354
+
355
+ ```ts
356
+ import { rateLimit } from 'weifuwu'
357
+
358
+ app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min
359
+ app.get('/api', rateLimit({ max: 10 }), handler) // per-route
360
+
361
+ // Custom key (by API key, user ID, etc.)
362
+ app.use(rateLimit({
363
+ max: 1000,
364
+ key: (req) => req.headers.get('x-api-key') ?? 'anonymous',
365
+ }))
366
+ ```
367
+
368
+ ## Compression
369
+
370
+ ```ts
371
+ import { compress } from 'weifuwu'
372
+
373
+ app.use(compress()) // brotli > gzip > deflate
374
+ app.use(compress({ threshold: 2048 })) // only compress > 2KB
375
+ ```
376
+
377
+ ## Validation
378
+
379
+ ```ts
380
+ import { z } from 'zod'
381
+ import { validate } from 'weifuwu'
382
+
383
+ const CreateUser = z.object({
384
+ name: z.string().min(1),
385
+ email: z.string().email(),
386
+ })
387
+
388
+ router.post('/users',
389
+ validate({ body: CreateUser }),
390
+ (req, ctx) => {
391
+ // ctx.parsed.body — typed & validated
392
+ },
393
+ )
394
+ ```
395
+
396
+ ## File upload
397
+
398
+ ```ts
399
+ import { upload } from 'weifuwu'
400
+
401
+ router.post('/upload',
402
+ upload({ dir: './uploads', maxFileSize: 10_485_760 }),
403
+ (req, ctx) => {
404
+ // ctx.parsed.files.avatar → { name, type, size, path }
405
+ // ctx.parsed.fields.title → 'hello'
406
+ },
407
+ )
408
+ ```
409
+
410
+ ## Cookie
411
+
412
+ ```ts
413
+ import { getCookies, setCookie, deleteCookie } from 'weifuwu'
414
+
415
+ // Read
416
+ const cookies = getCookies(req) // { session: 'abc' }
417
+
418
+ // Set (immutable — returns new Response)
419
+ let res = new Response('ok')
420
+ res = setCookie(res, 'session', 'token', { httpOnly: true, secure: true, maxAge: 3600 })
421
+
422
+ // Delete
423
+ res = deleteCookie(res, 'session')
424
+ ```
425
+
426
+ ## Static files
427
+
428
+ ```ts
429
+ import { serveStatic } from 'weifuwu'
430
+
431
+ router.get('/static/*', serveStatic('./public'))
432
+ ```
433
+
434
+ Features: MIME type detection (20+ types), ETag + If-None-Match (304), directory index (index.html), path traversal protection, Cache-Control.
435
+
436
+ ---
437
+
438
+ # PostgreSQL
439
+
440
+ Built-in PostgreSQL client — connection management, type-safe DDL, transactions, and module lifecycle.
441
+
442
+ ```ts
443
+ import { serve, Router, postgres } from 'weifuwu'
444
+
445
+ const app = new Router()
446
+ const pg = postgres() // reads DATABASE_URL
447
+ app.use(pg) // injects ctx.sql into handlers
448
+ ```
449
+
450
+ ## Type-safe DDL with schema builder
451
+
452
+ Define tables declaratively with type inference — no raw SQL for common operations, no Zod needed:
453
+
454
+ ```ts
455
+ import { pgTable, serial, uuid, text, integer, boolean, timestamptz, jsonb, sql, timestamps } from 'weifuwu'
456
+
457
+ const users = pgTable('_users', {
458
+ id: serial('id').primaryKey(),
459
+ name: text('name').notNull(),
460
+ email: text('email').unique().notNull(),
461
+ age: integer('age'),
462
+ active: boolean('active').default(true),
463
+ ...timestamps(), // adds created_at + updated_at with defaults
464
+ metadata: jsonb<{ role: string }>('metadata'),
465
+ })
466
+ ```
467
+
468
+ Supports 11 column types:
469
+ | Builder | DDL | TS Type |
470
+ |---------|-----|---------|
471
+ | `serial()` | `SERIAL` | `number` |
472
+ | `uuid()` | `UUID` | `string` |
473
+ | `text()` | `TEXT` | `string` |
474
+ | `integer()` | `INTEGER` | `number` |
475
+ | `boolean()` | `BOOLEAN` | `boolean` |
476
+ | `timestamptz()` | `TIMESTAMPTZ` | `string` |
477
+ | `jsonb<T>()` | `JSONB` | `T` |
478
+ | `textArray()` | `TEXT[]` | `string[]` |
479
+ | `vector(name, dims)` | `vector(N)` | `number[]` |
480
+ | `timestamps()` | two TIMESTAMPTZ columns | `{ created_at, updated_at }` |
481
+
482
+ Column constraints chainable: `.primaryKey()`, `.notNull()`, `.nullable()`, `.default(value | sql\`...\`)`, `.unique()`, `.references(table, column?, onDelete?)`.
483
+
484
+ ## DDL execution
485
+
486
+ ```ts
487
+ await users.create() // CREATE TABLE IF NOT EXISTS
488
+ await users.create({ // WITH PARTITION BY RANGE
489
+ partitionBy: partitionBy('range', 'created_at'),
490
+ })
491
+ await users.createIndex('email') // CREATE INDEX
492
+ await users.createUniqueIndex('slug') // CREATE UNIQUE INDEX
493
+ await users.createIndex('created_at', { desc: true })
494
+ await users.createIndex(['a', 'b']) // multi-column
495
+ await users.createIndex('embedding', { // pgvector HNSW
496
+ type: 'hnsw', operator: 'vector_cosine_ops',
497
+ })
498
+ await users.drop({ cascade: true })
499
+ ```
500
+
501
+ ## Type-safe CRUD with BoundTable
502
+
503
+ Two usage paths — use `pg.table()` when you have a `pg` handle, or `pgTable()` with explicit `sql`:
504
+
505
+ The `BoundTable` follows a clean CRUD naming — singular for one, plural for many:
506
+
507
+ ```ts
508
+ // pg.table() — auto-binds sql, no need to pass it
509
+ const users = pg.table('_users', {
510
+ id: serial('id').primaryKey(),
511
+ name: text('name').notNull(),
512
+ email: text('email').unique(),
513
+ active: boolean('active').default(true),
514
+ ...timestamps(),
515
+ })
516
+
517
+ // Create — single
518
+ const user = await users.insert({ name: 'Alice', email: 'alice@test.com' })
519
+ // → { id: 1, name: 'Alice', ... }
520
+
521
+ // Create — many
522
+ const batch = await users.insertMany([
523
+ { name: 'Alice' },
524
+ { name: 'Bob' },
525
+ { name: 'Charlie' },
526
+ ])
527
+ // → [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' }]
528
+
529
+ // Read — by id
530
+ const found = await users.read(1)
531
+
532
+ // Read — with selected columns
533
+ const partial = await users.read(1, { select: ['id', 'name'] })
534
+
535
+ // Read — many with optional filtering + pagination
536
+ const { count, data } = await users.readMany({ role: 'admin' })
537
+ // count is total matching rows, data is the page
538
+ const { data: sorted } = await users.readMany({ active: true }, { orderBy: { name: 'asc' } })
539
+ const { data: page } = await users.readMany(undefined, { limit: 10, offset: 0 })
540
+ const { data: filtered } = await users.readMany(
541
+ { role: 'admin' },
542
+ { orderBy: { name: 'desc' }, limit: 5 },
543
+ )
544
+
545
+ // Read — complex conditions with where helpers
546
+ import { eq, gte, lt, contains, and } from 'weifuwu'
547
+ const { count, data } = await users.readMany(
548
+ and(
549
+ eq('role', 'admin'),
550
+ gte('created_at', '2026-01-01'),
551
+ contains('metadata', { region: 'us' }),
552
+ ),
553
+ { orderBy: { name: 'asc' } },
554
+ )
555
+ // Array shorthand — implicit AND
556
+ const { data } = await users.readMany(
557
+ [eq('role', 'admin'), gte('created_at', '2026-01-01')],
558
+ { limit: 10 },
559
+ )
560
+
561
+ // Update — single row by id (auto-sets updated_at if column exists)
562
+ const updated = await users.update(1, { name: 'Bob' })
563
+ // → { id: 1, name: 'Bob', email: 'alice@test.com', ... }
564
+
565
+ // Update — many with Partial where
566
+ const count = await users.updateMany({ role: 'guest' }, { role: 'user' })
567
+
568
+ // Update — many with SQL where
569
+ await users.updateMany(gte('age', 65), { role: 'retired' })
570
+
571
+ // Delete — single row by id, returns deleted row
572
+ const deleted = await users.delete(1)
573
+ // → { id: 1, name: 'Bob', ... } or undefined
574
+
575
+ // Delete — many
576
+ const deleted = await users.deleteMany({ active: false })
577
+
578
+ // Read — select specific columns
579
+ const { data } = await users.readMany(
580
+ { role: 'admin' },
581
+ { select: ['id', 'name', 'email'], limit: 10 },
582
+ )
583
+ ```
584
+
585
+ ### Upsert
586
+
587
+ ```ts
588
+ // Insert or update on conflict
589
+ const user = await users.upsert(
590
+ { email: 'alice@test.com', name: 'Alice' },
591
+ 'email', // conflict target — column(s) with unique constraint
592
+ )
593
+ // ON CONFLICT (email) DO UPDATE SET "name" = EXCLUDED."name" RETURNING *
594
+ ```
595
+
596
+ Supports composite conflict targets:
597
+
598
+ ```ts
599
+ await members.upsert(
600
+ { channel_id: 1, member_id: 42, role: 'admin' },
601
+ ['channel_id', 'member_id'],
602
+ )
603
+ ```
604
+
605
+ ### Count
606
+
607
+ ```ts
608
+ const total = await users.count() // all rows
609
+ const admins = await users.count({ role: 'admin' }) // with Partial filter
610
+ const recent = await users.count(gte('created_at', from)) // with SQL condition
611
+ ```
612
+
613
+ ### Soft delete
614
+
615
+ If a table has a `deleted_at` column, `delete()` and `deleteMany()` set the timestamp instead of removing the row:
616
+
617
+ ```ts
618
+ const users = pg.table('_users', {
619
+ id: serial('id').primaryKey(),
620
+ name: text('name'),
621
+ deleted_at: timestamptz('deleted_at'), // enables soft delete
622
+ })
623
+
624
+ await users.delete(1) // SET deleted_at = NOW() WHERE id = 1
625
+ await users.deleteMany({ role: 'guest' })
626
+
627
+ // readMany auto-filters soft-deleted rows
628
+ const { data } = await users.readMany() // WHERE deleted_at IS NULL
629
+
630
+ // Include soft-deleted rows
631
+ const { data } = await users.readMany(undefined, { withDeleted: true })
632
+
633
+ // Hard delete (bypass soft delete)
634
+ await users.hardDelete(1)
635
+ await users.hardDeleteMany({ role: 'guest' })
636
+ ```
637
+
638
+ ### Timestamps
639
+
640
+ The `timestamps()` macro adds `created_at` and `updated_at` columns with `NOT NULL DEFAULT NOW()`.
641
+
642
+ `update()` automatically appends `"updated_at" = NOW()` to the SET clause when the column exists — no need to pass it manually.
643
+
644
+ ### Where helpers
645
+
646
+ Importable functions for composing complex WHERE clauses. Works with `readMany`, `updateMany`, `deleteMany`, and `count` — pass as the first argument (single `SQL` or `SQL[]` for implicit AND):
647
+
648
+ ```ts
649
+ import { eq, ne, gt, gte, lt, lte, isNull, isNotNull, like, contains, in_, and, or, not } from 'weifuwu'
650
+
651
+ // Single condition
652
+ const { data } = await users.readMany(gte('created_at', '2026-01-01'))
653
+
654
+ // Array = implicit AND
655
+ const { data } = await users.readMany([
656
+ eq('role', 'admin'),
657
+ gte('created_at', '2026-01-01'),
658
+ contains('metadata', { region: 'us' }),
659
+ ])
660
+
661
+ // Explicit AND/OR composition
662
+ const { data } = await users.readMany(
663
+ or(
664
+ and(eq('role', 'admin'), eq('status', 'active')),
665
+ eq('role', 'superadmin'),
666
+ ),
667
+ { orderBy: { name: 'asc' }, limit: 10 },
668
+ )
669
+
670
+ // Also works with updateMany and deleteMany
671
+ await users.updateMany(gte('age', 65), { role: 'retired' })
672
+ await users.deleteMany(eq('status', 'archived'))
673
+ ```
674
+
675
+ | Helper | SQL | Example |
676
+ |--------|-----|---------|
677
+ | `eq(col, val)` | `= $1` | `eq('level', 'error')` |
678
+ | `ne(col, val)` | `!= $1` | `ne('status', 'archived')` |
679
+ | `gt(col, val)` | `> $1` | `gt('age', 18)` |
680
+ | `gte(col, val)` | `>= $1` | `gte('created_at', '2026-01-01')` |
681
+ | `lt(col, val)` | `< $1` | `lt('id', beforeId)` |
682
+ | `lte(col, val)` | `<= $1` | `lte('score', 100)` |
683
+ | `isNull(col)` | `IS NULL` | `isNull('deleted_at')` |
684
+ | `isNotNull(col)` | `IS NOT NULL` | `isNotNull('email')` |
685
+ | `like(col, pattern)` | `LIKE $1` | `like('name', 'Alice%')` |
686
+ | `contains(col, obj)` | `@> $1::jsonb` | `contains('metadata', { service: 'auth' })` |
687
+ | `in_(col, arr)` | `= ANY($1)` | `in_('id', [1, 2, 3])` |
688
+ | `and(...conds)` | `(... AND ...)` | `and(eq('a', 1), eq('b', 2))` |
689
+ | `or(...conds)` | `(... OR ...)` | `or(eq('a', 1), eq('b', 2))` |
690
+ | `not(cond)` | `NOT (...)` | `not(eq('status', 'archived'))` |
691
+
692
+ ### Complex queries use raw SQL
693
+
694
+ ```ts
695
+ app.get('/users/stats', async (req, ctx) => {
696
+ const rows = await ctx.sql`
697
+ SELECT u.*, count(p.id) as posts
698
+ FROM ${users} u LEFT JOIN posts p ON p.user_id = u.id
699
+ GROUP BY u.id
700
+ `
701
+ return Response.json(rows)
702
+ })
703
+ ```
704
+
705
+ ### Transactions
706
+
707
+ ```ts
708
+ const result = await pg.transaction(async (tx) => {
709
+ const [user] = await tx`INSERT INTO "_users" (...) VALUES (...) RETURNING *`
710
+ const [wallet] = await tx`INSERT INTO "_wallets" ("user_id") VALUES (${user.id}) RETURNING *`
711
+ return { user, wallet }
712
+ })
713
+ ```
714
+
715
+ Use BoundTable methods inside transactions with `withSql()`:
716
+
717
+ ```ts
718
+ const users = pg.table('_users', { ... })
719
+ const wallets = pg.table('_wallets', { ... })
720
+
721
+ const result = await pg.transaction(async (tx) => {
722
+ const txUsers = users.withSql(tx)
723
+ const txWallets = wallets.withSql(tx)
724
+
725
+ const user = await txUsers.insert({ name: 'Alice' })
726
+ await txWallets.insert({ user_id: user.id })
727
+ return user
728
+ })
729
+ ```
730
+
731
+ ### Connection lifecycle
732
+
733
+ ```ts
734
+ const pg = postgres() // reads DATABASE_URL
735
+ const pg = postgres('postgres://...') // explicit connection
736
+ const pg = postgres({
737
+ connection: 'postgres://...',
738
+ max: 10, // pool size
739
+ ssl: { rejectUnauthorized: false }, // SSL options
740
+ idle_timeout: 30, // idle timeout (s)
741
+ connect_timeout: 10, // connection timeout (s)
742
+ closeTimeout: 5, // close grace period (s)
743
+ signal: ac.signal, // abort → sql.end()
744
+ })
745
+ await pg.close()
746
+ ```
747
+
748
+ ### Module base class
749
+
750
+ Every database module extends `PgModule`:
751
+
752
+ ```ts
753
+ import { PgModule } from 'weifuwu'
754
+
755
+ class MyModule extends PgModule {
756
+ constructor(pg: PostgresClient) {
757
+ super(pg) // sets this.sql = pg.sql
758
+ }
759
+ async migrate() { /* override */ }
760
+
761
+ // Built-in helpers
762
+ // this.table(name, builders) — create a BoundTable
763
+ // this.transaction(fn) — run in a transaction
764
+ // close() — calls pg.close() automatically
765
+ }
766
+ ```
767
+
768
+ ---
769
+
770
+ # Auth & User
771
+
772
+ ```ts
773
+ import { serve, Router, postgres, user } from 'weifuwu'
774
+
775
+ const app = new Router()
776
+ const pg = postgres()
777
+ await pg.migrate()
778
+
779
+ const auth = user({ pg, jwtSecret: process.env.JWT_SECRET! })
780
+
781
+ // POST /auth/register { email, password, name }
782
+ // POST /auth/login { email, password }
783
+ // GET /auth/oauth/authorize?client_id=...&redirect_uri=...&response_type=code
784
+ // POST /auth/oauth/consent
785
+ // POST /auth/oauth/token (grant_type=authorization_code|client_credentials)
786
+ app.use('/auth', auth.router())
787
+
788
+ // Protected routes — verifies JWT, sets ctx.user
789
+ app.get('/me', auth.middleware(), async (req, ctx) => {
790
+ return Response.json(ctx.user)
791
+ // { id, email, name, role }
792
+ })
793
+ ```
794
+
795
+ Password hashing uses `crypto.scryptSync` + `timingSafeEqual` (Node.js built-in, zero deps). JWT tokens use the `jsonwebtoken` package. The users table (`_users` by default) is auto-created on first `migrate()`.
796
+
797
+ ## OAuth2 Server
798
+
799
+ Enable OAuth2 Server to let third-party apps (SPA, mobile, microservices) authenticate users through your app.
800
+
801
+ ```ts
802
+ const auth = user({
803
+ pg,
804
+ jwtSecret: process.env.JWT_SECRET!,
805
+ oauth2: { server: true },
806
+ })
807
+
808
+ await auth.migrate() // creates _users + _oauth2_clients + _oauth2_codes + _oauth2_tokens
809
+
810
+ // Register a client app (programmatic — CLI, admin UI, seed script)
811
+ const client = await auth.registerClient({
812
+ name: 'My SPA',
813
+ redirectUris: ['https://myapp.com/callback'],
814
+ })
815
+ // → { clientId, clientSecret, name, redirectUris }
816
+
817
+ // Use auth middleware to protect routes — OAuth2 JWT tokens work seamlessly
818
+ app.get('/api/data', auth.middleware(), handler)
819
+ ```
820
+
821
+ ### Supported Grant Types
822
+
823
+ | Grant | Use Case | PKCE |
824
+ |-------|----------|------|
825
+ | `authorization_code` (with client_secret) | Server-side apps | Optional |
826
+ | `authorization_code` (with `code_challenge`/`code_verifier`) | SPA / Mobile apps | Required |
827
+ | `client_credentials` | Machine-to-machine | — |
828
+
829
+ ### Flow (Authorization Code + PKCE)
830
+
831
+ ```
832
+ 1. Third-party app redirects user:
833
+ GET /oauth/authorize?client_id=xxx&redirect_uri=https://app.com/cb
834
+ &response_type=code&code_challenge=S256&state=yyy
835
+
836
+ 2. User not logged in → 302 to /login?redirect=... → auto returns to consent page after login
837
+
838
+ 3. User confirms consent → POST /oauth/consent { approve: true, client_id, ... }
839
+ 302 redirect_uri?code=xxx&state=yyy
840
+
841
+ 4. Third-party app POST /oauth/token
842
+ { grant_type: authorization_code, code, client_id, client_secret,
843
+ redirect_uri, code_verifier }
844
+ → { access_token, token_type: "Bearer", expires_in, refresh_token }
845
+
846
+ 5. access_token is a standard JWT — auth.middleware() and auth.verify() work with it directly
847
+ ```
848
+
849
+ ### Client Management
850
+
851
+ ```ts
852
+ const client = await auth.registerClient({ name, redirectUris })
853
+ const found = await auth.getClient(client.clientId)
854
+ await auth.revokeClient(client.clientId)
855
+ ```
856
+
857
+ ### Using OAuth2 Tokens with the Built-in Auth Middleware
858
+
859
+ The `access_token` issued by the OAuth2 Server shares the same `jwtSecret` and compatible payload (`sub`, `email`, `role`) as password-login JWTs, so `auth()` can verify OAuth2 tokens without any modifications:
860
+
861
+ ```ts
862
+ import { auth } from 'weifuwu'
863
+
864
+ // Same auth() middleware validates both password-login JWTs and OAuth2 JWTs
865
+ app.get('/api', auth({ verify: (token) => auth.verify(token) }), handler)
866
+ ```
867
+
868
+ For `client_credentials` tokens (machine-to-machine), `verify()` returns `null` since no user is associated.
869
+
870
+ ### Social Login (GitHub) — Cookbook
871
+
872
+ `user()` does not bundle social login (to avoid third-party dependencies), but adding a GitHub login with the low-level API takes ~30 lines:
873
+
874
+ ```ts
875
+ import { user } from 'weifuwu'
876
+ import jwt from 'jsonwebtoken'
877
+
878
+ const auth = user({ pg, jwtSecret })
879
+
880
+ // 1. Redirect to GitHub authorization
881
+ app.get('/auth/github', () => {
882
+ const url = new URL('https://github.com/login/oauth/authorize')
883
+ url.searchParams.set('client_id', process.env.GH_CLIENT_ID!)
884
+ url.searchParams.set('redirect_uri', 'http://localhost:3000/auth/github/callback')
885
+ url.searchParams.set('scope', 'user:email')
886
+ return Response.redirect(url.href)
887
+ })
888
+
889
+ // 2. GitHub callback → fetch user info → register/login
890
+ app.get('/auth/github/callback', async (req) => {
891
+ const { code } = Object.fromEntries(new URL(req.url).searchParams)
892
+ if (!code) return new Response('Missing code', { status: 400 })
893
+
894
+ // Exchange code for token
895
+ const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
896
+ method: 'POST',
897
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
898
+ body: JSON.stringify({
899
+ client_id: process.env.GH_CLIENT_ID,
900
+ client_secret: process.env.GH_CLIENT_SECRET,
901
+ code,
902
+ }),
903
+ })
904
+ const { access_token } = await tokenRes.json() as any
905
+
906
+ // Fetch user info from GitHub
907
+ const userRes = await fetch('https://api.github.com/user', {
908
+ headers: { Authorization: `Bearer ${access_token}` },
909
+ })
910
+ const ghUser = await userRes.json() as any
911
+
912
+ // Find or create local user
913
+ const existing = await pg.sql`SELECT * FROM "_users" WHERE email = ${ghUser.email}`
914
+ let localUser = existing[0]
915
+
916
+ if (!localUser) {
917
+ localUser = await auth.register({
918
+ email: ghUser.email,
919
+ password: crypto.randomUUID(), // Random password — user can only log in via GitHub
920
+ name: ghUser.name ?? ghUser.login,
921
+ })
922
+ }
923
+
924
+ // Sign JWT (same format as user())
925
+ const token = jwt.sign(
926
+ { sub: localUser.id, email: localUser.email, role: localUser.role ?? 'user' },
927
+ process.env.JWT_SECRET!,
928
+ { expiresIn: '24h' },
929
+ )
930
+ return Response.json({ token })
931
+ })
932
+ ```
933
+
934
+ The same pattern works for Google, WeChat, or any OAuth2 provider.
935
+
936
+ ---
937
+
938
+ # React SSR with tsx()
939
+
940
+ ```ts
941
+ import { serve, Router } from 'weifuwu'
942
+ import { tsx } from 'weifuwu/tsx'
943
+
944
+ const app = new Router()
945
+ app.use('/', await tsx({ dir: './ui/' }))
946
+
947
+ serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
948
+ ```
949
+
950
+ ### Directory structure
951
+
952
+ ```
953
+ ui/
954
+ ├── pages/ ← page files
955
+ │ ├── page.tsx → GET / (React component, default export)
956
+ │ ├── layout.tsx → root layout (HTML shell, receives req/ctx, NOT hydrated)
957
+ │ ├── not-found.tsx → 404 error page (rendered for unmatched routes, wrapped in layout)
958
+ │ ├── about/page.tsx → GET /about
959
+ │ ├── blog/[slug]/
960
+ │ │ ├── page.tsx → GET /blog/:slug
961
+ │ │ ├── load.ts → data fetching (server-only, default export)
962
+ │ │ └── route.ts → POST /blog/:slug (API, named exports POST/PUT/DELETE/...)
963
+ │ ├── blog/layout.tsx → /blog/* layout (UI structure, receives children, hydrated)
964
+ │ └── api/search/
965
+ │ └── route.ts → GET /api/search (standalone API, no page.tsx needed)
966
+ └── components/ ← component files (auto-detected by HMR)
967
+ └── button.tsx
968
+ ```
969
+
970
+ ### Development mode
971
+
972
+ tsx() runs in development mode automatically when `NODE_ENV !== 'production'`:
973
+
974
+ - **File watching** — chokidar watches the `dir` directory for `.tsx`/`.ts` changes
975
+ - Page files in `pages/` → single-file recompilation + registry update
976
+ - Component files in `components/` → full rebuild of all pages
977
+ - New files are detected automatically
978
+ - **Live reload** — Compiled via esbuild `write: false` + `vm.Script.runInContext` (no disk writes, no `node --watch` conflict)
979
+ - **WebSocket auto-refresh** — `/__weifuwu/livereload` endpoint pushes reload signals; browser refreshes automatically
980
+ - **`node --watch` compatible** — External files (`app.ts`, `middleware/`) handled by `--watch` restart; `ui/` changes handled by tsx() without conflict
981
+
982
+ ```bash
983
+ node app.ts # development (auto-reload + live refresh)
984
+ NODE_ENV=production node app.ts # production
985
+ ```
986
+
987
+ ### Tailwind CSS
988
+
989
+ tsx() includes built-in Tailwind CSS v4 support. If an `app.css` file exists in the `dir` directory, it is compiled automatically through PostCSS + `@tailwindcss/postcss`. If no `app.css` is found, one is created automatically:
990
+
991
+ ```css
992
+ @import "tailwindcss";
993
+ ```
994
+
995
+ Write `className` directly in your components — no CLI, no configuration:
996
+
997
+ ```tsx
998
+ export default function Home() {
999
+ return <h1 className="text-3xl font-bold text-blue-600">Hello</h1>
1000
+ }
1001
+ ```
1002
+
1003
+ ### `@` alias
1004
+
1005
+ If your project has a `tsconfig.json` or `jsconfig.json` with `compilerOptions.paths`, tsx() reads it automatically and passes aliases to all esbuild builds (SSR compilation, hydration bundles, and hot reload):
1006
+
1007
+ ```json
1008
+ {
1009
+ "compilerOptions": {
1010
+ "paths": {
1011
+ "@/*": ["./ui/*"]
1012
+ }
1013
+ }
1014
+ }
1015
+ ```
1016
+
1017
+ ### shadcn/ui
1018
+
1019
+ tsx() works with [shadcn/ui](https://ui.shadcn.com) out of the box.
1020
+
1021
+ ```bash
1022
+ npx shadcn@latest init
1023
+ # Style: your preference
1024
+ # Base color: your preference
1025
+ # CSS file path: ui/app.css
1026
+ # Import alias: @/ → ./ui/
1027
+ ```
1028
+
1029
+ ### page.tsx — page component
1030
+
1031
+ ```tsx
1032
+ export default function Page({ params, query }: {
1033
+ params: { slug: string }
1034
+ query: Record<string, string>
1035
+ }) {
1036
+ return <article><h1>{params.slug}</h1></article>
1037
+ }
1038
+ ```
1039
+
1040
+ ### load.ts — data fetching (server-only)
1041
+
1042
+ ```ts
1043
+ export default async function load({ params, query }: {
1044
+ params: Record<string, string>
1045
+ query: Record<string, string>
1046
+ }) {
1047
+ const data = await db.query(params.slug)
1048
+ return { data } // merged into props passed to page.tsx
1049
+ }
1050
+ ```
1051
+
1052
+ ### layout.tsx
1053
+
1054
+ **Root layout** (`pages/layout.tsx`) — receives `{ children, req, ctx }`:
1055
+
1056
+ ```tsx
1057
+ export default function RootLayout({ children, req, ctx }: {
1058
+ children: React.ReactNode
1059
+ req: Request
1060
+ ctx: Context
1061
+ }) {
1062
+ return (
1063
+ <html>
1064
+ <head><title>App</title></head>
1065
+ <body><div id="__weifuwu_root">{children}</div></body>
1066
+ </html>
1067
+ )
1068
+ }
1069
+ ```
1070
+
1071
+ **Nested layouts** (`pages/blog/layout.tsx`) — receives only `{ children }`.
1072
+
1073
+ ### route.ts — API (co-located with page)
1074
+
1075
+ ```ts
1076
+ export const POST: Handler = async (req, ctx) => {
1077
+ const body = await req.json()
1078
+ return Response.json({ ...body, slug: ctx.params.slug })
1079
+ }
1080
+ ```
1081
+
1082
+ ### not-found.tsx — 404 page
1083
+
1084
+ ```tsx
1085
+ export default function NotFound() {
1086
+ return <h1 class="text-4xl">404 – Not Found</h1>
1087
+ }
1088
+ ```
1089
+
1090
+ ---
1091
+
1092
+ # AI: Streaming & Workflow
1093
+
1094
+ ## AI streaming
1095
+
1096
+ Server-sent event streaming via the Vercel AI SDK:
1097
+
1098
+ ```ts
1099
+ import { serve, Router, aiStream } from 'weifuwu'
1100
+ import { openai } from '@ai-sdk/openai'
1101
+
1102
+ const app = new Router()
1103
+ const chat = await aiStream(async (req, ctx) => {
1104
+ const { messages } = await req.json()
1105
+ return { model: openai('gpt-4o'), messages }
1106
+ })
1107
+ app.use('/chat', chat.router())
1108
+
1109
+ serve(app.handler(), { port: 3000 })
1110
+ ```
1111
+
1112
+ ## runWorkflow
1113
+
1114
+ Multi-step DAG execution engine — packaged as a single AI SDK `Tool`. Use it with `streamText()` or `generateText()` when the LLM needs conditional logic, loops, or multi-step tool orchestration.
1115
+
1116
+ ```ts
1117
+ import { tool, streamText } from 'ai'
1118
+ import { runWorkflow } from 'weifuwu'
1119
+ import { z } from 'zod'
1120
+
1121
+ const tools = {
1122
+ queryUser: tool({
1123
+ description: 'Query user info',
1124
+ inputSchema: z.object({ userId: z.string() }),
1125
+ execute: async ({ userId }) => ({ id: userId, email: 'user@test.com', name: 'Test' }),
1126
+ }),
1127
+ sendEmail: tool({
1128
+ description: 'Send an email',
1129
+ inputSchema: z.object({ to: z.string(), subject: z.string() }),
1130
+ execute: async ({ to, subject }) => ({ sent: true }),
1131
+ }),
1132
+ runWF: runWorkflow({ tools: { queryUser, sendEmail } }),
1133
+ }
1134
+
1135
+ const result = await streamText({
1136
+ model,
1137
+ tools,
1138
+ messages: [{ role: 'user', content: 'Query user 123, send welcome email if exists' }],
1139
+ })
1140
+ ```
1141
+
1142
+ ### Node types
1143
+
1144
+ 7 built-in node types for defining the execution graph:
1145
+
1146
+ | Node | Purpose | Input |
1147
+ |------|---------|-------|
1148
+ | `call` | Call a registered AI SDK Tool | `{ tool: "name", args: {...} }` |
1149
+ | `set` | Assign a variable | `{ name: "x", value: 42 }` |
1150
+ | `get` | Read a variable | `{ name: "x" }` |
1151
+ | `eval` | Evaluate an expression | `{ expression: "$var.x + 1" }` |
1152
+ | `if` | Conditional branch | `{ conditions: [{ test: ..., body: [nodes] }] }` |
1153
+ | `while` | Loop | `{ condition: "$var.i < 5" }, body: [nodes]` |
1154
+ | `http` | HTTP request | `{ url: "https://...", method: "GET" }` |
1155
+
1156
+ ### Reference syntax
1157
+
1158
+ | Pattern | Meaning | Example |
1159
+ |---------|---------|---------|
1160
+ | `$var.x` | Variable `x` | `$var.counter` |
1161
+ | `$nodes.u.output` | Full output of node `u` | `$nodes.u.output` |
1162
+ | `$nodes.u.output.field` | Specific field | `$nodes.u.output.email` |
1163
+ | `$input.userId` | Input param | `$input.userId` |
1164
+
1165
+ ---
1166
+
1167
+ # AI Agent
1168
+
1169
+ Server-side AI agents with OpenAI-compatible API. Built-in chat, tool-use (tool-calling), and knowledge (RAG) types. Works out of the box with Ollama or any OpenAI-compatible provider.
1170
+
1171
+ ```ts
1172
+ import { agent } from 'weifuwu'
1173
+
1174
+ const agents = agent({ pg })
1175
+
1176
+ await agents.migrate()
1177
+ app.use('/api', agents.router())
1178
+ ```
1179
+
1180
+ | Type | Description | Execution |
1181
+ |------|-------------|-----------|
1182
+ | `chat` | Pure conversation | `streamText()` / `generateText()` |
1183
+ | `tool-use` | Tool-calling agent | `streamText({ tools })` |
1184
+
1185
+ ### Knowledge (RAG)
1186
+
1187
+ Add documents to any agent — `searchKnowledge` tool auto-injected:
1188
+
1189
+ ```ts
1190
+ await agents.addKnowledge(agentId, 'Title', 'Document content...')
1191
+ ```
1192
+
1193
+ ### Streaming
1194
+
1195
+ ```http
1196
+ POST /agents/:id/run { input: "hello", stream: true }
1197
+ → event-stream (fullStream SSE: text-delta, tool-call, tool-result, finish)
1198
+ ```
1199
+
1200
+ ### Programmatic API
1201
+
1202
+ ```ts
1203
+ const result = await agents.run(agentId, { input: 'hello', stream: false })
1204
+ // { output: "Hello!", elapsed: 1234 }
1205
+ ```
1206
+
1207
+ ---
1208
+
1209
+ # GraphQL
1210
+
1211
+ Dynamic GraphQL schema generated per-request based on the authenticated tenant's tables.
1212
+
1213
+ ```graphql
1214
+ type Article {
1215
+ id: ID!
1216
+ title: String!
1217
+ content: String
1218
+ status: String
1219
+ comments(limit: Int, offset: Int): [Comment!]!
1220
+ }
1221
+
1222
+ type Query {
1223
+ articles(limit: Int, offset: Int): [Article!]!
1224
+ getArticle(id: ID!): Article
1225
+ }
1226
+
1227
+ type Mutation {
1228
+ createArticle(data: CreateArticleInput!): Article!
1229
+ updateArticle(id: ID!, data: PatchArticleInput!): Article!
1230
+ deleteArticle(id: ID!): Boolean!
1231
+ }
1232
+ ```
1233
+
1234
+ Built with `graphql-js` native constructors (`GraphQLObjectType`), no SDL generation, no `makeExecutableSchema`.
1235
+
1236
+ ---
1237
+
1238
+ # Tenant BaaS
1239
+
1240
+ Built-in multi-tenant backend-as-a-service — define tables at runtime via API, get RESTful CRUD + GraphQL automatically, with row-level tenant isolation.
1241
+
1242
+ ```ts
1243
+ import { serve, Router, postgres, user, tenant } from 'weifuwu'
1244
+
1245
+ const pg = postgres()
1246
+ const u = user({ pg, jwtSecret: process.env.JWT_SECRET! })
1247
+ const t = tenant({ pg, usersTable: '_users' })
1248
+
1249
+ await pg.migrate()
1250
+ await u.migrate()
1251
+ await t.migrate() // creates _tenants, _tenant_members, _user_tables
1252
+
1253
+ const app = new Router()
1254
+ app.use('/auth', u.router())
1255
+ app.use('/api', u.middleware()) // → ctx.user
1256
+ app.use('/api', t.middleware()) // → ctx.tenant
1257
+ app.use('/api', t.router()) // → management + data CRUD
1258
+ app.use('/graphql', t.graphql()) // → dynamic GraphQL
1259
+ ```
1260
+
1261
+ ## System tables
1262
+
1263
+ | Table | Purpose |
1264
+ |-------|---------|
1265
+ | `_tenants` | Tenant records (`id TEXT PK DEFAULT gen_random_uuid()`, `name`, `created_at`) |
1266
+ | `_tenant_members` | User-tenant membership (`tenant_id`, `user_id`, `role`) |
1267
+ | `_user_tables` | Dynamic table definitions (`tenant_id`, `slug`, `fields JSONB`) |
1268
+
1269
+ ## Dynamic table API
1270
+
1271
+ Create a table at runtime:
1272
+
1273
+ ```json
1274
+ POST /api/tables
1275
+ {
1276
+ "slug": "articles",
1277
+ "fields": [
1278
+ { "name": "title", "type": "string", "required": true },
1279
+ { "name": "content", "type": "text" },
1280
+ { "name": "status", "type": "enum", "options": ["draft", "published"], "default": "draft" },
1281
+ { "name": "views", "type": "integer", "default": 0 },
1282
+ { "name": "embedding", "type": "vector", "dimensions": 1536, "index": "hnsw" }
1283
+ ]
1284
+ }
1285
+ ```
1286
+
1287
+ ## Field types
1288
+
1289
+ | type | PostgreSQL | Index support |
1290
+ |------|-----------|---------------|
1291
+ | `string` | `TEXT` | `true`, `unique` |
1292
+ | `integer` | `INTEGER` | `true`, `desc`, `unique` |
1293
+ | `float` | `DOUBLE PRECISION` | `true`, `desc` |
1294
+ | `boolean` | `BOOLEAN` | `true` |
1295
+ | `text` | `TEXT` | `true` |
1296
+ | `datetime` | `TIMESTAMPTZ` | `true`, `desc` |
1297
+ | `date` | `DATE` | `true`, `desc` |
1298
+ | `enum` | `TEXT` (with validation) | `true` |
1299
+ | `json` | `JSONB` | `gin` |
1300
+ | `vector` | `vector(n)` (pgvector) | `hnsw` (HNSW, vector_cosine_ops) |
1301
+
1302
+ ## RESTful API
1303
+
1304
+ All routes require `ctx.tenant` (set by `t.middleware()`). All queries automatically filter by `tenant_id`.
1305
+
1306
+ | Route | Method | Description |
1307
+ |-------|--------|-------------|
1308
+ | `/sys/tenants` | POST | Create tenant, caller becomes admin |
1309
+ | `/sys/tenants` | GET | List user's tenants |
1310
+ | `/sys/tenants/invite` | POST | Invite user by email (admin) |
1311
+ | `/sys/tenants/members/:userId` | DELETE | Remove member (admin) |
1312
+ | `/sys/tables` | POST/GET | Create / list dynamic tables |
1313
+ | `/sys/tables/:slug` | GET/PATCH/DELETE | Get schema / add fields / drop table |
1314
+ | `/:slug` | GET | List rows (limit, offset, sort) |
1315
+ | `/:slug` | POST | Create row |
1316
+ | `/:slug/:id` | GET/PATCH/DELETE | Get / update / delete row |
1317
+ | `/:slug/:id/:_nested` | GET | List related rows (has_many / M2M) |
1318
+ | `/:slug/:id/:_nested` | POST | Create related row (auto-fills relation field) |
1319
+
1320
+ ---
1321
+
1322
+ # Messager
1323
+
1324
+ Real-time chat with channels, WebSocket, and agent routing.
1325
+
1326
+ ```ts
1327
+ import { messager, agent } from 'weifuwu'
1328
+
1329
+ const agents = agent({ pg })
1330
+ const msg = messager({ pg, agents })
1331
+
1332
+ await msg.migrate()
1333
+ app.use('/api', msg.router())
1334
+ app.ws('/ws', u.middleware(), msg.wsHandler())
1335
+ ```
1336
+
1337
+ ## Channels
1338
+
1339
+ ```http
1340
+ POST /channels name, type (channel|dm), members
1341
+ GET /channels
1342
+ GET /channels/:id
1343
+ ```
1344
+
1345
+ ## Messages
1346
+
1347
+ ```http
1348
+ GET /channels/:id/messages ?limit=50&before={id}
1349
+ POST /channels/:id/messages content, sender_type, type
1350
+ POST /channels/:id/read last_message_id
1351
+ ```
1352
+
1353
+ ## WebSocket
1354
+
1355
+ ```json
1356
+ { "type": "message", "channel_id": 1, "content": "Hi" }
1357
+ { "type": "typing", "channel_id": 1, "is_typing": true }
1358
+ { "type": "read", "channel_id": 1, "last_message_id": 42 }
1359
+ ```
1360
+
1361
+ ## Programmatic send
1362
+
1363
+ ```ts
1364
+ await msg.send(channelId, 'System message', { sender_type: 'system' })
1365
+ ```
1366
+
1367
+ ---
1368
+
1369
+ # LogDB — Structured Event Logging
1370
+
1371
+ PostgreSQL-backed structured logging with monthly partitioning, metadata search, and built-in REST API.
1372
+
1373
+ ```ts
1374
+ import { serve, Router, logdb, postgres } from 'weifuwu'
1375
+
1376
+ const pg = postgres()
1377
+ const logger = logdb({ pg })
1378
+
1379
+ await logger.migrate() // create table + partitions
1380
+ app.use('/logs', logger.router()) // mount REST API
1381
+ ```
1382
+
1383
+ ## Module API
1384
+
1385
+ ```ts
1386
+ const logger = logdb({
1387
+ pg: PostgresClient,
1388
+ table?: string // default: '_log_entries'
1389
+ })
1390
+ ```
1391
+
1392
+ | Method | Returns | Description |
1393
+ |--------|---------|-------------|
1394
+ | `log(input)` | `LogEntry` | Insert a log entry programmatically |
1395
+ | `router()` | `Router` | REST API routes: `POST /`, `GET /`, `GET /:id` |
1396
+ | `migrate()` | `Promise<void>` | Create partitioned table + month partitions |
1397
+ | `clean(n)` | `Promise<number>` | Drop partitions older than `n` months |
1398
+ | `close()` | `Promise<void>` | Close database connection |
1399
+
1400
+ ## Log entries
1401
+
1402
+ ```ts
1403
+ interface LogEntryInput {
1404
+ level: string // info, warn, error, debug
1405
+ source: string // api, ui, system, or custom
1406
+ message: string
1407
+ metadata?: Record<string, unknown>
1408
+ }
1409
+ ```
1410
+
1411
+ ## REST API
1412
+
1413
+ | Method | Path | Description |
1414
+ |--------|------|-------------|
1415
+ | `POST /` | Create a log entry | Returns `LogEntry` with status 201 |
1416
+ | `GET /` | Query log entries | Returns `{ entries: LogEntry[], total: number }` |
1417
+ | `GET /:id` | Get single entry | Returns `LogEntry` or 404 |
1418
+
1419
+ ### Query parameters (`GET /`)
1420
+
1421
+ | Param | Example | Description |
1422
+ |-------|---------|-------------|
1423
+ | `level` | `?level=error` | Filter by level (exact match) |
1424
+ | `source` | `?source=api` | Filter by source (exact match) |
1425
+ | `after` | `?after=2026-01-01` | Entries on or after this timestamp |
1426
+ | `before` | `?before=2026-03-01` | Entries before this timestamp |
1427
+ | `meta.*` | `?meta.service=auth&meta.env=prod` | Filter by metadata key/value |
1428
+ | `limit` | `?limit=20` | Page size (default: 50) |
1429
+ | `offset` | `?offset=40` | Page offset (default: 0) |
1430
+
1431
+ ## Partitioning
1432
+
1433
+ Logs are stored in a PostgreSQL range-partitioned table by `created_at`. Partitions are pre-created for the current month + 12 months ahead. This keeps each partition small, enables partition-pruning for time-range queries, and allows instant retention via `DROP TABLE`.
1434
+
1435
+ ### Retention
1436
+
1437
+ ```ts
1438
+ // Drop all partitions older than 12 months
1439
+ const dropped = await logger.clean(12)
1440
+ console.log(`Dropped ${dropped} old partitions`)
1441
+ ```
1442
+
1443
+ The `migrate()` method creates the parent table and pre-creates partitions. The `log()` method checks for the current month's partition and creates it if missing — safe across month boundaries without re-running migration.
1444
+
1445
+ ---
1446
+
1447
+ # Opencode
1448
+
1449
+ AI programming assistant — chat with LLM agents that have access to filesystem tools, skills, and isolated session workspaces.
1450
+
1451
+ ```ts
1452
+ import { serve, Router, postgres, opencode } from 'weifuwu'
1453
+
1454
+ const app = new Router()
1455
+ const pg = postgres()
1456
+ const oc = await opencode({ pg, permissions: { ... } })
1457
+
1458
+ await oc.migrate()
1459
+ app.use('/opencode', await oc.router())
1460
+ app.ws('/opencode', oc.wsHandler())
1461
+
1462
+ serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
1463
+ ```
1464
+
1465
+ ### Tools
1466
+
1467
+ | Tool | Description |
1468
+ |------|-------------|
1469
+ | `bash` | Execute shell commands in the workspace |
1470
+ | `read` | Read files with offset/limit |
1471
+ | `write` | Create or overwrite files |
1472
+ | `edit` | Exact string replacements |
1473
+ | `grep` | Regex content search |
1474
+ | `glob` | Glob pattern file search |
1475
+ | `web` | Fetch URL content |
1476
+ | `question` | Ask the user for input |
1477
+ | `skill` | Load a skill on demand |
1478
+
1479
+ ### Permissions
1480
+
1481
+ Control tool access per conversation:
1482
+
1483
+ ```ts
1484
+ const oc = await opencode({
1485
+ pg,
1486
+ permissions: {
1487
+ bash: { allow: true },
1488
+ read: { allow: true },
1489
+ write: { allow: false },
1490
+ edit: { allow: false },
1491
+ skill: { '*': { allow: true }, 'internal-*': { allow: false } },
1492
+ },
1493
+ })
1494
+ ```
1495
+
1496
+ ---
1497
+
1498
+ # SEO
1499
+
1500
+ Built-in SEO module — `robots.txt`, `sitemap.xml`, indexing headers, and meta tag utilities.
1501
+
1502
+ ```ts
1503
+ import { seo, seoMiddleware, seoTags } from 'weifuwu'
1504
+
1505
+ const app = new Router()
1506
+
1507
+ // robots.txt + sitemap.xml
1508
+ app.use(seo({
1509
+ baseUrl: 'https://example.com',
1510
+ robots: [
1511
+ { userAgent: '*', allow: '/', disallow: ['/admin', '/api'] },
1512
+ ],
1513
+ sitemap: {
1514
+ urls: [
1515
+ { loc: '/', changefreq: 'daily', priority: 1.0 },
1516
+ { loc: '/about', changefreq: 'monthly', priority: 0.8 },
1517
+ ],
1518
+ // Dynamic URLs from database
1519
+ async resolve() {
1520
+ const articles = await db.query('SELECT slug, updated_at FROM articles')
1521
+ return articles.map(a => ({
1522
+ loc: `/blog/${a.slug}`,
1523
+ lastmod: a.updated_at,
1524
+ }))
1525
+ },
1526
+ cacheTTL: 3_600_000, // re-generate every hour (default)
1527
+ },
1528
+ }))
1529
+ ```
1530
+
1531
+ ### Endpoints
1532
+
1533
+ | Path | Description |
1534
+ |------|-------------|
1535
+ | `GET /robots.txt` | Generated robots.txt with optional Sitemap reference |
1536
+ | `GET /sitemap.xml` | Generated XML sitemap with caching |
1537
+
1538
+ ### seoMiddleware — Indexing control
1539
+
1540
+ ```ts
1541
+ // Global — block all paths
1542
+ app.use(seoMiddleware({ headers: { 'X-Robots-Tag': 'noindex' } }))
1543
+
1544
+ // Per-path via function
1545
+ app.use(seoMiddleware({
1546
+ headers: {
1547
+ 'X-Robots-Tag': (path) => path.startsWith('/admin') ? 'noindex' : undefined,
1548
+ },
1549
+ }))
1550
+ ```
1551
+
1552
+ ### seoTags — Meta / OG / Twitter Card
1553
+
1554
+ Generate SEO meta tags for SSR pages:
1555
+
1556
+ ```ts
1557
+ const tags = seoTags({
1558
+ title: 'My Page',
1559
+ description: 'A great page about things',
1560
+ ogImage: 'https://example.com/og.png',
1561
+ twitterCard: 'summary_large_image',
1562
+ canonical: 'https://example.com/page',
1563
+ })
1564
+ // → <title>My Page</title>
1565
+ // → <meta property="og:title" content="My Page">
1566
+ // → <meta name="twitter:card" content="summary_large_image">
1567
+ // → <link rel="canonical" href="https://example.com/page">
1568
+ // ...
1569
+ ```
1570
+
1571
+ Use in `layout.tsx` or `page.tsx` with `tsx()`:
1572
+
1573
+ ```tsx
1574
+ export default function RootLayout({ children }) {
1575
+ return (
1576
+ <html>
1577
+ <head>{seoTags({ title: 'My App' })}</head>
1578
+ <body>{children}</body>
1579
+ </html>
1580
+ )
1581
+ }
1582
+ ```
1583
+
1584
+ # Security
1585
+
1586
+ ## Helmet — Security headers
1587
+
1588
+ ```ts
1589
+ import { helmet } from 'weifuwu'
1590
+
1591
+ // Apply all security headers with safe defaults
1592
+ app.use(helmet())
1593
+
1594
+ // Customize individual headers (any can be set to false to remove)
1595
+ app.use(helmet({
1596
+ contentSecurityPolicy: "default-src 'self'",
1597
+ xFrameOptions: 'DENY',
1598
+ strictTransportSecurity: 'max-age=63072000; includeSubDomains; preload',
1599
+ }))
1600
+
1601
+ // Middleware-order: set after helmet to override
1602
+ app.use(helmet({ xFrameOptions: false })) // remove a header
1603
+ ```
1604
+
1605
+ 13 security headers set by default:
1606
+
1607
+ | Header | Default |
1608
+ |--------|---------|
1609
+ | `Content-Security-Policy` | `default-src 'self'; ...` |
1610
+ | `X-Content-Type-Options` | `nosniff` |
1611
+ | `X-Frame-Options` | `SAMEORIGIN` |
1612
+ | `Strict-Transport-Security` | `max-age=15552000; includeSubDomains` |
1613
+ | `X-XSS-Protection` | `0` |
1614
+ | `Referrer-Policy` | `no-referrer` |
1615
+ | `Permissions-Policy` | `camera=(),geolocation=(),...` |
1616
+ | `Cross-Origin-Embedder-Policy` | `require-corp` |
1617
+ | `Cross-Origin-Opener-Policy` | `same-origin` |
1618
+ | `Cross-Origin-Resource-Policy` | `same-origin` |
1619
+ | `Origin-Agent-Cluster` | `?1` |
1620
+ | `X-DNS-Prefetch-Control` | `off` |
1621
+ | `X-Download-Options` | `noopen` |
1622
+ | `X-Permitted-Cross-Domain-Policies` | `none` |
1623
+
1624
+ Does not override response headers already set by the application — your explicit headers take precedence.
1625
+
1626
+ ## Request ID
1627
+
1628
+ ```ts
1629
+ import { requestId } from 'weifuwu'
1630
+
1631
+ // Every response gets X-Request-ID
1632
+ app.use(requestId())
1633
+
1634
+ // Custom header name
1635
+ app.use(requestId({ header: 'X-Trace-Id' }))
1636
+
1637
+ // Custom ID generator
1638
+ app.use(requestId({ generator: () => crypto.randomUUID() }))
1639
+
1640
+ // Access the ID in handlers via ctx.requestId
1641
+ app.get('/log', (req, ctx) => {
1642
+ console.log(`Handling request ${ctx.requestId}`)
1643
+ return Response.json({ id: ctx.requestId })
1644
+ })
1645
+ ```
1646
+
1647
+ Preserves incoming `X-Request-ID` for distributed tracing — if the upstream service already set it, the value is reused and propagated.
1648
+
1649
+ ## Server-Sent Events
1650
+
1651
+ ```ts
1652
+ import { createSSEStream, formatSSE } from 'weifuwu'
1653
+
1654
+ async function* eventStream() {
1655
+ yield { type: 'ping', data: { time: Date.now() } }
1656
+ yield { type: 'message', data: { text: 'hello' } }
1657
+ }
1658
+
1659
+ app.get('/events', () => createSSEStream(eventStream()))
1660
+ ```
1661
+
1662
+ | Function | Description |
1663
+ |----------|-------------|
1664
+ | `createSSEStream(iterable, opts?)` | Returns a `Response` with `Content-Type: text/event-stream` |
1665
+ | `formatSSE(event, data)` | Formats an SSE event string (`event: ...\ndata: ...\n\n`) |
1666
+ | `formatSSEData(data)` | Formats SSE data-only string (`data: ...\n\n`) |
1667
+
1668
+ # Health, i18n, Email & Testing
1669
+
1670
+ ## Health check
1671
+
1672
+ ```ts
1673
+ import { serve, Router, health } from 'weifuwu'
1674
+
1675
+ const app = new Router()
1676
+ app.use(health()) // GET /health → 200
1677
+ app.use(health({ path: '/healthz' })) // custom path
1678
+ app.use(health({
1679
+ check: async () => { await db.sql`SELECT 1` }, // fail → 503
1680
+ }))
1681
+ serve(app.handler(), { port: 3000 })
1682
+ ```
1683
+
1684
+ Returns a `Router` — mount with `app.use()`.
1685
+
1686
+ ## Internationalization
1687
+
1688
+ ```ts
1689
+ import { i18n } from 'weifuwu'
1690
+
1691
+ app.use(i18n({ dir: './locales', defaultLocale: 'en' }))
1692
+
1693
+ // In any handler after i18n middleware:
1694
+ app.get('/hello', (req, ctx) => {
1695
+ const msg = ctx.t('greeting', { name: 'World' })
1696
+ return Response.json({ message: msg, locale: ctx.locale })
1697
+ })
1698
+ ```
1699
+
1700
+ Locale detection: `Cookie: locale=zh` → `Accept-Language: zh-CN` → `defaultLocale`.
1701
+
1702
+ ## Email
1703
+
1704
+ ```ts
1705
+ import { mailer } from 'weifuwu'
1706
+
1707
+ // SMTP transport
1708
+ const mail = mailer({
1709
+ transport: 'smtp://user:pass@smtp.example.com',
1710
+ from: 'noreply@example.com',
1711
+ })
1712
+ await mail.send({ to: 'user@example.com', subject: 'Welcome', html: '<h1>Hi!</h1>' })
1713
+ await mail.close()
1714
+
1715
+ // Custom transport (Resend, SES, SendGrid, etc.)
1716
+ const mail2 = mailer({
1717
+ send: async (msg) => { await resend.emails.send(msg) },
1718
+ })
1719
+ await mail2.send({ to: 'user@example.com', subject: 'Hi', text: 'Hello' })
1720
+ await mail2.close()
1721
+ ```
1722
+
1723
+ ## Test utilities
1724
+
1725
+ ```ts
1726
+ import { createTestServer } from 'weifuwu'
1727
+
1728
+ const { server, url } = await createTestServer(app.handler())
1729
+ const res = await fetch(`${url}/api/users`)
1730
+ assert.equal(res.status, 200)
1731
+ server.stop()
1732
+ ```
1733
+
1734
+ ---
1735
+
156
1736
  ## License
157
1737
 
158
1738
  MIT