weifuwu 0.19.10 → 0.22.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
@@ -25,6 +25,17 @@ serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
25
25
  npx weifuwu init my-app && cd my-app && npm run dev
26
26
  ```
27
27
 
28
+ ## CLI
29
+
30
+ ```bash
31
+ npx weifuwu init my-app # Full project (SSR + i18n + theme + WS demo)
32
+ npx weifuwu init my-api --minimal # Minimal HTTP project (2 files)
33
+ npx weifuwu init my-api --skip-install # Skip npm install
34
+ npx weifuwu dev # Start dev server (auto-detect index.ts)
35
+ npx weifuwu generate module my-mod # Scaffold middleware module + test
36
+ npx weifuwu version # Print version
37
+ ```
38
+
28
39
  ---
29
40
 
30
41
  ## Core Concepts
@@ -42,11 +53,14 @@ await server.ready
42
53
  | `hostname` | `string` | `'0.0.0.0'` | Listen address |
43
54
  | `signal` | `AbortSignal` | — | Shutdown on abort |
44
55
  | `websocket` | `WsUpgradeHandler` | — | WebSocket upgrade handler |
45
- | `maxBodySize` | `number` | | Max body bytes |
56
+ | `maxBodySize` | `number` | `10MB` | Max body bytes (0 = unlimited) |
57
+ | `timeout` | `number` | `30_000` | Socket inactivity timeout (ms) |
58
+ | `keepAliveTimeout` | `number` | `5_000` | Keep-Alive idle timeout (ms) |
59
+ | `headersTimeout` | `number` | `6_000` | Headers read timeout (ms) |
46
60
  | `shutdown` | `boolean` | `true` | Auto SIGTERM/SIGINT |
47
61
 
48
62
  ```ts
49
- interface Server { stop: () => void; readonly port: number; readonly hostname: string; ready: Promise<void> }
63
+ interface Server { stop: () => Promise<void>; readonly port: number; readonly hostname: string; ready: Promise<void> }
50
64
  const { server, url } = await createTestServer(handler)
