weifuwu 0.17.26 → 0.18.1

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
@@ -88,45 +88,89 @@ app.get('/admin', mw, handler) // route-level
88
88
 
89
89
  ## Module Patterns
90
90
 
91
- All modules follow one of 5 patterns. The pattern letter is marked in each module's heading.
91
+ All modules follow one of **2 patterns** learn these and you know every module.
92
92
 
93
93
  | Pattern | How to mount | Example |
94
94
  |---------|-------------|---------|
95
- | `[A]` | `app.use(mod())` | `compress()`, `preferences()` |
96
- | `[B]` | `app.use(mod())` + call `.stop()` / `.close()` etc. | `rateLimit({...})` |
97
- | `[C]` | `app.use(mod.middleware())` + `app.use('/', mod.router())` | `analytics()`, `user()` |
98
- | `[D]` | `app.use(mod().handler())` | `health()`, `seo()` |
99
- | `[E]` | `app.use('/', g.router().handler())` | `graphql(handler)` |
95
+ | `[α]` | `app.use(mod())` | `compress()`, `preferences()`, `postgres()` |
96
+ | `[β]` | `app.use('/path', mod())` | `health()`, `graphql(handler)`, `user()` |
97
+
98
+ ### Pattern α Middleware
99
+
100
+ ```ts
101
+ app.use(compress()) // basic
102
+ const pg = postgres() // with extras: .sql, .table, .migrate(), .close()
103
+ app.use(pg)
104
+ app.use(rateLimit({ max: 100 })) // with .stop()
105
+ ```
106
+
107
+ ### Pattern β — Router
108
+
109
+ ```ts
110
+ app.use('/health', health()) // no extras
111
+ app.use('/graphql', graphql(handler))
112
+ app.use('/logs', logdb({ pg })) // with .log(), .migrate()
113
+ app.use('/auth', user({ pg, jwtSecret })) // with .middleware(), .register()
114
+ app.ws('/ws', messager({ pg }).wsHandler())
115
+ ```
116
+
117
+ β modules that need **separate middleware** use `.middleware()`:
118
+ ```ts
119
+ const a = analytics()
120
+ app.use(a.middleware()) // tracking
121
+ app.use('/', a) // dashboard
122
+ ```
100
123
 
101
124
  ---
102
125
 
103
126
  ## Module Reference
104
127
 
105
- ### agent [C]
128
+ ### agent [β]
106
129
 
107
130
  ```ts
108
- const a = agent({ pg })
131
+ const a = agent({ pg, model: openai('gpt-4o'), embeddingModel: openai.embedding('text-embedding-3-small') })
109
132
  await a.migrate()
110
- app.use('/api', a.router())
111
- await a.addKnowledge(agentId, 'Title', 'docs')
112
- a.run(agentId, { task: 'summarize' })
133
+ app.use('/api', a)
134
+ await a.addKnowledge(agentId, 'Title', 'some knowledge content')
135
+ a.run(agentId, { input: 'summarize the data', stream: true })
113
136
  ```
114
137
 
115
- ### aiStream [E]
138
+ | Option | Type | Default | Description |
139
+ |--------|------|---------|-------------|
140
+ | `pg` | `object` | — | PostgreSQL client |
141
+ | `model` | `object` | — | AI model (e.g. `openai('gpt-4o')`) |
142
+ | `embeddingModel` | `object` | — | Embedding model for knowledge search |
143
+ | `embeddingDimension` | `number` | `1536` | Embedding vector dimension |
144
+ | `tools` | `object[]` | — | Custom tool definitions |
145
+
146
+ | Method | Description |
147
+ |--------|-------------|
148
+ | `.run(agentId, { input, stream?, messages? })` | Execute agent with input |
149
+ | `.addKnowledge(agentId, title, content)` | Add knowledge document |
150
+ | `.migrate()` | DB setup |
151
+ | `.close()` | Cleanup |
152
+
153
+ ### aiStream [β]
154
+
155
+ Creates an AI streaming chat endpoint using the Vercel AI SDK.
116
156
 
117
157
  ```ts
118
158
  const chat = await aiStream(async (req) => ({ model: openai('gpt-4o'), messages: (await req.json()).messages }))
119
- app.use('/chat', chat.router().handler())
159
+ app.use('/chat', chat)
120
160
  ```
121
161
 
