weifuwu 0.22.2 → 0.23.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
@@ -60,10 +60,20 @@ await server.ready
60
60
  | `shutdown` | `boolean` | `true` | Auto SIGTERM/SIGINT |
61
61
 
62
62
  ```ts
63
- interface Server { stop: () => Promise<void>; readonly port: number; readonly hostname: string; ready: Promise<void> }
63
+ interface Server {
64
+ stop: (timeoutMs?: number) => Promise<void> // graceful: waits for in-flight, force-closes after timeoutMs (default 10s)
65
+ readonly port: number
66
+ readonly hostname: string
67
+ ready: Promise<void>
68
+ }
64
69
  const { server, url } = await createTestServer(handler)
65
70
  ```
66
71
 
72
+ `server.stop()` performs a graceful shutdown: stops accepting new connections,
73
+ closes idle keep-alive sockets, then waits for in-flight requests to complete.
74
+ If they don't finish within `timeoutMs` (default 10 seconds), remaining connections
75
+ are forcibly closed. SIGTERM/SIGINT use the same graceful pattern.
76
+
67
77
  ### Router
68
78
 
69
79
  ```ts
@@ -113,7 +123,7 @@ Request → serve() → app.handler() → global middleware × N → path middle
113
123
 
114
124
  1. `serve()` receives HTTP request
115
125
  2. `app.handler()` creates `ctx = { params, query }` and routes to the matching trie node
116
- 3. **Global middleware** runs in `use()` order (e.g. `preferences()`, `postgres()`, `cors()`)
126
+ 3. **Global middleware** runs in `use()` order (e.g. `theme()`, `i18n()`, `postgres()`, `cors()`)
117
127
  4. **Path‑scoped middleware** runs for matching paths (e.g. `app.use('/admin', authMW)`)
118
128
  5. **Route‑level middleware** runs (e.g. `app.get('/admin', validate(...), handler)`)
119
129
  6. **Route handler** returns `Response` — middleware chain unwinds
@@ -142,22 +152,23 @@ The `ctx` object accumulates properties as it passes through the middleware chai
142
152
  | `csrfToken` | `csrf()` | `string` | CSRF token |
143
153
  | `requestId` | `requestId()` | `string` | Request ID |
144
154
  | `session` | `session()` | `Session` | Session data object |
145
- | `sessionId` | `session()` | `string` | Session ID |
146
155
  | `sql` | `postgres()` | `Sql<{}>` | PostgreSQL tagged-template client |
147
156
  | `redis` | `redis()` | `Redis` | Redis client |
157
+ | `ai` | `aiProvider()` | `AIProvider` | AI model & embedding |
148
158
  | `queue` | `queue()` | `Queue` | Job queue |
149
- | `prefs` | `preferences()` | `{ locale, theme }` | User preferences (locale, theme) |
150
- | `deploy` | `deploy()` | `{ appName? }` | Deploy gateway info |
159
+ | `session` | `session()` | `Session` | Session data object |
160
+ | `user` | `auth()` / `user().middleware()` | `{ id?: string }` | Authenticated user |
161
+ | `permissions` | `permissions()` | `{ roles, permissions }` | RBAC roles & permissions sets |
162
+ | `theme` | `theme()` | `{ value, set }` | Current theme + switcher |
163
+ | `i18n` | `i18n()` | `{ locale, t, set }` | Locale, translation, switcher |
164
+ | `flash` | `flash()` | `{ value, set }` | Flash message + setter |
165
+ | `tailwind` | `tailwindContext()` | `{ css, url }` | Compiled Tailwind CSS |
166
+ | `tenant` | `tenant()` | `TenantContext` | Current tenant info |
167
+ | `parsed` | `validate()` / `upload()` | `{ body, files }` | Validated/parsed request data |
151
168
  | `layoutStack` | `ssr()` internal | `LayoutEntry[]` | React layout component stack |
152
169
  | `loaderData` | User middleware | `Record<string, unknown>` | SSR data passed to client |
153
- | `user` | `auth()` / `user().middleware()` | `{ id?: string }` | Authenticated user |
154
- | `parsed` | `validate()` / `upload()` | `{ body, query, params, headers, files }` | Validated/parsed request data |
155
- | `t` | `preferences()` | `(key) => string` | Translation function |
156
- | `setPref` | `preferences()` | `(key, val) => Response` | Set preference cookie + redirect |
157
- | `compiledTailwindCss` | `ssr()` internal | `string` | Compiled CSS content (internal) |
158
- | `tailwindCssUrl` | `ssr()` internal | `string` | Compiled CSS route URL (internal) |
159
- | `session` | `session()` | `Session` | Session data object |
160
- | `sessionId` | `session()` | `string` | Session ID |
170
+ | `mountPath` | `Router` | `string` | Sub-router mount path |
171
+ | `deploy` | `deploy()` | `{ appName? }` | Deploy gateway info |
161
172
 
162
173
  ### Type-Safe Context
163
174
 
@@ -186,7 +197,7 @@ All modules follow one of **2 patterns** — learn these and you know every modu
186
197
 
187
198
  | Pattern | How to mount | Example |
188
199
  |---------|-------------|---------|
189
- | `[α]` | `app.use(mod())` | `compress()`, `preferences()`, `postgres()` |
200
+ | `[α]` | `app.use(mod())` | `compress()`, `theme()`, `postgres()` |
190
201
  | `[β]` | `app.use('/path', mod())` | `health()`, `ssr({dir})`, `graphql(handler)`, `user()` |
191
202
 
192
203
  ### Pattern α — Middleware
@@ -339,7 +350,8 @@ Uses `TEST_DATABASE_URL` or `DATABASE_URL`. Automatically skipped in CI if unset
339
350
  ### agent [β]
340
351
 
341
352
  ```ts
342
- const a = agent({ pg, model: openai('gpt-4o'), embeddingModel: openai.embedding('text-embedding-3-small') })
353
+ const provider = aiProvider()
354
+ const a = agent({ pg, provider })
343
355
  await a.migrate()
344
356
  app.use('/api', a)
345
357
  await a.addKnowledge(agentId, 'Title', 'some knowledge content')