51
65
  ```
52
66
 
@@ -58,9 +72,24 @@ app.get('/hello/:name', (req, ctx) => Response.json({ message: `Hello, ${ctx.par
58
72
  app.post('/data', async (req, ctx) => { const body = await req.json(); return Response.json(body, { status: 201 }) })
59
73
  app.use('/admin', authMW) // path-scoped middleware
60
74
  app.use('/admin', adminRouter) // sub-router (flattened into parent trie)
61
- app.ws('/echo', { open(ws) { ws.send('connected') }, message(ws, _ctx, data) { ws.send(`echo: ${data}`) } })
75
+ app.ws('/echo', {
76
+ open(ws, ctx) { ctx.ws.json({ type: 'connected' }) },
77
+ message(ws, ctx, data) { ctx.ws.json({ echo: data.toString() }) },
78
+ })
79
+ app.ws('/chat', {
80
+ open(ws, ctx) { ctx.ws.join('room') },
81
+ message(ws, ctx, data) { ctx.ws.sendRoom('room', JSON.parse(data.toString())) },
82
+ })
62
83
  app.onError((err, req, ctx) => Response.json({ error: err.message }, { status: 500 }))
63
84
 
85
+ // Debug: list all registered routes
86
+ console.log(app.routes())
87
+ // [ 'GET /hello/:name', 'POST /data', 'WS /echo', 'WS /chat' ]
88
+
89
+ // Cross-process WebSocket broadcast (Redis)
90
+ import { createHub } from 'weifuwu'
91
+ app.wsHub(createHub({ redis: redis() }))
92
+
64
93
  const handler = app.handler()
65
94
  const wsHandler = app.websocketHandler()
66
95
  serve(handler, { port: 3000, websocket: wsHandler })
@@ -112,6 +141,8 @@ The `ctx` object accumulates properties as it passes through the middleware chai
112
141
  | `env` | `loadEnv()` | `Record<string, string>` | Public env vars (`WEIFUWU_PUBLIC_*`) |
113
142
  | `csrfToken` | `csrf()` | `string` | CSRF token |
114
143
  | `requestId` | `requestId()` | `string` | Request ID |
144
+ | `session` | `session()` | `Session` | Session data object |
145
+ | `sessionId` | `session()` | `string` | Session ID |
115
146
  | `sql` | `postgres()` | `Sql<{}>` | PostgreSQL tagged-template client |
116
147
  | `redis` | `redis()` | `Redis` | Redis client |
117
148
  | `queue` | `queue()` | `Queue` | Job queue |
@@ -125,6 +156,27 @@ The `ctx` object accumulates properties as it passes through the middleware chai
125
156
  | `setPref` | `preferences()` | `(key, val) => Response` | Set preference cookie + redirect |
126
157
  | `compiledTailwindCss` | `ssr()` internal | `string` | Compiled CSS content (internal) |
127
158
  | `tailwindCssUrl` | `ssr()` internal | `string` | Compiled CSS route URL (internal) |
159
+ | `session` | `session()` | `Session` | Session data object |
160
+ | `sessionId` | `session()` | `string` | Session ID |
161
+
162
+ ### Type-Safe Context
163
+
164
+ Middleware-injected properties are **automatically typed** through chained `use()` calls:
165
+
166
+ ```ts
167
+ const app = new Router()
168
+ .use(csrf()) // → Router<Context & { csrfToken: string }>
169
+ .use(requestId()) // → Router<Context & { csrfToken, requestId }>
170
+ .use(postgres()) // → Router<Context & { csrfToken, requestId, sql }>
171
+
172
+ app.get('/me', (_req, ctx) => {
173
+ ctx.csrfToken // ✅ string (IDE autocomplete)
174
+ ctx.requestId // ✅ string
175
+ ctx.sql`SELECT 1` // ✅ Sql<{}>
176
+ })
177
+ ```
178
+
179
+ Each module exports an `XxxInjected` type (e.g. `PostgresInjected`, `UserInjected`) for composing custom context types. `Context` is an interface — modules augment it via `declare module` for ambient compatibility.
128
180
 
129
181
  ---
130
182
 
@@ -165,6 +217,123 @@ app.use('/', a) // dashboard
165
217
 
166
218
  ---
167
219
 
220
+ ## Request Tracing & Logging
221
+
222
+ Every request gets a **trace ID** via `AsyncLocalStorage`, injected into responses as `X-Trace-Id`. W3C `traceparent` headers are forwarded.
223
+
224
+ ```ts
225
+ import { currentTraceId } from 'weifuwu'
226
+
227
+ app.get('/api', (req, ctx) => {
228
+ console.log('Handling request', currentTraceId()) // f240a3f3-60e2-...
229
+ })
230
+ ```
231
+
232
+ **Structured logging** — `logger({ format: 'json' })` outputs JSON to stderr with `traceId`, `timestamp`, `elapsed_ms`:
233
+
234
+ ```json
235
+ {"level":"info","message":"request","method":"GET","path":"/api/users","status":200,"elapsed_ms":42,"traceId":"f240a3f3-...","timestamp":"2025-01-15T10:30:00.000Z"}
236
+ ```
237
+
238
+ Default format is `'short'` (human-readable). `'combined'` includes query strings.
239
+
240
+ ---
241
+
242
+ ## AI Observability
243
+
244
+ Agent runs are **automatically logged** to `_agent_runs`. Dashboard endpoints provide analytics:
245
+
246
+ ```
247
+ GET /agents/:id/runs?days=7 → [{ input, output, tokens_in, tokens_out, elapsed_ms, status, trace_id, ... }]
248
+ GET /agents/:id/runs/summary?days=7 → { total, success, error, success_rate, tokens_in, tokens_out, avg_elapsed_ms, p95_elapsed_ms }
249
+ GET /opencode/sessions/:id/usage → { message_count, tokens_in, tokens_out, tokens_total }
250
+ ```
251
+
252
+ Non-streaming runs log full token data; streaming runs log `status: 'stream'`.
253
+
254
+ ---
255
+
256
+ ## Agent ↔ Messager Streaming
257
+
258
+ Agent replies in messager channels now stream **token-by-token** via WebSocket:
259
+
260
+ ```ts
261
+ // Backend — automatic when agents are attached to messager
262
+ const msg = messager({ pg, agents: agent({ pg, model }) })
263
+ app.ws('/ws', msg.wsHandler())
264
+ // Agent replies stream to: hub.broadcast({ type: 'agent_stream', data: { token, full } })
265
+ ```
266
+
267
+ ```tsx
268
+ // Frontend — React hook
269
+ import { useAgentStream } from 'weifuwu/react'
270
+
271
+ const { getAgentText, isAgentStreaming, stream } = useAgentStream({
272
+ wsPath: '/ws',
273
+ channelId: 1,
274
+ })
275
+ ```
276
+
277
+ Multi-round conversation context: the last 10 channel messages are automatically injected into agent calls.
278
+
279
+ ---
280
+
281
+ ## Test Utilities
282
+
283
+ Chainable test helper for HTTP-level testing without starting a server:
284
+
285
+ ```ts
286
+ import { testApp } from 'weifuwu'
287
+
288
+ const app = testApp()
289
+ app.use(postgres({ connection: TEST_DB }))
290
+ app.get('/users/:id', (req, ctx) => Response.json({ id: ctx.params.id, user: ctx.user }))
291
+
292
+ const res = await app
293
+ .getReq('/users/42?name=Alice')
294
+ .withUser({ id: 1 })
295
+ .header('X-Custom', 'val')
296
+ .body({ data: 'test' })
297
+ .send()
298
+
299
+ assert.equal(res.status, 200)
300
+ assert.deepEqual(await res.json(), { id: '42', user: { id: 1 } })
301
+ ```
302
+
303
+ | Method | Description |
304
+ |--------|-------------|
305
+ | `app.getReq(path)` `postReq` `putReq` `patchReq` `deleteReq` | Start building a request |
306
+ | `.withUser(u)` `.withTenant(t)` `.with(ctx)` | Simulate middleware injection |
307
+ | `.header(k,v)` `.body(data)` `.rawBody(str)` | Set request properties |
308
+ | `.send()` → `TestResponse` | Execute and get `{ status, headers, json(), text() }` |
309
+
310
+ ### Database test isolation
311
+
312
+ ```ts
313
+ import { createTestDb, withTestDb } from 'weifuwu'
314
+
315
+ // Isolated schema — each test gets its own schema, destroyed after
316
+ const db = await createTestDb()
317
+ await db.sql`CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)`
318
+ await db.sql`INSERT INTO users (name) VALUES ('Alice')`
319
+ await db.destroy() // DROP SCHEMA ... CASCADE
320
+
321
+ // Transaction rollback — all changes are rolled back after callback
322
+ await withTestDb(async (sql) => {
323
+ await sql`INSERT INTO users ...`
324
+ // Automatically rolled back
325
+ })
326
+ ```
327
+
328
+ | Function | Description |
329
+ |----------|-------------|
330
+ | `createTestDb(opts?)` | Create isolated schema, returns `{ sql, url, schema, destroy }` |
331
+ | `withTestDb(url?, fn)` | Run callback in a transaction, auto-rollback |
332
+
333
+ Uses `TEST_DATABASE_URL` or `DATABASE_URL`. Automatically skipped in CI if unset.
334
+
335
+ ---
336
+
168
337
  ## Module Reference