122
- ### analytics [C]
162
+ | Param | Type | Description |
163
+ |-------|------|-------------|
164
+ | `handler` | `(req, ctx) => AIStreamOptions \| Promise<AIStreamOptions>` | Returns AI SDK options (model, messages, schema, etc.) |
165
+
166
+ ### analytics [β]
123
167
 
124
168
  In-memory or PostgreSQL page view tracking with built-in dashboard.
125
169
 
126
170
  ```ts
127
171
  const a = analytics()
128
172
  app.use(a.middleware())
129
- app.use('/', a.router()) // GET /__analytics (dashboard), GET /__analytics/data?days=7 (JSON)
173
+ app.use('/', a) // GET /__analytics (dashboard), GET /__analytics/data?days=7 (JSON)
130
174
  ```
131
175
 
132
176
  | Option | Type | Default | Description |
@@ -139,26 +183,38 @@ app.use('/', a.router()) // GET /__analytics (dashboard), GET /__analytics
139
183
  const a = analytics({ pg })
140
184
  await a.migrate()
141
185
  app.use(a.middleware())
142
- app.use('/', a.router())
186
+ app.use('/', a) // dashboard routes
143
187
  ```
144
188
 
145
- ### auth [A]
189
+ ### auth [α]
146
190
 
147
191
  ```ts
148
192
  app.use(auth({ token: 'sk-123' })) // static token
149
193
  app.use(auth({ header: 'X-API-Key', token: 'my-key' })) // custom header
150
- app.use(auth({ verify: async (token) => ({ sub: 'abc' }) })) // custom verify
194
+ app.use(auth({ verify: async (token, req) => ({ sub: 'abc' }) })) // custom verify → sets ctx.user
151
195
  app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
152
196
  ```
153
197
 
154
- ### compress [A]
198
+ | Option | Type | Default | Description |
199
+ |--------|------|---------|-------------|
200
+ | `token` | `string` | — | Static token to match |
201
+ | `header` | `string` | `'Authorization'` | Header name |
202
+ | `verify` | `(token, req) => object\|null` | — | Verify function, return value sets `ctx.user` |
203
+ | `proxy` | `string` | — | Auth service URL to proxy requests to |
204
+
205
+ ### compress [α]
155
206
 
156
207
  ```ts
157
- app.use(compress()) // brotli > gzip > deflate
158
- app.use(compress({ threshold: 2048 })) // only > 2KB
208
+ app.use(compress()) // brotli > gzip > deflate (min 1KB)
209
+ app.use(compress({ threshold: 2048, level: 4 })) // custom threshold and level
159
210
  ```
160
211
 
161
- ### cors [A]
212
+ | Option | Type | Default | Description |
213
+ |--------|------|---------|-------------|
214
+ | `threshold` | `number` | `1024` | Minimum byte size to compress |
215
+ | `level` | `number` | `6` | Compression level (zlib) |
216
+
217
+ ### cors [α]
162
218
 
163
219
  ```ts
164
220
  app.use(cors()) // allow all
@@ -167,18 +223,28 @@ app.use(cors({ origin: (o) => o.endsWith('.trusted.com') && o }))
167
223
  app.use(cors({ credentials: true, maxAge: 3600 }))
168
224
  ```
169
225
 
170
- ### csrf [A]
226
+ | Option | Type | Default | Description |
227
+ |--------|------|---------|-------------|
228
+ | `origin` | `string\|string[]\|function` | `'*'` | Allowed origins |
229
+ | `methods` | `string[]` | `['GET','POST','PUT','DELETE','PATCH','HEAD','OPTIONS']` | Allowed methods |
230
+ | `allowedHeaders` | `string[]` | — | Custom allowed headers |
231
+ | `exposedHeaders` | `string[]` | — | Response headers exposed to client |
232
+ | `credentials` | `boolean` | `false` | Allow cookies/credentials |
233
+ | `maxAge` | `number` | — | Preflight cache duration (seconds) |
234
+
235
+ ### csrf [α]
171
236
 
172
237
  ```ts
173
238
  app.use(csrf())
174
- // ctx.csrfToken available in handlers
175
- // Auto-validates X-CSRF-Token header on POST/PUT/DELETE/PATCH
239
+ // ctx.csrfToken set on GET/HEAD/OPTIONS
240
+ // Auto-validates x-csrf-token or x-xsrf-token header on POST/PUT/DELETE/PATCH
241
+ // Falls back to body field matching the key name
176
242
  ```
177
243
 
178
244
  | Option | Default | Description |
179
245
  |--------|---------|-------------|
180
246
  | `cookie` | `'_csrf'` | Cookie name |
181
- | `header` | `'x-csrf-token'` | Header name |
247
+ | `header` | `'x-csrf-token'` | Header name (also accepts `x-xsrf-token`) |
182
248
  | `key` | `'_csrf'` | Body field fallback |
183
249
  | `excludeMethods` | `['GET','HEAD','OPTIONS']` | Skip validation |
184
250
 
@@ -186,31 +252,59 @@ app.use(csrf())
186
252
 
187
253
  ```ts