@@ -349,9 +361,10 @@ a.run(agentId, { input: 'summarize the data', stream: true })
349
361
  | Option | Type | Default | Description |
350
362
  |--------|------|---------|-------------|
351
363
  | `pg` | `object` | — | PostgreSQL client |
352
- | `model` | `object` | | AI model (e.g. `openai('gpt-4o')`) |
353
- | `embeddingModel` | `object` | — | Embedding model for knowledge search |
354
- | `embeddingDimension` | `number` | `1536` | Embedding vector dimension |
364
+ | `provider` | `AIProvider` | `aiProvider()` (from env) | AI provider for model & embedding resolution |
365
+ | `model` | `object` | — | Explicit AI model (overrides provider) |
366
+ | `embeddingModel` | `object` | | Explicit embedding model (overrides provider) |
367
+ | `embeddingDimension` | `number` | `provider.dimension` | Embedding vector dimension |
355
368
  | `tools` | `object[]` | — | Custom tool definitions |
356
369
 
357
370
  | Method | Description |
@@ -366,13 +379,15 @@ a.run(agentId, { input: 'summarize the data', stream: true })
366
379
  Creates an AI streaming chat endpoint using the Vercel AI SDK.
367
380
 
368
381
  ```ts
369
- const chat = await aiStream(async (req) => ({ model: openai('gpt-4o'), messages: (await req.json()).messages }))
382
+ const provider = aiProvider()
383
+ const chat = await aiStream(async (req) => ({ messages: (await req.json()).messages }), provider)
370
384
  app.use('/chat', chat)
371
385
  ```
372
386
 
373
387
  | Param | Type | Description |
374
388
  |-------|------|-------------|
375
389
  | `handler` | `(req, ctx) => AIStreamOptions \| Promise<AIStreamOptions>` | Returns AI SDK options (model, messages, schema, etc.) |
390
+ | `provider` | `AIProvider` | Optional. If provided and handler omits `model`, `provider.model()` is used as default |
376
391
 
377
392
  ### analytics [β]
378
393
 
@@ -404,6 +419,16 @@ app.use(auth({ token: 'sk-123' })) // static token
404
419
  app.use(auth({ header: 'X-API-Key', token: 'my-key' })) // custom header
405
420
  app.use(auth({ verify: async (token, req) => ({ sub: 'abc' }) })) // custom verify → sets ctx.user
406
421
  app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