169
338
 
170
339
  ### agent [β]
@@ -274,6 +443,46 @@ app.use(cors({ credentials: true, maxAge: 3600 }))
274
443
  | `credentials` | `boolean` | `false` | Allow cookies/credentials |
275
444
  | `maxAge` | `number` | — | Preflight cache duration (seconds) |
276
445
 
446
+ ### cache [α]
447
+
448
+ Response caching middleware with memory and Redis stores. Caches GET/HEAD responses, with tag-based invalidation.
449
+
450
+ ```ts
451
+ app.use(cache()) // in-memory, 5min TTL
452
+ app.use(cache({ ttl: 60_000, store: 'redis', redis: ctx.redis })) // Redis store
453
+ app.use(cache({
454
+ ttl: 30_000,
455
+ tag: (req, ctx) => ctx.user ? `user:${ctx.user.id}` : undefined, // per-user invalidation
456
+ }))
457
+
458
+ // Programmatic invalidation
459
+ const c = cache({ store: 'redis', redis: ctx.redis })
460
+ app.use(c)
461
+ await c.invalidate('users') // invalidate all entries tagged with 'users'
462
+ await c.flush() // clear entire cache
463
+ ```
464
+
465
+ | Option | Type | Default | Description |
466
+ |--------|------|---------|-------------|
467
+ | `ttl` | `number` | `300000` (5min) | Cache TTL in ms |
468
+ | `store` | `'memory' \| 'redis' \| CacheStore` | `'memory'` | Cache store backend |
469
+ | `redis` | `Redis` | — | Redis client (required when `store: 'redis'`) |
470
+ | `key` | `(req) => string` | SHA256(method+URL) | Custom cache key |
471
+ | `tag` | `(req, ctx) => string \| string[]` | — | Tag for grouped invalidation |
472
+ | `cacheCookies` | `boolean` | `false` | Cache responses with Set-Cookie |
473
+ | `cacheStatus` | `number[]` | `[200]` | Status codes to cache |
474
+ | `maxBodySize` | `number` | `1048576` (1MB) | Max body bytes to cache |
475
+
476
+ Cached responses include `X-Cache: HIT` and `Age` headers. Requests with `Authorization` or `Cookie` headers are never cached. Binary content types (image, audio, video) are skipped.
477
+
478
+ ```ts
479
+ import { MemoryCache, RedisCache } from 'weifuwu'
480
+
481
+ const mem = new MemoryCache()
482
+ await mem.set('key', { status: 200, statusText: 'OK', headers: {}, body: '...', createdAt: Date.now(), tags: [] }, 300_000)
483
+ mem.close()
484
+ ```
485
+
277
486
  ### csrf [α]
278
487
 