188
254
  import { deploy, defineConfig } from 'weifuwu'
189
- const config = defineConfig({ apps: [{ name: 'api', dir: './api', domain: 'api.example.com', port: 3001 }] })
190
- await deploy(config)
255
+ const config = defineConfig({
256
+ domain: 'example.com',
257
+ apps: {
258
+ api: { repo: 'git@github.com:user/api.git', entry: 'app.ts', port: 3001, subdomain: 'api' },
259
+ },
260
+ })
261
+ const server = await deploy(config)
262
+ // server.close(), server.ready, server.url
263
+ // server.apps.list(), server.apps.status(name), server.apps.deploy(name)
191
264
  ```
192
265
 
193
- ### health [D]
266
+ ### health [β]
194
267
 
195
268
  ```ts
196
- app.use(health()) // GET /health → 200
197
- app.use(health({ checks: { db: async () => { await pg.sql`SELECT 1`; return { ok: true } } } }))
269
+ app.use('/health', health())
270
+ // Returns 200 on success, 503 when check throws
198
271
  ```
199
272
 
200
- ### helmet [A]
273
+ | Option | Type | Default | Description |
274
+ |--------|------|---------|-------------|
275
+ | `path` | `string` | `'/health'` | Health check endpoint |
276
+ | `check` | `() => Promise<void>` | — | Async function; throws → 503 |
201
277
 
202
- 13 security headers: CSP, HSTS, X-Frame-Options, X-Content-Type-Options, etc.
278
+ ### helmet [α]
279
+
280
+ 15 security headers: CSP, HSTS, X-Frame-Options, X-Content-Type-Options, etc.
203
281
 
204
282
  ```ts
205
283
  app.use(helmet())
206
284
  app.use(helmet({ contentSecurityPolicy: "default-src 'self'", xFrameOptions: 'DENY' }))
207
285
  ```
208
286
 
209
- ### iii [C] Worker / Function / Trigger
287
+ | Option | Default | Description |
288
+ |--------|---------|-------------|
289
+ | `contentSecurityPolicy` | `"default-src 'self'"` | CSP policy |
290
+ | `xFrameOptions` | `'SAMEORIGIN'` | Frame-embedding policy |
291
+ | `strictTransportSecurity` | `'max-age=15552000; includeSubDomains'` | HSTS |
292
+ | `referrerPolicy` | `'no-referrer'` | Referrer header |
293
+ | `xContentTypeOptions` | `'nosniff'` | MIME sniffing protection |
294
+ | `permissionsPolicy` | — | Feature permissions policy |
295
+ | `crossOriginEmbedderPolicy` | — | COEP header |
296
+ | `crossOriginOpenerPolicy` | — | COOP header |
297
+ | `crossOriginResourcePolicy` | — | CORP header |
298
+
299
+ ### iii [β] — Worker / Function / Trigger
300
+
301
+ Distributed function execution with WebSocket workers, triggers, and Redis streams.
210
302
 
211
303
  ```ts
304
+ import { createWorker } from 'weifuwu'
212
305
  const engine = iii({ pg, redis })
213
- app.use('/iii', engine.router())
306
+ app.use('/iii', engine)
307
+ app.ws('/iii', engine.wsHandler())
214
308
 
215
309
  const w = createWorker('orders')
216
310
  w.registerFunction('orders::create', async (payload) => db.query('INSERT INTO orders ...', [payload.items]))
@@ -218,73 +312,138 @@ engine.addWorker(w)
218
312
  await engine.trigger({ function_id: 'orders::create', payload: { items: ['apple'] } })