422
+
423
+ // Session-based auth (must be placed after session() middleware)
424
+ app.use(session())
425
+ app.use(auth({
426
+ session: true,
427
+ resolveUser: async (userId) => { // load user from DB
428
+ const [user] = await sql`SELECT * FROM users WHERE id = ${userId}`
429
+ return user ?? null // null → destroy stale session
430
+ },
431
+ }))
407
432
  ```
408
433
 
409
434
  | Option | Type | Default | Description |
@@ -412,6 +437,13 @@ app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
412
437
  | `header` | `string` | `'Authorization'` | Header name |
413
438
  | `verify` | `(token, req) => object\|null` | — | Verify function, return value sets `ctx.user` |
414
439
  | `proxy` | `string` | — | Auth service URL to proxy requests to |
440
+ | `session` | `boolean` | `false` | Enable session-based auth. Checks `ctx.session.userId` first |
441
+ | `resolveUser` | `(userId) => object\|null` | — | Load user from userId (called when `session: true`). Return falsy to reject + auto-destroy stale session |
442
+
443
+ When `session: true`, auth checks `ctx.session.userId` before the
444
+ Authorization header. This lets logged-in users authenticate via their
445
+ session cookie without sending a token. Falls back to header/token auth
446
+ if no session userId is present.
415
447
 
416
448
  ### compress [α]
417
449
 
@@ -699,6 +731,60 @@ await engine.trigger({ function_id: 'orders::create', payload: { items: ['apple'
699
731
 
700
732
 
701
733
 
734
+ ### knowledgeBase [β] — RAG with pgvector
735
+
736
+ ```ts
737
+ import { knowledgeBase, aiProvider } from 'weifuwu'
738
+
739
+ const kb = knowledgeBase({
740
+ pg: postgres(),
741
+ provider: aiProvider(),
742
+ table: 'my_docs',
743
+ })
744
+
745
+ // Create table + HNSW index (safe to call multiple times)
746
+ await kb.migrate()
747
+
748
+ // Ingest a document (auto chunk → embed → store)
749
+ await kb.ingest('docs/intro.md', `# Welcome\n\nThis is the introduction...`, {
750
+ title: 'Introduction',
751
+ metadata: { source: 'docs', author: 'alice' },
752
+ })
753
+
754
+ // Semantic search
755
+ const results = await kb.search('how to get started?', { limit: 5 })
756
+ // → [{ key, title, content, score: 0.92, metadata }, ...]
757
+
758
+ // Delete
759
+ await kb.delete('docs/outdated.md')
760
+
761
+ // List all documents
762
+ const entries = await kb.list()
763
+ // → [{ key, title, chunks: 3 }, ...]
764
+
765
+ // Use as middleware (injects ctx.kb.search)
766
+ app.use(kb.middleware())
767
+ app.get('/search', async (req, ctx) => {
768
+ const results = await ctx.kb.search(ctx.query.q)
769
+ return Response.json(results)
770
+ })
771
+ ```
772
+
773
+ | Option | Type | Default | Description |
774
+ |--------|------|---------|-------------|
775
+ | `pg` | `PostgresClient` | — | **Required.** PostgreSQL client |
776
+ | `provider` | `AIProvider` | — | **Required.** AI provider for embedding |
777
+ | `table` | `string` | `'_kb_docs'` | Database table name |
778
+ | `chunkSize` | `number` | `512` | Max characters per chunk |
779
+ | `chunkOverlap` | `number` | `64` | Overlap between chunks |
780
+ | `searchLimit` | `number` | `5` | Default search result count |
781
+ | `searchThreshold` | `number` | `0` | Minimum similarity (0–1) |
782
+
783
+ Documents are split on paragraph boundaries (`\n\n`). Re-ingesting the same key
784
+ replaces old chunks. Provider's `embed()` is used automatically.
785
+ The HNSW index enables fast approximate nearest-neighbor search (cosine distance).
786
+
787
+
702
788
  ### logdb [β]
703
789
 
704
790
  PostgreSQL structured event logging with monthly partitioning.
@@ -746,6 +832,72 @@ await mail.send({ to: 'user@test.com', subject: 'Hello', text: 'Body', html: '<p
746
832
  | `from` | `string` | — | Default sender address |
747
833
  | `send` | `function` | — | Custom send function (alternative to transport) |
748
834
 
835
+
836
+
837
+ ### oauthClient [β] — Social login (OAuth 2.0 client)
838
+
839
+ ```ts
840
+ import { oauthClient } from 'weifuwu'
841
+
842
+ app.use(session()) // required — stores OAuth state
843
+ app.use(user({ pg, jwtSecret })) // required — user management
844
+ app.use('/auth', oauthClient({ // mounts /auth/google, /auth/google/callback
845
+ pg,
846
+ jwtSecret,
847
+ redirectUrl: '/dashboard',
848
+ providers: {
849
+ google: {
850
+ clientId: process.env.GOOGLE_CLIENT_ID,
851
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
852
+ },
853
+ github: {
854
+ clientId: process.env.GITHUB_CLIENT_ID,
855
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
856
+ },
857
+ },
858
+ }))
859
+ ```
860
+
861
+ **Flow:** User clicks "Login with Google" → redirected to Google → back to app → user created/linked in database → JWT signed → session created → redirected to `redirectUrl` with `?token=` (or JSON response for API clients).
862
+
863
+ Supports custom providers via `authUrl`, `tokenUrl`, `userUrl`, and `parseUser`:
864
+
865
+ ```ts
866
+ app.use('/auth', oauthClient({
867
+ pg,
868
+ jwtSecret,
869
+ providers: {
870
+ discord: {
871
+ clientId: process.env.DISCORD_CLIENT_ID,
872
+ clientSecret: process.env.DISCORD_CLIENT_SECRET,
873
+ authUrl: 'https://discord.com/api/oauth2/authorize',
874
+ tokenUrl: 'https://discord.com/api/oauth2/token',
875
+ userUrl: 'https://discord.com/api/users/@me',
876
+ parseUser: (data) => ({
877
+ id: data.id,
878
+ email: data.email ?? '',
879
+ name: data.global_name ?? data.username,
880
+ avatarUrl: data.avatar
881
+ ? `https://cdn.discordapp.com/avatars/${data.id}/${data.avatar}.png`
882
+ : '',
883
+ }),
884
+ },
885
+ },
886
+ }))
887
+ ```
888
+
889
+ | Option | Type | Default | Description |
890
+ |--------|------|---------|-------------|
891
+ | `pg` | `PostgresClient` | — | **Required.** Database connection |
892
+ | `jwtSecret` | `string` | — | **Required.** Must match `user()` module's secret |
893
+ | `providers` | `Record<string, OAuthProviderConfig>` | — | **Required.** Provider configs (Google/GitHub built-in, any custom) |
894
+ | `redirectUrl` | `string` | `'/'` | Post-login redirect destination |
895
+ | `expiresIn` | `string \| number` | `'24h'` | JWT expiry |
896
+ | `table` | `string` | `'_auth_providers'` | Provider-user link table name |
897
+
898
+ The module auto-creates a `_auth_providers` table (`user_id`, `provider`, `provider_id`, `email`, `name`, `avatar_url`) on first request. Built-in providers (Google, GitHub) have preset URLs — you only need to provide `clientId` and `clientSecret`.
899
+
900
+
749
901
  ### messager [β]
750
902
 
751
903
  Real-time chat with channels, WebSocket, agent routing.
@@ -976,56 +1128,110 @@ await fts.dropIndex(pg.sql, articles)
976
1128
 
977
1129
  Search options: `fields`, `limit` (20), `offset` (0), `headline` (false), `language` ('english'), `minRank`.
978
1130
 
979
- ### preferences [α]
1131
+ ### theme [α]
1132
+
1133
+ ```ts
1134
+ app.use(theme({ default: 'dark' }))
1135
+ // → ctx.theme = { value: 'dark', set: fn }
1136
+ // → ctx.theme.value — 'dark'
1137
+ // → ctx.theme.set('light', '/settings') — 302 + Set-Cookie
1138
+
1139
+ // Client-side switching (interceptor auto-handles /__theme/:value)
1140
+ // → GET /__theme/dark — 302 + Set-Cookie
1141
+ ```
980
1142
 
981
- Locale detection + theme + translations. `/__lang/:locale` and `/__theme/:theme` auto-routed.
1143
+ | Option | Type | Default | Description |
1144
+ |--------|------|---------|-------------|
1145
+ | `default` | `string` | `'system'` | Default theme |
1146
+ | `cookie` | `string` | `'theme'` | Cookie name (empty to disable) |
982
1147
 
983
1148
  ```ts
984
- app.use(preferences({ dir: './locales', locale: { default: 'en' }, theme: { default: 'system' } }))
985
- // ctx.prefs.locale, ctx.prefs.theme, ctx.t('key'), ctx.setPref('locale', 'zh')
986
- // ctx.setPref() returns a 302 Response with Set-Cookie — return it from your handler
987
- // GET /__lang/zh → 302 + Set-Cookie (or JSON if Accept: application/json)
988
- // GET /__theme/dark → same pattern
1149
+ // Server-side switching
1150
+ app.post('/settings', async (req, ctx) => {
1151
+ const { theme } = await req.json()
1152
+ return ctx.theme.set(theme, '/settings')
1153
+ })
989
1154
  ```
990
1155
 
991
- | Option | Default | Description |
992
- |--------|---------|-------------|
993
- | `dir` | — | Translation JSON directory |
994
- | `locale.default` | `'en'` | Fallback locale |
995
- | `locale.cookie` | `'locale'` | Cookie name |
996
- | `locale.fromAcceptLanguage` | `true` | Detect from header |
997
- | `theme.default` | `'system'` | `'light'` \| `'dark'` \| `'system'` |
998
- | `theme.cookie` | `'theme'` | Cookie name |
1156
+ See [`useTheme()`](#usetheme) for client-side usage.
999
1157
 
1000
- ```tsx
1001
- // Client-side no-refresh switching — import enables it automatically
1002
- import { useLocale, useTheme } from 'weifuwu/react'
1158
+ ### i18n [α]
1003
1159
 