279
488
  ```ts
@@ -398,6 +607,29 @@ Restart=always
398
607
 
399
608
 
400
609
 
610
+ ### graphql [β]
611
+
612
+ ```ts
613
+ const handler: GraphQLHandler = () => ({
614
+ schema: `type Query { hello: String }`,
615
+ resolvers: { Query: { hello: () => 'world' } },
616
+ graphiql: true, // GET / returns GraphiQL IDE
617
+ maxDepth: 10, // max query nesting (default 10, 0 = disable)
618
+ timeout: 30_000, // execution timeout in ms
619
+ })
620
+ app.use('/graphql', graphql(handler))
621
+ ```
622
+
623
+ | Option | Type | Default | Description |
624
+ |--------|------|---------|-------------|
625
+ | `schema` | `string \| GraphQLSchema` | — | SDL string or pre-built schema |
626
+ | `resolvers` | `object` | — | Resolver map |
627
+ | `rootValue` | `any` | — | Root value for queries |
628
+ | `context` | `(req, ctx) => object` | — | Per-request context factory |
629
+ | `graphiql` | `boolean` | `false` | Serve GraphiQL IDE at GET / |
630
+ | `maxDepth` | `number` | `10` | Max query nesting depth |
631
+ | `timeout` | `number` | `30_000` | Execution timeout (ms) |
632
+
401
633
  ### health [β]
402
634
 
403
635
  ```ts
@@ -584,52 +816,70 @@ app.use(pg) // injects ctx.sql
584
816
  | `ssl` | `boolean\|object` | — | SSL options |
585
817
  | `idle_timeout` | `number` | `30` | Idle timeout (seconds) |
586
818
  | `connect_timeout` | `number` | `30` | Connection timeout |
819
+ | `statementTimeout` | `number` | `30_000` | Per-statement timeout (ms, 0 = disable) |
820
+ | `onQuery` | `(query, ms, rows) => void` | — | Query logging callback |
587
821
 
588
822
  ```ts
589
823
  // Raw SQL via tagged template
590
824
  await pg.sql`SELECT * FROM users WHERE email = ${email}`
591
825
 
592
- // Type-safe DDL
593
- import { pgTable, serial, text, boolean, timestamps } from 'weifuwu'
826
+ // Define a table — one API, sql pre-bound
827
+ import { serial, text, boolean, timestamps } from 'weifuwu'
594
828
 
595
- const users = pgTable('_users', {
829
+ const users = pg.table('_users', {
596
830
  id: serial('id').primaryKey(),
597
831
  name: text('name').notNull(),
598
832
  email: text('email').unique().notNull(),
599
833
  active: boolean('active').default(true),
600
834
  ...timestamps(),
601
835
  })
602
- await users.create(pg.sql)
603
- await users.createIndex(pg.sql, 'email')
604
-
605
- // BoundTable — sql bound once, no need to pass sql to every call
606
- const t = pg.table('_users', { id: serial('id'), name: text('name'), email: text('email'), ...timestamps() })
607
- await t.insert({ name: 'Alice' })
608
- // vs unbound Table sql passed as first argument each time
609
- // const t = pgTable('_users', { ... })
610
- // await t.insert(pg.sql, { name: 'Alice' })
611
- const { count, data } = await t.readMany({ role: 'admin' }, { orderBy: { name: 'asc' }, limit: 10 })
612
- await t.upsert({ email: 'alice@test.com' }, 'email')
613
-
614
- // Transactions
836
+ await users.create() // DDL — no need to pass sql
837
+ await users.createIndex('email')
838
+
839
+ // CRUD — sql already bound
840
+ await users.insert({ name: 'Alice' })
841
+ const { count, data } = await users.readMany({ role: 'admin' }, { orderBy: { name: 'asc' }, limit: 10 })
842
+ await users.upsert({ email: 'alice@test.com' }, 'email')
843
+
844
+ // Reuse schema without redefining fields
845
+ import { pgTable } from 'weifuwu'
846
+ const usersSchema = pgTable('_users', { id: serial('id'), name: text('name') }) // define once
847
+ const users = pg.table(usersSchema) // bind — no field duplication
848
+
849
+ // Transactions — with auto-retry on deadlock/serialization failure
615
850
  await pg.transaction(async (sql) => {
616
- const users = pg.table('_users', {}).withSql(sql)
617
- return users.insert({ name: 'Bob' })
618
- })
851
+ const txUsers = users.withSql(sql)
852
+ return txUsers.insert({ name: 'Bob' })
853
+ }, { maxRetries: 3 })
619
854
 
620
855
  // Soft delete — automatic if deleted_at column exists
621
- await t.delete(1) // SET deleted_at = NOW()
622
- await t.hardDelete(1) // DELETE FROM
623
- await t.readMany() // WHERE deleted_at IS NULL (use withDeleted: true to include)
856
+ await users.delete(1) // SET deleted_at = NOW()
857
+ await users.hardDelete(1) // DELETE FROM
858
+ await users.read(1) // auto-filters deleted_at IS NULL (use withDeleted: true to include)
624
859
 
625
860
  // JSONB queries
626
861
  const logs = pg.table('logs', { meta: jsonb<{ service: string }>('meta') })
627
862
  await logs.readMany(contains('meta', { service: 'auth' }))
628
863
 
864
+ // Connection pool visibility
865
+ console.log(pg.poolStats()) // { active: 3, idle: 7, waiting: 0, max: 10 }
866
+
867
+ // Migration tracking
868
+ await pg.migrate() // creates _weifuwu_migrations
869
+ await pg.markMigrated('myModule') // idempotent
870
+ const done = await pg.isMigrated('myModule')
871
+
629
872
  // Partitioned tables
630
873
  await logs.create({ partitionBy: partitionBy('range', 'created_at') })
631
874
  ```
