weifuwu 0.16.2 → 0.16.4

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