1004
- <Link href="/__lang/zh">中文</Link> // <Link> handles it via interceptor
1005
- <button onClick={() => setLocale('en')}>EN</button> // or programmatic
1006
- const { theme, resolvedTheme, setTheme } = useTheme()
1007
- // resolvedTheme resolves 'system' → 'dark'|'light' based on prefers-color-scheme
1160
+ ```ts
1161
+ app.use(i18n({ default: 'zh', dir: './locales' }))
1162
+ // → ctx.i18n = { locale: 'zh', t: (key) => string, set: (locale) => Response }
1163
+ // ctx.i18n.t('welcome') → '欢迎'
1164
+ // → ctx.i18n.locale → 'zh'
1165
+ // → ctx.i18n.set('en', '/settings') — 302 + Set-Cookie
1166
+ // → GET /__lang/en — switch locale (client-side interceptor)
1008
1167
  ```
1009
1168
 
1169
+ | Option | Type | Default | Description |
1170
+ |--------|------|---------|-------------|
1171
+ | `default` | `string` | `'en'` | Default locale |
1172
+ | `dir` | `string` | — | Directory with `{locale}.json` files |
1173
+ | `messages` | `object` | — | Inline translations: `{ zh: { welcome: '欢迎' } }` |
1174
+ | `cookie` | `string` | `'locale'` | Cookie name (empty to disable) |
1175
+ | `fromAcceptLanguage` | `boolean` | `true` | Detect from Accept-Language header |
1176
+
1177
+ ```ts
1178
+ // Handler
1179
+ app.get('/greet', async (req, ctx) => {
1180
+ const greeting = ctx.i18n?.t('welcome', { name: 'Alice' })
1181
+ return Response.json({ greeting, locale: ctx.i18n?.locale })
1182
+ })
1183
+ ```
1184
+
1185
+ **Client-side:** import `useLocale` from `weifuwu/react`, `useTheme` from `weifuwu/react`.
1186
+
1010
1187
  ### queue [α]
1011
1188
 
1189
+ Async job queue. Supports immediate, delayed, and recurring (cron) tasks with three backends:
1190
+
1191
+ - `{ store: 'memory' }` — in-memory, zero dependency, suitable for dev & cron-like tasks
1192
+ - `{ store: 'pg', pg }` — PostgreSQL-backed, persistent, multi-instance safe via `FOR UPDATE SKIP LOCKED`
1193
+ - `{ store: 'redis', redis }` — Redis-backed, production-grade, distributed
1194
+
1012
1195
  ```ts
1013
- const q = queue({ redis })
1014
- app.use(q) // injects ctx.queue
1015
- await q.add('send-email', { to: 'user@test.com' }, { cron: '0 8 * * *' })
1196
+ // Create queue
1197
+ const q = queue({ store: 'memory' })
1198
+ // const q = queue({ store: 'pg', pg })
1199
+ // const q = queue({ store: 'redis', redis })
1200
+
1201
+ // Register cron job (uses the same backend for persistence)
1202
+ q.cron('*/5 * * * *', async () => { await cleanCache() })
1203
+
1204
+ // Or use process/add for full queue semantics
1205
+ q.process('send-email', async (job) => {
1206
+ await sendMail(job.payload)
1207
+ })
1208
+
1209
+ // Immediate
1210
+ q.add('send-email', { to: 'user@test.com' })
1211
+
1212
+ // Delayed
1213
+ q.add('send-email', { to: 'user@test.com' }, { delay: 60_000 })
1214
+
1215
+ // Scheduled (cron) — re-queues automatically
1216
+ q.add('weekly-report', {}, { schedule: '0 9 * * 1' })
1217
+
1218
+ q.run()
1016
1219
  ```
1017
1220
 
1018
1221
  | Option | Type | Default | Description |
1019
1222
  |--------|------|---------|-------------|
1020
- | `redis` | `object` | | Redis client |
1223
+ | `store` | `'memory' \| 'pg' \| 'redis'` | `'memory'` | Backend store |
1224
+ | `redis` | `object` | — | Redis client (required when `store: 'redis'`) |
1021
1225
  | `url` | `string` | — | Redis URL (alternative to client) |
1022
- | `prefix` | `string` | `'queue:'` | Redis key prefix |
1023
- | `pollInterval` | `number` | `1000` | Poll interval (ms) |
1226
+ | `pg` | `object` | | PostgreSQL client (required when `store: 'pg'`) |
1227
+ | `prefix` | `string` | `'queue'` | Key/table prefix |
1228
+ | `pollInterval` | `number` | `200` | Poll interval (ms) |
1024
1229
 
1025
1230
  | Method | Description |
1026
1231
  |--------|-------------|
1027
- | `.add(name, data, opts?)` | Add job to queue |
1028
- | `.process(handler)` | Register job processor |
1232
+ | `.cron(pattern, handler)` | Register a cron job (uses process + add internally) |
1233
+ | `.add(type, payload, opts?)` | Add job (opts: `delay`, `schedule`) |
1234
+ | `.process(type, handler)` | Register job processor |
1029
1235
  | `.run()` | Start processing |
1030
1236
  | `.stop()` | Stop processing |
1031
1237
  | `.jobs(limit?)` | List pending jobs |
@@ -1035,6 +1241,18 @@ await q.add('send-email', { to: 'user@test.com' }, { cron: '0 8 * * *' })
1035
1241
  | `.dashboard()` | Returns a Router with management endpoints |
1036
1242
  | `.close()` | Cleanup |
1037
1243
 
1244
+ **Schedule (cron) field reference:**
1245
+
1246
+ | Field | Range |
1247
+ |-------|-------|
1248
+ | minute | 0–59 |
1249
+ | hour | 0–23 |
1250
+ | day of month | 1–31 |
1251
+ | month | 1–12 |
1252
+ | day of week | 0–6 (0=Sunday) |
1253
+
1254
+ Supported cron syntax: `*` (any), `*/n` (every n), `n-m` (range), `n,m,o` (list), `n` (exact).
1255
+
1038
1256
  **Dashboard endpoints** (mount via `app.use('/__queue', q.dashboard())`):