632
875
 
876
+ **When to use pgTable vs pg.table:**
877
+ | API | Use when |
878
+ |-----|---------|
879
+ | `pg.table('t', cols)` | You have `pg` available (factory, handler, migrate) |
880
+ | `pg.table(schema)` | Reusing a schema without duplicating field definitions |
881
+ | `pgTable('t', cols)` | No `pg` reference (utility modules, standalone schema files) |
882
+
633
883
  | Column builder | Type | Notes |
634
884
  |---------------|------|-------|
635
885
  | `serial(name)` | `number` | Auto-increment |
@@ -644,27 +894,27 @@ await logs.create({ partitionBy: partitionBy('range', 'created_at') })
644
894
 
645
895
  **Column modifiers:** `.primaryKey()`, `.notNull()`, `.nullable()`, `.default(val)`, `.unique()`, `.references(table, column?, onDelete?)`.
646
896
 
647
- **BoundTable CRUD methods:**
897
+ **CRUD methods:**
648
898
 
649
899
  | Method | Description |
650
900
  |--------|-------------|
651
901
  | `insert(data)` | INSERT + RETURNING \*, returns the inserted row |
652
902
  | `insertMany(data)` | Bulk INSERT + RETURNING \*, returns rows |
653
- | `read(id, opts?)` | SELECT by primary key, returns row or undefined |
903
+ | `read(id, opts?)` | SELECT by detected primary key + auto soft-delete filter |
654
904
  | `readMany(where?, opts?)` | Filtered query with `{ count, data }` — auto-filters soft-deleted |
655
- | `update(id, data)` | UPDATE by id + RETURNING \*, returns updated row |
905
+ | `update(id, data)` | UPDATE by primary key + RETURNING \*, returns updated row |
656
906
  | `updateMany(where, data)` | Bulk UPDATE, returns affected row count |
657
907
  | `delete(id)` | Soft delete if `deleted_at` exists, else hard delete |
658
908
  | `hardDelete(id)` | Always DELETE FROM |
659
909
  | `deleteMany(where)` | Soft bulk delete if `deleted_at` exists |
660
910
  | `hardDeleteMany(where)` | Always DELETE FROM |
661
911
  | `upsert(data, conflict)` | INSERT ON CONFLICT DO UPDATE, returns row |
662
- | `count(where?)` | SELECT COUNT(\*) |
912
+ | `count(where?)` | SELECT COUNT(\*) — auto-filters soft-deleted |
663
913
  | `create(opts?)` | CREATE TABLE IF NOT EXISTS |
664
914
  | `drop(opts?)` | DROP TABLE IF EXISTS |
665
915
  | `createIndex(columns, opts?)` | CREATE INDEX |
666
916
  | `createUniqueIndex(columns)` | CREATE UNIQUE INDEX |
667
- | `withSql(sql)` | Returns a new BoundTable bound to a different sql (for transactions) |
917
+ | `withSql(sql)` | Returns copy bound to a different sql (for transactions) |
668
918
 
669
919
  **Where helpers** — composable query conditions:
670
920
 