219
313
  ```
220
314
 
315
+ | Option | Type | Default | Description |
316
+ |--------|------|---------|-------------|
317
+ | `pg` | `object` | — | PostgreSQL client for persistent triggers |
318
+ | `redis` | `object` | — | Redis client for streams |
319
+ | `streamTTL` | `number` | `3600` | Redis stream key TTL (seconds, 0 = no expiry) |
320
+
221
321
  | Method | Description |
222
322
  |--------|-------------|
223
323
  | `.addWorker(w)` | Register a worker |
224
- | `.trigger({ function_id, payload, action? })` | Invoke a function (sync or void) |
225
- | `.router()` | REST + WS API |
324
+ | `.removeWorker(w)` | Remove a worker |
325
+ | `.trigger({ function_id, payload, action?, timeout_ms? })` | Invoke a function |
326
+ | `.listWorkers()` | List registered workers |
327
+ | `.listFunctions()` | List registered functions |
328
+ | `.listTriggers()` | List registered triggers |
329
+ | `.wsHandler()` | WebSocket handler |
330
+ | `.migrate()` | DB setup |
331
+ | `.shutdown()` | Clean shutdown |
226
332
 
227
- ### logdb [C]
333
+ ### logdb [β]
228
334
 
229
335
  PostgreSQL structured event logging with monthly partitioning.
230
336
 
231
337
  ```ts
232
338
  const logger = logdb({ pg })
233
339
  await logger.migrate()
234
- app.use('/logs', logger.router())
340
+ app.use('/logs', logger)
235
341
  await logger.clean(12) // drop partitions older than 12 months
236
- await logger.log({ level: 'info', source: 'app', message: 'hello' })
342
+ await logger.log({ level: 'info', source: 'app', message: 'hello', metadata: { userId: 1 } })
237
343
  ```
238
344
 
345
+ | Option | Type | Default | Description |
346
+ |--------|------|---------|-------------|
347
+ | `pg` | `object` | — | PostgreSQL client |
348
+ | `table` | `string` | `'_log_entries'` | Table name |
349
+
239
350
  | Method | Path | Description |
240
351
  |--------|------|-------------|
241
352
  | POST | `/` | Create log entry |
242
353
  | GET | `/` | Query (`?level=`, `?source=`, `?after=`, `?before=`, `?meta.*=`) |
243
354
  | GET | `/:id` | Get single entry |
244
355
 
245
- ### logger [A]
356
+ ### logger [α]
246
357
 
247
358
  ```ts
248
359
  app.use(logger()) // GET /hello 200 5ms
249
360
  app.use(logger({ format: 'combined' })) // with query params
250
361
  ```
251
362
 
363
+ | Option | Type | Default | Description |
364
+ |--------|------|---------|-------------|
365
+ | `format` | `'short' \| 'combined'` | `'short'` | Log format: path only, or path + query params |
366
+
252
367
  ### mailer
253
368
 
254
369
  ```ts
255
- const mail = mailer({ host: 'smtp.example.com', port: 587, auth: { user, pass } })
256
- await mail.send({ to: 'user@test.com', subject: 'Hello', text: 'Body', html: '<p>Body</p>' })
370
+ const mail = mailer({ from: 'noreply@example.com', transport: 'smtp://user:pass@smtp.example.com:587' })
371
+ await mail.send({ to: 'user@test.com', subject: 'Hello', text: 'Body', html: '<p>Body</p>', cc: 'admin@test.com' })
257
372
  ```
258
373
 
259
- ### messager [C]
374
+ | Option | Type | Default | Description |
375
+ |--------|------|---------|-------------|
376
+ | `transport` | `string\|object` | — | Nodemailer transport config or connection string |
377
+ | `from` | `string` | — | Default sender address |
378
+ | `send` | `function` | — | Custom send function (alternative to transport) |
379
+
380
+ ### messager [β]
260
381
 
261
382
  Real-time chat with channels, WebSocket, agent routing.
262
383
 
263
384
  ```ts
264
385
  const msg = messager({ pg, agents, redis: redis() })
265
386
  await msg.migrate()
266
- app.ws('/ws', u.middleware(), msg.wsHandler())
267
- await msg.send(channelId, 'System message', { sender_type: 'system' })
387
+ app.use('/api', msg)
388
+ app.ws('/ws', msg.wsHandler())
389
+ await msg.send(channelId, 'System message', { sender_type: 'system', sender_id: 'bot' })
268
390
  ```
269
391
 
270
- ### opencode [C]
392
+ | Option | Type | Default | Description |
393
+ |--------|------|---------|-------------|
394
+ | `pg` | `object` | — | PostgreSQL client |
395
+ | `agents` | `AgentModule` | — | Agent module for routing |
396
+ | `webhookTimeout` | `number` | — | Webhook timeout |
397
+ | `redis` | `object` | — | Redis client |
398
+
399
+ | Method | Description |
400
+ |--------|-------------|
401
+ | `.wsHandler()` | WebSocket handler (channels, typing, read receipts) |
402
+ | `.send(channel, content, opts?)` | Send message to channel |
403
+ | `.close()` | Cleanup |
404
+
405
+ ### opencode [β]
271
406
 
272
407
  AI programming assistant.
273
408
 
274
409
  ```ts