1039
1257
 
1040
1258
  | Method | Path | Description |
@@ -1099,6 +1317,85 @@ app.use(requestId({ header: 'X-Request-Id', generator: () => crypto.randomUUID()
1099
1317
 
1100
1318
 
1101
1319
 
1320
+ ### s3 [α] — S3-compatible object storage
1321
+
1322
+ ```ts
1323
+ import { s3 } from 'weifuwu'
1324
+
1325
+ app.use(s3({
1326
+ bucket: 'my-app',
1327
+ region: 'us-east-1',
1328
+ endpoint: process.env.S3_URL, // MinIO / R2 / AWS
1329
+ forcePathStyle: true, // required for MinIO
1330
+ credentials: {
1331
+ accessKeyId: process.env.S3_ACCESS_KEY,
1332
+ secretAccessKey: process.env.S3_SECRET_KEY,
1333
+ },
1334
+ publicUrl: 'https://cdn.example.com', // for unsigned public URLs
1335
+ }))
1336
+ ```
1337
+
1338
+ Injects `ctx.s3` with methods for S3-compatible object storage.
1339
+
1340
+ ```ts
1341
+ // Upload
1342
+ await ctx.s3.put('images/logo.png', buffer, { contentType: 'image/png' })
1343
+
1344
+ // Download
1345
+ const buf = await ctx.s3.get('images/logo.png') // Buffer | null
1346
+
1347
+ // Delete
1348
+ await ctx.s3.delete('images/logo.png')
1349
+
1350
+ // Check existence
1351
+ if (await ctx.s3.exists('images/logo.png')) { ... }
1352
+
1353
+ // Signed URL (expires in 1 hour by default)
1354
+ const url = await ctx.s3.url('images/logo.png')
1355
+ const shortUrl = await ctx.s3.url('images/logo.png', { expiresIn: 60 })
1356
+
1357
+ // Public URL (requires publicUrl in options)
1358
+ const publicUrl = await ctx.s3.url('images/logo.png', { expiresIn: 0 })
1359
+
1360
+ // List objects under a prefix
1361
+ const keys = await ctx.s3.list('images/')
1362
+ ```
1363
+
1364
+ | Option | Type | Default | Description |
1365
+ |--------|------|---------|-------------|
1366
+ | `bucket` | `string` | — | **Required.** S3 bucket name |
1367
+ | `region` | `string` | `'us-east-1'` | AWS region |
1368
+ | `endpoint` | `string` | — | Custom endpoint (MinIO, R2, B2) |
1369
+ | `forcePathStyle` | `boolean` | `false` | Path-style addressing (required for MinIO) |
1370
+ | `credentials` | `{ accessKeyId, secretAccessKey }` | — | Falls back to AWS env vars / IAM role |
1371
+ | `publicUrl` | `string` | — | Base URL for unsigned public URLs via `url(key, { expiresIn: 0 })` |
1372
+
1373
+ Credentials can be omitted to use AWS environment variables (`AWS_ACCESS_KEY_ID`,
1374
+ `AWS_SECRET_ACCESS_KEY`) or IAM roles (EC2, ECS, Lambda).
1375
+
1376
+ The module can also be used standalone without the middleware:
1377
+
1378
+ ```ts
1379
+ const storage = s3({ bucket: 'my-app', endpoint: '...' })
1380
+ await storage.put('key', body)
1381
+ const data = await storage.get('key')
1382
+ ```
1383
+
1384
+ Requires MinIO or another S3-compatible service for local development.
1385
+ Add to `docker-compose.yml`:
1386
+
1387
+ ```yml
1388
+ minio:
1389
+ image: minio/minio
1390
+ ports:
1391
+ - '9000:9000'
1392
+ environment:
1393
+ MINIO_ROOT_USER: minioadmin
1394
+ MINIO_ROOT_PASSWORD: minioadmin
1395
+ command: server /data
1396
+ ```
1397
+
1398
+
1102
1399
  ### seo [β] + seoMiddleware [α]
1103
1400
 
1104
1401
  ```ts
@@ -1155,6 +1452,16 @@ app.get('/logout', async (req, ctx) => {
1155
1452
  | `cookie.sameSite` | `string` | `'lax'` | SameSite policy |
1156
1453
  | `cookie.path` | `string` | `'/'` | Cookie path |
1157
1454
  | `cookie.domain` | `string` | — | Cookie domain |
1455
+ | `secret` | `string` | — | HMAC-SHA256 sign the session cookie (`uuid.signature`). Prevents tampering **strongly recommended in production** |
1456
+ | `rotateInterval` | `number` | `900000` (15min) | Auto-rotate session ID to prevent fixation attacks. Set `0` to disable |
1457
+
1458
+ When `secret` is set, the cookie value is signed with HMAC-SHA256:
1459
+ `uuid.base64url(hmac)`. Tampered cookies are rejected and treated as new
1460
+ sessions (no error message, no data leak).
1461
+
1462
+ Session ID auto-rotation copies data to a new ID and deletes the old one
1463
+ from the store. Rotation happens transparently on the next request after
1464
+ `rotateInterval` has elapsed.
1158
1465
 
1159
1466
  **Stores** are also exported for standalone use:
1160
1467
 
@@ -1171,7 +1478,7 @@ await redis.destroy('sid')
1171
1478
 
1172
1479
  ### ssr({ dir }) [β]
1173
1480
 
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.
1481
+ One-stop Server-Side Rendering. Accepts a directory and returns a Router that handles all SSR routes, tailwind CSS, hydration, and livereload — using Next.js-style file conventions.
1175
1482
 
1176
1483
  ```ts
1177
1484
  import { Router, ssr } from 'weifuwu'
@@ -1209,13 +1516,18 @@ app.use('/', ssr({ dir: './ui' }))
1209
1516
  | `app/error.tsx` | Error boundary for that subtree |
1210
1517
  | `app/globals.css` | Tailwind CSS entry (compiled via `@tailwindcss/postcss`) |
1211
1518
 
1212
- **How it works:**
1519
+ **How hydration works:**
1213
1520
 
1214
1521
  - Each page is lazy-resolved on first request — only the `page.tsx` and its layout chain are compiled
1215
- - Hydration bundle generated per-page at `/__ssr/{hash}.js`
1522
+ - An inline `<script type="module">` in the HTML handles hydration
1523
+ - It imports `{ setCtx, TsxContext }` from the vendor bundle (`/__wfw/v/bundle`) via importmap
1524
+ - Then dynamically imports the page component: `await import('/__ssr/[hash].js')`
1525
+ - The vendor bundle (react + react-dom + weifuwu client libs) is compiled once and cached
1526
+ - Page components are pre-compiled to `/__ssr/{hash}.js` — no runtime esbuild after first request
1527
+ - **Dev:** `createRoot` + render; **Production:** `hydrateRoot` (reuses SSR DOM)
1528
+ - Both the hydration script and the page component share the same store via `globalThis.__WEIFUWU_CTX_STORE`
1216
1529
  - Tailwind CSS served at `/__wfw/style/{hash}.css` (cached, content-hashed)
1217
- - Dev mode: vendor bundle, HMR WebSocket, file watcher all automatic
1218
- - Page components and layouts are compiled via esbuild at runtime — no build step needed
1530
+ - Dev mode extras: HMR WebSocket, file watcher, hot component replacement
1219
1531
 
1220
1532
  ```ts
1221
1533
  // Multiple independent SSR directories
@@ -1263,13 +1575,23 @@ app.post('/upload', upload({ dir: './uploads', maxFileSize: 10_485_760, allowedT
1263
1575
 
1264
1576
  ### user [β]
1265
1577
 
1266
- Authentication: register, login, JWT, OAuth2.
1578
+ Authentication: register, login, JWT, OAuth2 服务端, 社会化登录.
1267
1579
 
1268
1580
  ```ts
1269
- const auth = user({ pg, jwtSecret: process.env.JWT_SECRET! })
1270
- await auth.migrate()
1271
- app.use('/auth', auth) // POST /register, POST /login, OAuth2 routes
1272
- app.get('/me', auth.middleware(), (req, ctx) => Response.json(ctx.user))
1581
+ const u = user({
1582
+ pg,
1583
+ jwtSecret: process.env.JWT_SECRET!,
1584
+ oauthLogin: { // 可选 社会化登录
1585
+ providers: {
1586
+ github: { clientId: '...', clientSecret: '...' },
1587
+ google: { clientId: '...', clientSecret: '...' },
1588
+ },
1589
+ },
1590
+ })
1591
+ await u.migrate()
1592
+ app.use(u) // POST /register, POST /login
1593
+ app.use(u.middleware()) // ctx.user
1594
+ // GET /auth/github, GET /auth/github/callback (如配置 oauthLogin)
1273
1595
  ```
1274
1596
 
1275
1597
  | Option | Type | Default | Description |
@@ -1277,8 +1599,9 @@ app.get('/me', auth.middleware(), (req, ctx) => Response.json(ctx.user))
1277
1599
  | `pg` | `object` | — | PostgreSQL client |
1278
1600
  | `jwtSecret` | `string` | — | JWT signing secret |
1279
1601
  | `table` | `string` | `'_users'` | Users table name |
1280
- | `expiresIn` | `string` | `'7d'` | JWT expiration |
1281
- | `oauth2` | `object` | — | OAuth2 client config (PKCE flow) |
1602
+ | `expiresIn` | `string` | `'24h'` | JWT expiration |
1603
+ | `oauth2` | `object` | — | OAuth2 服务端 config (PKCE flow) |
1604
+ | `oauthLogin` | `object` | — | 社会化登录: `{ providers: Record<string, OAuthProviderConfig>, redirectUrl? }` |
1282
1605
 
1283
1606
  | Method | Description |
1284
1607
  |--------|-------------|
@@ -1287,6 +1610,54 @@ app.get('/me', auth.middleware(), (req, ctx) => Response.json(ctx.user))
1287
1610
  | `.verify(token)` | Verify JWT token |
1288
1611
  | `.middleware()` | JWT verify middleware — sets `ctx.user` |
1289
1612
 
1613
+ ### permissions [α] — RBAC
1614
+
1615
+ Role-based access control.
1616
+
1617
+ ```ts
1618
+ const perm = permissions({ pg })
1619
+ await perm.migrate()
1620
+
1621
+ // Assign roles & permissions
1622
+ await perm.assignRole(userId, 'admin')
1623
+ await perm.grantPermission('admin', 'posts:create')
1624
+ await perm.grantPermission('admin', 'posts:edit')
1625
+ await perm.grantPermission('admin', '*') // wildcard — all permissions
1626
+
1627
+ // Use as middleware
1628
+ app.use((req, ctx, next) => { ctx.user = { id: userId }; return next(req, ctx) })
1629
+ app.use(perm) // → ctx.roles, ctx.permissions
1630
+
1631
+ // Route guards
1632
+ app.get('/admin', perm.requireRole('admin'), adminHandler)
1633
+ app.post('/posts', perm.requirePermission('posts:create'), createHandler)
1634
+
1635
+ // Handler-level check
1636
+ app.get('/posts/:id', async (req, ctx) => {
1637
+ if (!ctx.permissions.has('posts:read')) {
1638
+ return Response.json({ error: 'forbidden' }, { status: 403 })
1639
+ }
1640
+ return Response.json(post)
1641
+ })
1642
+ ```
1643
+
1644
+ | Option | Type | Default | Description |
1645
+ |--------|------|---------|-------------|
1646
+ | `pg` | `object` | — | PostgreSQL client |
1647
+ | `prefix` | `string` | `''` | Table prefix (e.g. `'myapp'` → `myapp_roles`) |
1648
+
1649
+ | Method | Description |
1650
+ |--------|-------------|
1651
+ | `.assignRole(userId, role)` | Assign role to user (creates role if missing) |
1652
+ | `.removeRole(userId, role)` | Remove role from user |
1653
+ | `.grantPermission(role, permission)` | Grant permission to role |
1654
+ | `.revokePermission(role, permission)` | Revoke permission from role |
1655
+ | `.getUserRoles(userId)` | List user's roles |
1656
+ | `.getUserPermissions(userId)` | List user's permissions (union of all roles) |
1657
+ | `.requireRole(...roles)` | Middleware — rejects if user lacks any of the roles |
1658
+ | `.requirePermission(...perms)` | Middleware — rejects if user lacks any permission |
1659
+ | `.migrate()` | Create tables |
1660
+
1290
1661
  ### validate [α]
1291
1662
 
1292
1663
  ```ts
@@ -1412,7 +1783,7 @@ const count = useStore(s => s.count)
1412
1783
  <Head><title>Page Title</title><meta name="description" content="..." /></Head>
1413
1784
  ```
1414
1785
 
1415
- **`TsxContext`** — React context holding page data (`params`, `query`, `user`, `parsed`, `prefs`, `env`). Used internally by hooks; rarely needed directly.
1786
+ **`TsxContext`** — React context holding page data (`params`, `query`, `user`, `parsed`, `theme`, `i18n`, `flash`, `loaderData`, `env`). Used internally by hooks; rarely needed directly.
1416
1787
 
1417
1788
  ### Locale & Theme
1418
1789
 
@@ -1426,7 +1797,7 @@ function LangSwitch() {
1426
1797
 
1427
1798
  | Return | Description |
1428
1799
  |--------|-------------|
1429
- | `locale` | Current locale string (from `ctx.prefs.locale`) |
1800
+ | `locale` | Current locale string (from `ctx.i18n.locale`) |
1430
1801
  | `setLocale(locale)` | Switch locale (calls `navigate('/__lang/' + locale)`) |
1431
1802
  | `t` | Translate a key using loaded locale messages |
1432
1803
 
@@ -1465,7 +1836,7 @@ function Page() {
1465
1836
  }
1466
1837
  ```
1467
1838
 
1468
- On the server, data flows from middleware → `ctx` → `ctx.loaderData` (serialized). On the client, it's restored from `window.__WEIFUWU_CTX`. Under the hood, `useLoaderData()` uses `AsyncLocalStorage` on the server and `window.__WEIFUWU_CTX` on the client — no SSR-specific code needed in your components.
1839
+ On the server, data flows from middleware → `ctx` → `setCtx(ctxValue)` (serialized via JSON). On the client, the hydration script calls `setCtx(ctxData)` which populates the shared store (`globalThis.__WEIFUWU_CTX_STORE`). `useLoaderData()` reads from the snapshot via `useSyncExternalStore` — no SSR-specific code needed in your components.
1469
1840
 
1470
1841
  **`addInterceptor(fn)`** — Register a URL interceptor. Interceptors run before SPA navigation; if one returns `true`, `navigate()` skips the fetch-and-swap.
1471
1842
 
@@ -1481,8 +1852,21 @@ addInterceptor(async (url) => {
1481
1852
  ### Flash messages
1482
1853
 
1483
1854
  ```ts
1484
- // Server — set flash cookie on redirect, auto-cleared after first read
1485
- return ctx.setPref('flash', JSON.stringify({ type: 'success', message: 'Done' })) // 302 + Set-Cookie
1855
+ import { flash } from 'weifuwu'
1856
+
1857
+ app.use(flash())
1858
+
1859
+ // Read flash
1860
+ app.get('/', (req, ctx) => {
1861
+ const msg = ctx.flash.value // { type: 'success', text: 'Saved!' }
1862
+ })
1863
+
1864
+ // Set flash + redirect
1865
+ app.post('/save', async (req, ctx) => {
1866
+ await saveArticle()
1867
+ return ctx.flash.set({ type: 'success', text: '已保存' }, '/articles')
1868
+ // → 302 /articles + Set-Cookie flash=...
1869
+ })
1486
1870
  ```
1487
1871
 
1488
1872
  ```tsx
@@ -1490,36 +1874,91 @@ return ctx.setPref('flash', JSON.stringify({ type: 'success', message: 'Done' })
1490
1874
  import { useFlashMessage } from 'weifuwu/react'
1491
1875
 
1492
1876
  function Toast() {
1493
- const flash = useFlashMessage<{ type: string; message: string }>()
1877
+ const flash = useFlashMessage<{ type: string; text: string }>()
1494
1878
  if (!flash) return null
1495
- return <div className={`toast toast-${flash.type}`}>{flash.message}</div>
1879
+ return <div className={`toast toast-${flash.type}`}>{flash.text}</div>
1496
1880
  }
1497
1881
  ```
1498
1882
 
1883
+ | Option | Type | Default | Description |
1884
+ |--------|------|---------|-------------|
1885
+ | `name` | `string` | `'flash'` | Cookie name |
1886
+
1499
1887
  ### Dev mode
1500
1888
 
1501
- Auto-detected when `NODE_ENV !== 'production'`. `ssr({dir})` automatically registers vendor bundle, HMR WebSocket, and file watcher. No explicit setup needed.
1889
+ Auto-detected when `NODE_ENV === 'development'`. `ssr({dir})` automatically registers importmap, vendor bundle, HMR WebSocket, and file watcher. No explicit setup needed.
1502
1890
 
1503
- When a `.tsx` or `.css` file changes under the `ssr` dir, the browser hot-updates without refreshing — `useState` values are preserved. Layout changes trigger a full page reload.
1891
+ - Inline hydration script uses `createRoot` + render (replaces SSR DOM)
1892
+ - Vendor bundle served at `/__wfw/v/bundle?h=<hash>` — compiled from source, unminified
1893
+ - Hot component replacement: file changes → WebSocket message → browser imports hot bundle → `__WFW_REFRESH(NewComponent)` — `useState` values preserved
1894
+ - Tailwind CSS hot-reloads without page refresh
1895
+ - Layout changes trigger a full page reload
1504
1896
 
1505
1897
  ---
1506
1898
 
1507
1899
  ## AI
1508
1900
 
1509
1901
  ```ts
1510
- import { openai, streamText, generateText, streamObject, generateObject, tool, embed, embedMany } from 'weifuwu'
1902
+ import { openai, streamText, generateText, streamObject, generateObject, tool, embed, embedMany, aiProvider } from 'weifuwu'
1511
1903
  import { runWorkflow } from 'weifuwu'
1904
+
1905
+ const provider = aiProvider()
1512
1906
  ```
1513
1907
 
1514
1908
  For AI streaming endpoints see [`aiStream`](#aistream-β). For AI agent APIs see [`agent`](#agent-β).
1515
1909
 
1910
+ ### aiProvider [α] — AI model & embedding configuration
1911
+
1912
+ ```ts
1913
+ const provider = aiProvider() // auto from env
1914
+ app.use(provider) // → ctx.ai
1915
+
1916
+ // Handler
1917
+ app.post('/ask', async (req, ctx) => {
1918
+ const { text } = await ctx.ai.generateText({ prompt: 'hello' })
1919
+ const vec = await ctx.ai.embed('some text')
1920
+ const stream = ctx.ai.streamText({ system: 'assistant', messages: [...] })
1921
+ return stream.toTextStreamResponse()
1922
+ })
1923
+ ```
1924
+
1925
+ | Option | Type | Default | Description |
1926
+ |--------|------|---------|-------------|
1927
+ | `baseURL` | `string` | `OPENAI_BASE_URL` env or `http://localhost:11434/v1` | API base URL |
1928
+ | `apiKey` | `string` | `OPENAI_API_KEY` env or `'ollama'` | API key |
1929
+ | `model` | `string` | `OPENAI_MODEL` env or `'qwen3:0.6b'` | Chat model name |
1930
+ | `embeddingModel` | `string` | `OPENAI_EMBEDDING_MODEL` env or `'qwen3-embedding:0.6b'` | Embedding model name |
1931
+ | `embeddingDimension` | `number` | `EMBEDDING_DIMENSION` env or `1024` | Vector dimension |
1932
+
1933
+ | Method | Description |
1934
+ |--------|-------------|
1935
+ | `.model(name?)` | Get `LanguageModel` instance |
1936
+ | `.embeddingModel(name?)` | Get `EmbeddingModel` instance |
1937
+ | `.embed(text)` | Embed single text → `Promise<number[]>` |
1938
+ | `.embedMany(texts)` | Batch embed → `Promise<number[][]>` |
1939
+ | `.generateText(params)` | Generate text (model auto-injected) |
1940
+ | `.streamText(params)` | Stream text (model auto-injected) |
1941
+ | `.dimension` | Configured embedding dimension |
1942
+
1516
1943
  ### DAG Workflow
1517
1944
 
1518
1945
  ```ts
1519
1946
  const tools = { queryUser: tool({ ... }) }
1520
- const wf = runWorkflow({ tools })
1947
+
1948
+ // Via provider:
1949
+ const wf = runWorkflow({ tools, provider })
1950
+
1951
+ // Or explicit model:
1952
+ const wf = runWorkflow({ tools, model: openai('gpt-4o') })
1521
1953
  ```
1522
1954
 
1955
+ | Option | Type | Default | Description |
1956
+ |--------|------|---------|-------------|
1957
+ | `tools` | `object` | — | Registered tool definitions |
1958
+ | `provider` | `AIProvider` | — | AI provider (uses `provider.model()` for LLM-generated workflow) |
1959
+ | `model` | `LanguageModel` | — | Explicit model (overrides provider) |
1960
+ | `maxSteps` | `number` | `200` | Max execution steps |
1961
+
1523
1962
  ---
1524
1963
 
1525
1964
  ## Server-Sent Events
@@ -1549,7 +1988,7 @@ currentTraceId, currentTrace, runWithTrace, traceElapsed, TraceContext,
1549
1988
 
1550
1989
  ```ts
1551
1990
  auth, cors, csrf, compress, helmet, logger, rateLimit, requestId, validate, upload,
1552
- preferences, serveStatic, session, MemoryStore, RedisStore, SessionStore,
1991
+ theme, i18n, flash, permissions, serveStatic, session, MemoryStore, RedisStore, SessionStore,
1553
1992
  cache, MemoryCache, RedisCache, CacheStore
1554
1993
  ```
1555
1994
 
@@ -1571,7 +2010,7 @@ eq, ne, gt, gte, lt, lte, isNull, isNotNull, like, contains, in_, and, or, not
1571
2010
  ### Client-side (from `'weifuwu/react'`)
1572
2011
 
1573
2012
  ```ts
1574
- TsxContext, useLoaderData,
2013
+ TsxContext, setCtx, useCtx, addCtxRebuilder, useLoaderData,
1575
2014
  useWebsocket, useAction, useFetch, useQueryState, createStore,
1576
2015
  Link, useNavigate, useNavigating, addInterceptor,
1577
2016
  useLocale, useTheme, applyTheme, useFlashMessage,
@@ -1580,6 +2019,12 @@ Head
1580
2019
  ```
1581
2020
  export type { UseAgentStreamOptions, UseAgentStreamReturn, AgentStreamState } from 'weifuwu/react'
1582
2021
 
2022
+ ### AI Provider (framework abstraction)
2023
+
2024
+ ```ts
2025
+ aiProvider, AIProvider, AIProviderOptions
2026
+ ```
2027
+
1583
2028
  ### AI SDK (re-exported from `ai`)
1584
2029
 
1585
2030
  ```ts
@@ -1591,8 +2036,8 @@ openai, createOpenAI
1591
2036
  ### Other modules
1592
2037
 
1593
2038
  ```ts
1594
- preferences, health, analytics, seo, seoMiddleware, seoTags,
1595
- user, mailer, graphql, aiStream, runWorkflow,
2039
+ theme, i18n, flash, health, analytics, seo, seoMiddleware, seoTags,
2040
+ user, mailer, graphql, aiStream, runWorkflow, knowledgeBase, permissions, queue,
1596
2041
  logdb, messager, agent, iii, createWorker, registerWorker,
1597
2042
  opencode, deploy, defineConfig, webhook,
1598
2043
  testApp, TestApp, TestRequest, TestResponse,