@@ -690,6 +940,42 @@ class MyModule extends PgModule {
690
940
 
691
941
  Where helpers + `and`/`or`/`not` can be imported from `'weifuwu'` alongside `postgres`. Full column builders and table helpers are in the same barrel.
692
942
 
943
+ ### fts — Full-Text Search (PostgreSQL)
944
+
945
+ Utilities for PostgreSQL full-text search: create GIN indexes, search with ranking, and generate highlighted snippets.
946
+
947
+ ```ts
948
+ import { fts } from 'weifuwu'
949
+
950
+ const articles = pg.table('articles', {
951
+ id: serial('id').primaryKey(),
952
+ title: text('title'),
953
+ body: text('body'),
954
+ })
955
+
956
+ // Create search index
957
+ await fts.createIndex(pg.sql, articles, ['title', 'body'], { language: 'english' })
958
+
959
+ // Search with ranking
960
+ const results = await fts.search(pg.sql, articles, 'node.js framework', {
961
+ fields: ['title', 'body'],
962
+ limit: 20,
963
+ headline: true, // highlighted snippets via ts_headline
964
+ })
965
+ // → [{ id, rank: 0.8, row: { title, body, ... }, headline: '...<b>Node.js</b> framework...' }]
966
+
967
+ // Drop index
968
+ await fts.dropIndex(pg.sql, articles)
969
+ ```
970
+
971
+ | Function | Description |
972
+ |----------|-------------|
973
+ | `createIndex(sql, table, fields, opts?)` | Create GIN/GiST tsvector index |
974
+ | `search(sql, table, query, opts?)` | Search with ts_rank ordering |
975
+ | `dropIndex(sql, table, opts?)` | Drop the index |
976
+
977
+ Search options: `fields`, `limit` (20), `offset` (0), `headline` (false), `language` ('english'), `minRank`.
978
+
693
979
  ### preferences [α]
694
980
 
695
981
  Locale detection + theme + translations. `/__lang/:locale` and `/__theme/:theme` auto-routed.
@@ -742,16 +1028,34 @@ await q.add('send-email', { to: 'user@test.com' }, { cron: '0 8 * * *' })
742
1028
  | `.process(handler)` | Register job processor |
743
1029
  | `.run()` | Start processing |
744
1030
  | `.stop()` | Stop processing |
1031
+ | `.jobs(limit?)` | List pending jobs |
1032
+ | `.failedJobs(limit?)` | List failed jobs with error messages |
1033
+ | `.retryFailed(jobId)` | Retry a specific failed job |
1034
+ | `.retryAllFailed(type?)` | Retry all failed jobs (optionally by type) |
1035
+ | `.dashboard()` | Returns a Router with management endpoints |
745
1036
  | `.close()` | Cleanup |
746
1037
 
1038
+ **Dashboard endpoints** (mount via `app.use('/__queue', q.dashboard())`):
1039
+
1040
+ | Method | Path | Description |
1041
+ |--------|------|-------------|
1042
+ | GET | `/` | Queue stats + pending/failed counts by type |
1043
+ | GET | `/:type/failed` | List failed jobs for a type |
1044
+ | POST | `/:type/retry` | Retry all failed jobs of a type |
1045
+ | POST | `/retry/:id` | Retry a specific failed job by ID |
1046
+
747
1047
  ### rateLimit [α]
748
1048
 
749
1049
  ```ts
750
- app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min
1050
+ app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min, in-memory
751
1051
  app.get('/api', rateLimit({ max: 10 }), handler) // per-route
752
1052
  app.use(rateLimit({ key: (req) => req.headers.get('x-api-key') ?? 'anonymous' }))
1053
+
1054
+ // Multi-process: Redis-backed rate limiting
1055
+ app.use(rateLimit({ max: 100, store: 'redis', redis: ctx.redis }))
1056
+
753
1057
  // Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After headers
754
- // m.stop() — clear interval
1058
+ // m.stop() — clear interval (memory) or Redis cleanup
755
1059
  ```
756
1060
 
757
1061
  | Option | Type | Default | Description |
@@ -760,6 +1064,11 @@ app.use(rateLimit({ key: (req) => req.headers.get('x-api-key') ?? 'anonymous' })
760
1064
  | `window` | `number` | `60_000` | Window duration (ms) |
761
1065
  | `key` | `(req) => string` | IP-based | Key function |
762
1066
  | `message` | `string` | `'Too Many Requests'` | 429 response body |
1067
+ | `store` | `'memory' \| 'redis'` | `'memory'` | Backend store |
1068
+ | `redis` | `Redis` | — | Redis client (required when `store: 'redis'`) |
1069
+ | `prefix` | `string` | `'ratelimit:'` | Redis key prefix |
1070
+
1071
+ Redis mode uses `INCR` + `EXPIRE` for atomic counting, enabling accurate rate limiting across multiple server processes. Memory mode is ideal for single-process deployments.
763
1072
 
764
1073
  ### redis [α]
765
1074
 
@@ -808,6 +1117,58 @@ Also exports `seoTags(config)` for generating meta/og/twitter tags as an HTML st
808
1117
  | `sitemap` | `SitemapConfig` | — | Sitemap configuration (urls, resolve, cacheTTL) |
809
1118
  | `headers` | `SeoHeadersConfig` | — | Response headers (e.g. `X-Robots-Tag`) |
810
1119
 
1120
+ ### session [α]
1121
+
1122
+ Cookie-based server-side session management with memory and Redis stores.
1123
+
1124
+ ```ts
1125
+ app.use(session()) // in-memory store (default)
1126
+ app.use(session({ store: 'redis', redis: ctx.redis })) // Redis store
1127
+ app.use(session({ store: 'redis', redis, ttl: 30 * 60_000, cookieName: 'sid' }))
1128
+
1129
+ app.get('/login', async (req, ctx) => {
1130
+ ctx.session.userId = 42
1131
+ ctx.session.role = 'admin'
1132
+ // Auto-saved on response — cookie set automatically
1133
+ return Response.json({ ok: true })
1134
+ })
1135
+
1136
+ app.get('/logout', async (req, ctx) => {
1137
+ ctx.session.destroy() // or ctx.session = null
1138
+ return Response.json({ ok: true })
1139
+ })
1140
+
1141
+ // ctx.session.id — readonly session ID
1142
+ // ctx.session.save() — explicit dirty mark (for deep mutations)
1143
+ // ctx.session.destroy() — clear session + remove cookie
1144
+ // Session mutations are auto-detected on property set/delete
1145
+ ```
1146
+
1147
+ | Option | Type | Default | Description |
1148
+ |--------|------|---------|-------------|
1149
+ | `store` | `'memory' \| 'redis' \| SessionStore` | `'memory'` | Session store backend |
1150
+ | `redis` | `Redis` | — | Redis client (required when `store: 'redis'`) |
1151
+ | `ttl` | `number` | `86400000` (24h) | Session TTL in ms |
1152
+ | `cookieName` | `string` | `'__session'` | Cookie name |
1153
+ | `cookie.httpOnly` | `boolean` | `true` | Cookie httpOnly flag |
1154
+ | `cookie.secure` | `boolean` | `auto` | Cookie Secure flag (true in production) |
1155
+ | `cookie.sameSite` | `string` | `'lax'` | SameSite policy |
1156
+ | `cookie.path` | `string` | `'/'` | Cookie path |
1157
+ | `cookie.domain` | `string` | — | Cookie domain |
1158
+
1159
+ **Stores** are also exported for standalone use:
1160
+
1161
+ ```ts
1162
+ import { MemoryStore, RedisStore } from 'weifuwu'
1163
+
1164
+ const mem = new MemoryStore() // auto-cleanup every 60s
1165
+ await mem.set('sid', { userId: 1 }, 86400000)
1166
+ mem.close()
1167
+
1168
+ const redis = new RedisStore(redisClient, 'myapp:session:')
1169
+ await redis.destroy('sid')
1170
+ ```
1171
+
811
1172
  ### ssr({ dir }) [β]
812
1173
 
813
1174
  One-stop Server-Side Rendering. Accepts a directory and returns a Router that handles all SSR routes, tailwind CSS, hydration bundles, and livereload — using Next.js-style file conventions.
@@ -938,7 +1299,67 @@ app.post('/users', validate({ body: CreateUser, query: z.object({ ref: z.string(
938
1299
  // ctx.parsed.headers — typed & validated
939
1300
  })
940
1301
  // Validation failure: returns 400 with { error: 'Validation failed', issues: [...] }
1302
+
1303
+ **Form body auto-parsing** — `application/x-www-form-urlencoded` bodies are automatically parsed into `Record<string, string>` via `URLSearchParams`, even without a Zod schema:
1304
+
1305
+ ```ts
1306
+ // No schema needed — just parse the form
1307
+ app.post('/contact', validate(), (req, ctx) => {
1308
+ const email = ctx.parsed.body.email // string
1309
+ const msg = ctx.parsed.body.message // string
1310
+ return Response.json({ received: true })
1311
+ })
1312
+
1313
+ // Or validate with Zod
1314
+ app.post('/contact', validate({ body: z.object({ email: z.string().email() }) }), handler)
941
1315
  ```
1316
+
1317
+ | Option | Type | Default | Description |
1318
+ |--------|------|---------|-------------|
1319
+ | `body` | `ZodSchema` | — | Body validation schema (omit to skip) |
1320
+ | `query` | `ZodSchema` | — | Query validation schema |
1321
+ | `params` | `ZodSchema` | — | URL params validation schema |
1322
+ | `headers` | `ZodSchema` | — | Header validation schema |
1323
+
1324
+ ### webhook [β]
1325
+
1326
+ Webhook receiver with built-in signature verification for Stripe, GitHub, and Slack. Event-based dispatch with replay protection.
1327
+
1328
+ ```ts
1329
+ import { webhook } from 'weifuwu'
1330
+
1331
+ const wh = webhook({
1332
+ stripe: { secret: process.env.STRIPE_WEBHOOK_SECRET! },
1333
+ github: { secret: process.env.GITHUB_WEBHOOK_SECRET! },
1334
+ slack: { secret: process.env.SLACK_WEBHOOK_SECRET! },
1335
+ })
1336
+
1337
+ app.use('/webhooks', wh)
1338
+
1339
+ wh.on('checkout.session.completed', async (event, ctx) => {
1340
+ await fulfillOrder(event.payload.data.object)
1341
+ })
1342
+
1343
+ wh.on('push', async (event, ctx) => {
1344
+ await triggerCI(event.payload)
1345
+ })
1346
+
1347
+ wh.on('*', (event) => {
1348
+ console.log(`Received ${event.provider}.${event.event}`)
1349
+ })
1350
+ ```
1351
+
1352
+ | Option | Type | Default | Description |
1353
+ |--------|------|---------|-------------|
1354
+ | `stripe` | `PlatformConfig` | — | Stripe webhook config with `secret` |
1355
+ | `github` | `PlatformConfig` | — | GitHub webhook config |
1356
+ | `slack` | `PlatformConfig` | — | Slack webhook config |
1357
+ | `custom` | `CustomVerifierConfig[]` | — | Custom signature verifiers |
1358
+ | `replayProtection` | `boolean` | `true` | Deduplicate by event ID |
1359
+ | `idempotencyTTL` | `number` | `3600000` | Dedup TTL (ms) |
1360
+
1361
+ Built-in verifiers handle HMAC-SHA256, timestamp validation (Slack's 5-min window), and Stripe's `t=` / `v1=` signature format. Slack URL verification challenges are auto-responded.
1362
+
942
1363
  ### Client-side navigation
943
1364
 
944
1365
  ```tsx
@@ -1120,14 +1541,16 @@ Every public symbol can be imported from `'weifuwu'`:
1120
1541
  ```ts
1121
1542
  serve, createTestServer, Router, ssr,
1122
1543
  Context, Handler, Middleware, ErrorHandler, ServeOptions, Server,
1123
- loadEnv
1544
+ loadEnv, testApp, TestApp, TestRequest, TestResponse,
1545
+ currentTraceId, currentTrace, runWithTrace, traceElapsed, TraceContext,
1124
1546
  ```
1125
1547
 
1126
1548
  ### Middleware modules
1127
1549
 
1128
1550
  ```ts
1129
1551
  auth, cors, csrf, compress, helmet, logger, rateLimit, requestId, validate, upload,
1130
- preferences, serveStatic
1552
+ preferences, serveStatic, session, MemoryStore, RedisStore, SessionStore,
1553
+ cache, MemoryCache, RedisCache, CacheStore
1131
1554
  ```
1132
1555
 
1133
1556
  ### Database
@@ -1136,6 +1559,7 @@ preferences, serveStatic
1136
1559
  postgres, PostgresOptions, PostgresClient,
1137
1560
  redis, RedisOptions, RedisClient,
1138
1561
  queue, QueueOptions, QueueJob, Queue,
1562
+ PostgresInjected, RedisInjected, QueueInjected,
1139
1563
  // Schema helpers — importable alongside postgres:
1140
1564
  pgTable, SQL, sql,
1141
1565
  ColumnBuilder, serial, uuid, text, integer, boolean, boolean_, timestamptz, jsonb, textArray, vector,
@@ -1151,8 +1575,10 @@ TsxContext, useLoaderData,
1151
1575
  useWebsocket, useAction, useFetch, useQueryState, createStore,
1152
1576
  Link, useNavigate, useNavigating, addInterceptor,
1153
1577
  useLocale, useTheme, applyTheme, useFlashMessage,
1578
+ useAgentStream,
1154
1579
  Head
1155
1580
  ```
1581
+ export type { UseAgentStreamOptions, UseAgentStreamReturn, AgentStreamState } from 'weifuwu/react'
1156
1582
 
1157
1583
  ### AI SDK (re-exported from `ai`)
1158
1584
 
@@ -1168,9 +1594,15 @@ openai, createOpenAI
1168
1594
  preferences, health, analytics, seo, seoMiddleware, seoTags,
1169
1595
  user, mailer, graphql, aiStream, runWorkflow,
1170
1596
  logdb, messager, agent, iii, createWorker, registerWorker,
1171
- opencode, deploy, defineConfig,
1597
+ opencode, deploy, defineConfig, webhook,
1598
+ testApp, TestApp, TestRequest, TestResponse,
1599
+ createTestDb, withTestDb,
1172
1600
  getCookies, setCookie, deleteCookie,
1173
- createSSEStream, formatSSE, formatSSEData
1601
+ createSSEStream, formatSSE, formatSSEData,
1602
+ currentTraceId, currentTrace, runWithTrace, traceElapsed,
1603
+ createHub, Hub, HubOptions,
1604
+ DEFAULT_MAX_BODY, MIGRATIONS_TABLE,
1605
+ fts,
1174
1606
  ```
1175
1607
 
1176
1608
  ---