275
- const oc = await opencode({ pg, permissions: { bash: { allow: true }, write: { allow: false } } })
410
+ const oc = await opencode({
411
+ pg,
412
+ model: openai('gpt-4o'),
413
+ workspace: '/home/user/project',
414
+ permissions: { bash: { allow: true }, write: { allow: false } },
415
+ })
276
416
  await oc.migrate()
277
- app.use('/opencode', await oc.router())
417
+ app.use('/opencode', oc)
278
418
  app.ws('/opencode', oc.wsHandler())
279
419
  ```
280
420
 
281
- ### postgres [B]
421
+ | Option | Type | Default | Description |
422
+ |--------|------|---------|-------------|
423
+ | `pg` | `object` | — | PostgreSQL client |
424
+ | `model` | `string` | — | AI model name (e.g. `'gpt-4o'`, `'deepseek-v4-flash'`) |
425
+ | `baseURL` | `string` | — | OpenAI-compatible API base URL |
426
+ | `apiKey` | `string` | — | API key for the model |
427
+ | `workspace` | `string` | — | Project directory |
428
+ | `systemPrompt` | `string` | — | Custom system prompt |
429
+ | `skills` | `object[]` | — | Custom skill definitions |
430
+ | `permissions` | `object` | — | Tool permission rules |
431
+
432
+ ### postgres [α]
282
433
 
283
434
  ```ts
284
435
  const pg = postgres() // reads DATABASE_URL
285
436
  app.use(pg) // injects ctx.sql
286
437
  ```
287
438
 
439
+ | Option | Type | Default | Description |
440
+ |--------|------|---------|-------------|
441
+ | `connection` | `string` | `DATABASE_URL` env | PostgreSQL connection string |
442
+ | `max` | `number` | `10` | Max pool connections |
443
+ | `ssl` | `boolean\|object` | — | SSL options |
444
+ | `idle_timeout` | `number` | `30` | Idle timeout (seconds) |
445
+ | `connect_timeout` | `number` | `30` | Connection timeout |
446
+
288
447
  ```ts
289
448
  // Type-safe DDL
290
449
  const users = pgTable('_users', { id: serial('id').primaryKey(), name: text('name').notNull(), email: text('email').unique().notNull(), active: boolean('active').default(true), ...timestamps() })
@@ -301,13 +460,14 @@ await pg.transaction(async (tx) => { const users = pg.table('_users', { ... }).w
301
460
 
302
461
  Where helpers: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `isNull`, `isNotNull`, `like`, `contains`, `in_`, `and`, `or`, `not`.
303
462
 
304
- ### preferences [A]
463
+ ### preferences [α]
305
464
 
306
465
  Locale detection + theme + translations. `/__lang/:locale` and `/__theme/:theme` auto-routed.
307
466
 
308
467
  ```ts
309
468
  app.use(preferences({ dir: './locales', locale: { default: 'en' }, theme: { default: 'system' } }))
310
469
  // ctx.prefs.locale, ctx.prefs.theme, ctx.t('key'), ctx.setPref('locale', 'zh')
470
+ // ctx.setPref() returns a 302 Response with Set-Cookie — return it from your handler
311
471
  // GET /__lang/zh → 302 + Set-Cookie (or JSON if Accept: application/json)
312
472
  // GET /__theme/dark → same pattern
313
473
  ```
@@ -331,48 +491,92 @@ const { theme, resolvedTheme, setTheme } = useTheme()
331
491
  // resolvedTheme resolves 'system' → 'dark'|'light' based on prefers-color-scheme
332
492
  ```
333
493
 
334
- ### queue [B]
494
+ ### queue [α]
335
495
 
336
496
  ```ts
337
497
  const q = queue({ redis })
498
+ app.use(q) // injects ctx.queue
338
499
  await q.add('send-email', { to: 'user@test.com' }, { cron: '0 8 * * *' })
339
500
  ```
340
501
 
341
- ### rateLimit [B]
502
+ | Option | Type | Default | Description |
503
+ |--------|------|---------|-------------|
504
+ | `redis` | `object` | — | Redis client |
505
+ | `url` | `string` | — | Redis URL (alternative to client) |
506
+ | `prefix` | `string` | `'queue:'` | Redis key prefix |
507
+ | `pollInterval` | `number` | `1000` | Poll interval (ms) |
508
+
509
+ | Method | Description |
510
+ |--------|-------------|
511
+ | `.add(name, data, opts?)` | Add job to queue |
512
+ | `.process(handler)` | Register job processor |
513
+ | `.run()` | Start processing |
514
+ | `.stop()` | Stop processing |
515
+ | `.close()` | Cleanup |
516
+
517
+ ### rateLimit [α]
342
518
 
343
519
  ```ts
344
520
  app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min
345
521
  app.get('/api', rateLimit({ max: 10 }), handler) // per-route
346
522
  app.use(rateLimit({ key: (req) => req.headers.get('x-api-key') ?? 'anonymous' }))
523
+ // Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After headers
347
524
  // m.stop() — clear interval
348
525
  ```
349
526
 
350
- ### redis [B]
527
+ | Option | Type | Default | Description |
528
+ |--------|------|---------|-------------|
529
+ | `max` | `number` | `100` | Max requests per window |
530
+ | `window` | `number` | `60_000` | Window duration (ms) |
531
+ | `key` | `(req) => string` | IP-based | Key function |
532
+ | `message` | `string` | `'Too Many Requests'` | 429 response body |
533
+
534
+ ### redis [α]
351
535
 
352
536
  ```ts
353
- const r = redis() // reads REDIS_URL
354
- app.use(r) // injects ctx.redis
537
+ const r = redis() // reads REDIS_URL
538
+ app.use(r) // injects ctx.redis
355
539
  await ctx.redis.set('key', 'value')
356
540
  // r.close() — cleanup
357
541
  ```
358
542
 
359
- ### requestId [A]
543
+ | Option | Type | Default | Description |
544
+ |--------|------|---------|-------------|
545
+ | `url` | `string` | `REDIS_URL` env | Redis connection string |
546
+ | (all ioredis options) | — | — | Passed directly to ioredis |
547
+
548
+ ### requestId [α]
360
549
 
361
550
  ```ts
362
551
  app.use(requestId())
552
+ app.use(requestId({ header: 'X-Request-Id', generator: () => crypto.randomUUID() }))
363
553
  // Sets X-Request-ID header on responses, available as ctx.requestId
364
554
  ```
365
555
 
366
- ### seo [D] + seoMiddleware [A]
556
+ | Option | Type | Default | Description |
557
+ |--------|------|---------|-------------|
558
+ | `header` | `string` | `'X-Request-ID'` | Header name to read/write |
559
+ | `generator` | `() => string` | `crypto.randomUUID()` | ID generator |
560
+
561
+ ### seo [β] + seoMiddleware [α]
367
562
 
368
563
  ```ts
369
- app.use(seo({ baseUrl: 'https://example.com', robots: [{ userAgent: '*', allow: '/' }], sitemap: { urls: [{ loc: '/' }] } }))
564
+ app.use('/', seo({ baseUrl: 'https://example.com', robots: [{ userAgent: '*', allow: '/' }], sitemap: { urls: [{ loc: '/' }] } }))
370
565
  // GET /robots.txt, GET /sitemap.xml
371
566
 
372
567
  app.use(seoMiddleware({ headers: { 'X-Robots-Tag': (path) => path.startsWith('/admin') ? 'noindex' : undefined } }))
373
568
  ```
374
569
 
375
- ### tenant [C]
570
+ Also exports `seoTags(config)` for generating meta/og/twitter tags as an HTML string.
571
+
572
+ | Option | Type | Default | Description |
573
+ |--------|------|---------|-------------|
574
+ | `baseUrl` | `string` | — | Base URL for sitemap URLs |
575
+ | `robots` | `RobotsRule[]` | `[{ userAgent: '*', allow: '/' }]` | Robots.txt rules |
576
+ | `sitemap` | `SitemapConfig` | — | Sitemap configuration (urls, resolve, cacheTTL) |
577
+ | `headers` | `SeoHeadersConfig` | — | Response headers (e.g. `X-Robots-Tag`) |
578
+
579
+ ### tenant [β]
376
580
 
377
581
  Multi-tenant BaaS with dynamic table API and GraphQL.
378
582
 
@@ -380,36 +584,69 @@ Multi-tenant BaaS with dynamic table API and GraphQL.
380
584
  const t = tenant({ pg, usersTable: '_users' })
381
585
  await t.migrate()
382
586
  app.use('/api', t.middleware()) // → ctx.tenant
383
- app.use('/api', t.router()) // dynamic CRUD
587
+ app.use('/api', t) // dynamic CRUD
384
588
  app.use('/graphql', t.graphql()) // dynamic GraphQL
385
589
  ```
386
590
 
387
- ### upload [A]
591
+ | Option | Type | Default | Description |
592
+ |--------|------|---------|-------------|
593
+ | `pg` | `object` | — | PostgreSQL client |
594
+ | `usersTable` | `string` | — | Users table name for tenant membership lookup |
595
+
596
+ ### upload [α]
388
597
 
389
598
  ```ts
390
- app.post('/upload', upload({ dir: './uploads', maxFileSize: 10_485_760 }), (req, ctx) => {
391
- // ctx.parsed.files.avatar → { name, type, size, path }
599
+ app.post('/upload', upload({ dir: './uploads', maxFileSize: 10_485_760, allowedTypes: ['image/jpeg', 'image/png'] }), (req, ctx) => {
600
+ // ctx.parsed.files.avatar → { name, type, size, path } or { name, type, size, buffer } (when no dir)
601
+ // Multiple files with same field name → array
392
602
  // ctx.parsed.fields.title → 'hello'
393
603
  })
394
604
  ```
395
605
 
396
- ### user [C]
606
+ | Option | Type | Default | Description |
607
+ |--------|------|---------|-------------|
608
+ | `dir` | `string` | — | Write files to disk (omit for in-memory) |
609
+ | `maxFileSize` | `number` | — | Max bytes per file |
610
+ | `allowedTypes` | `string[]` | — | Allowed MIME types |
611
+
612
+ ### user [β]
613
+
614
+ Authentication: register, login, JWT, OAuth2.
397
615
 
398
616
  ```ts
399
617
  const auth = user({ pg, jwtSecret: process.env.JWT_SECRET! })
400
618
  await auth.migrate()
401
- app.use('/auth', auth.router()) // POST /register, POST /login
619
+ app.use('/auth', auth) // POST /register, POST /login, OAuth2 routes
402
620
  app.get('/me', auth.middleware(), (req, ctx) => Response.json(ctx.user))
403
621
  ```
404
622
 
405
- ### validate [A]
623
+ | Option | Type | Default | Description |
624
+ |--------|------|---------|-------------|
625
+ | `pg` | `object` | — | PostgreSQL client |
626
+ | `jwtSecret` | `string` | — | JWT signing secret |
627
+ | `table` | `string` | `'_users'` | Users table name |
628
+ | `expiresIn` | `string` | `'7d'` | JWT expiration |
629
+ | `oauth2` | `object` | — | OAuth2 client config (PKCE flow) |
630
+
631
+ | Method | Description |
632
+ |--------|-------------|
633
+ | `.register(data)` | Register a new user programmatically |
634
+ | `.login(data)` | Log in programmatically |
635
+ | `.verify(token)` | Verify JWT token |
636
+ | `.middleware()` | JWT verify middleware — sets `ctx.user` |
637
+
638
+ ### validate [α]
406
639
 
407
640
  ```ts
408
641
  import { z } from 'zod'
409
642
  const CreateUser = z.object({ name: z.string().min(1), email: z.string().email() })
410
- app.post('/users', validate({ body: CreateUser }), (req, ctx) => {
643
+ app.post('/users', validate({ body: CreateUser, query: z.object({ ref: z.string().optional() }) }), (req, ctx) => {
411
644
  // ctx.parsed.body — typed & validated
645
+ // ctx.parsed.query — typed & validated
646
+ // ctx.parsed.params — typed & validated (for dynamic routes)
647
+ // ctx.parsed.headers — typed & validated
412
648
  })
649
+ // Validation failure: returns 400 with { error: 'Validation failed', issues: [...] }
413
650
  ```
414
651
 
415
652
  ---
@@ -481,17 +718,27 @@ const loading = useNavigating() // reactive loading state
481
718
 
482
719
  ```tsx
483
720
  import { useWebsocket, useAction, useFetch, useQueryState, createStore, Head } from 'weifuwu/react'
484
- import { useLocale, useTheme, applyTheme, addInterceptor, useLoaderData } from 'weifuwu/react'
721
+ import { useLocale, useTheme, applyTheme, addInterceptor, useLoaderData, useFlashMessage } from 'weifuwu/react'
485
722
 
486
723
  // WebSocket — auto-reconnecting
487
- const { send, lastMessage, readyState } = useWebsocket('/ws/chat', { onMessage: (d) => console.log(d), reconnect: { maxRetries: 10, delay: 3000 } })
724
+ const { send, lastMessage, readyState, close, reconnect } = useWebsocket('/ws/chat', {
725
+ onMessage: (d) => console.log(d),
726
+ reconnect: { maxRetries: 10, delay: 3000 },
727
+ protocols: [], // optional sub-protocols
728
+ enabled: true, // pause/resume connection
729
+ })
488
730
 
489
731
  // Form action
490
- const { submit, data, error, pending } = useAction('/api/feedback', { method: 'POST' })
491
- // Auto-reads _csrf cookie, sends as X-CSRF-Token
732
+ const { submit, data, error, pending, reset } = useAction('/api/feedback', {
733
+ method: 'POST',
734
+ headers: { 'X-Custom': 'value' },
735
+ onSuccess: (data) => console.log(data),
736
+ onError: (err) => console.error(err),
737
+ })
738
+ // Auto-reads _csrf cookie, sends as x-csrf-token or x-xsrf-token
492
739
 
493
740
  // Data fetching — cache + dedup + mutate
494
- const { data, error, loading, mutate } = useFetch('/api/posts', { fallback: loadData })
741
+ const { data, error, loading, mutate } = useFetch('/api/posts', { fallback: loadData, ttl: 30_000 })
495
742
 
496
743
  // URL query state
497
744
  const [q, setQ] = useQueryState('q', '')
@@ -503,9 +750,10 @@ const count = useStore(s => s.count)
503
750
 
504
751
  // Per-page meta tags
505
752
  <Head><title>Page Title</title><meta name="description" content="..." /></Head>
506
-
507
753
  ```
508
754
 
755
+ **`TsxContext`** — React context holding page data (`params`, `query`, `user`, `parsed`, `prefs`, `env`). Used internally by hooks; rarely needed directly.
756
+
509
757
  ### Locale & Theme
510
758
 
511
759
  ```tsx
@@ -520,7 +768,7 @@ function LangSwitch() {
520
768
  |--------|-------------|
521
769
  | `locale` | Current locale string (from `ctx.prefs.locale`) |
522
770
  | `setLocale(locale)` | Switch locale (calls `navigate('/__lang/' + locale)`) |
523
- | `t` | Translation function (same as `useCtx().t`) |
771
+ | `t` | Translate a key using loaded locale messages |
524
772
 
525
773
  ```tsx
526
774
  import { useTheme } from 'weifuwu/react'
@@ -571,11 +819,19 @@ addInterceptor(async (url) => {
571
819
  ### Flash messages
572
820
 
573
821
  ```ts
574
- // Server
822
+ // Server — set flash cookie on redirect, auto-cleared after first read
575
823
  return ctx.setPref('flash', JSON.stringify({ type: 'success', message: 'Done' })) // 302 + Set-Cookie
824
+ ```
825
+
826
+ ```tsx
827
+ // Client
828
+ import { useFlashMessage } from 'weifuwu/react'
576
829
 
577
- // Client (tsx)
578
- function Toast() { const { prefs } = useCtx(); const flash = prefs?.flash ? JSON.parse(prefs.flash) : null; ... }
830
+ function Toast() {
831
+ const flash = useFlashMessage<{ type: string; message: string }>()
832
+ if (!flash) return null
833
+ return <div className={`toast toast-${flash.type}`}>{flash.message}</div>
834
+ }
579
835
  ```
580
836
 
581
837
  ### Dev mode
@@ -591,21 +847,7 @@ import { openai, streamText, generateText, streamObject, generateObject, tool, e
591
847
  import { runWorkflow } from 'weifuwu'
592
848
  ```
593
849
 
594
- ### Streaming
595
-
596
- ```ts
597
- const chat = await aiStream(async (req) => ({ model: openai('gpt-4o'), messages: (await req.json()).messages }))
598
- app.use('/chat', chat.router().handler())
599
- ```
600
-
601
- ### Agents
602
-
603
- ```ts
604
- const agents = agent({ pg })
605
- await agents.migrate()
606
- app.use('/api', agents.router())
607
- await agents.addKnowledge(agentId, 'Title', 'content')
608
- ```
850
+ For AI streaming endpoints see [`aiStream`](#aistream-β). For AI agent APIs see [`agent`](#agent-β).
609
851
 
610
852
  ### DAG Workflow
611
853