weifuwu 0.17.9 → 0.17.10
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 +303 -1091
- package/cli.ts +18 -5
- package/dist/cli.js +19 -6
- package/dist/client-router.d.ts +1 -1
- package/dist/index.js +41 -45
- package/dist/router.d.ts +3 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -5,1304 +5,544 @@ description: Web-standard HTTP framework for Node.js — (req, ctx) => Response
|
|
|
5
5
|
|
|
6
6
|
# weifuwu
|
|
7
7
|
|
|
8
|
-
**Web-standard HTTP framework for Node.js.** `(req, ctx) => Response` — no framework-specific objects
|
|
9
|
-
|
|
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
|
-
- [WebSocket & Real-time](#websocket--real-time)
|
|
18
|
-
- [AI](#ai)
|
|
19
|
-
- [Data Layer](#data-layer)
|
|
20
|
-
- [iii — Worker / Function / Trigger](#iii--worker--function--trigger)
|
|
21
|
-
- [Multi-tenant BaaS](#multi-tenant-baas)
|
|
22
|
-
- [Messager](#messager)
|
|
23
|
-
- [LogDB](#logdb)
|
|
24
|
-
- [SEO](#seo)
|
|
25
|
-
- [Opencode](#opencode)
|
|
26
|
-
- [Deploy](#deploy)
|
|
27
|
-
- [Health check](#health-check)
|
|
28
|
-
- [Preferences](#preferences)
|
|
29
|
-
- [Email](#email)
|
|
30
|
-
- [Server-Sent Events](#server-sent-events)
|
|
31
|
-
- [Utility functions](#utility-functions)
|
|
32
|
-
- [Testing](#testing)
|
|
33
|
-
- [License](#license)
|
|
8
|
+
**Web-standard HTTP framework for Node.js.** `(req, ctx) => Response` — no framework-specific objects.
|
|
34
9
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
## Quick start
|
|
38
|
-
|
|
39
|
-
### Hello World
|
|
10
|
+
## Quick Start
|
|
40
11
|
|
|
41
12
|
```ts
|
|
42
13
|
import { serve } from 'weifuwu'
|
|
43
14
|
serve((req, ctx) => new Response('Hello, World!'), { port: 3000 })
|
|
44
15
|
```
|
|
45
16
|
|
|
46
|
-
### Full-stack SSR in one file
|
|
47
|
-
|
|
48
17
|
```ts
|
|
49
18
|
import { serve, Router, tsx, preferences } from 'weifuwu'
|
|
50
|
-
|
|
51
19
|
const app = new Router()
|
|
52
|
-
app.use(preferences({
|
|
53
|
-
dir: './locales', // i18n (0 extra deps)
|
|
54
|
-
theme: {}, // dark mode (0 extra deps)
|
|
55
|
-
}))
|
|
20
|
+
app.use(preferences({ dir: './locales' }))
|
|
56
21
|
app.use('/', await tsx({ dir: './ui' }))
|
|
57
|
-
|
|
58
22
|
serve(app.handler(), { port: 3000 })
|
|
59
23
|
```
|
|
60
24
|
|
|
61
|
-
Your tsx pages can use it directly, and locale/theme switching routes (`/__lang/zh`, `/__theme/dark`) work automatically without extra routes:
|
|
62
|
-
|
|
63
|
-
```tsx
|
|
64
|
-
import { Head, useCtx, useData, createStore } from 'weifuwu/react'
|
|
65
|
-
|
|
66
|
-
export default function Page() {
|
|
67
|
-
const { t, theme } = useCtx() // i18n + theme
|
|
68
|
-
const { data } = useData('/api/list') // data fetching
|
|
69
|
-
return <h1>{t('hello')} / {theme}</h1>
|
|
70
|
-
}
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
**Zero extra dependencies** — no zustand, react-query, next-intl, next-themes, react-hot-toast needed.
|
|
74
|
-
|
|
75
|
-
### weifuwu init
|
|
76
|
-
|
|
77
25
|
```bash
|
|
78
|
-
npx weifuwu init my-app
|
|
79
|
-
cd my-app && npm install && npm run dev
|
|
26
|
+
npx weifuwu init my-app && cd my-app && npm run dev
|
|
80
27
|
```
|
|
81
28
|
|
|
82
29
|
---
|
|
83
30
|
|
|
84
|
-
##
|
|
31
|
+
## Core Concepts
|
|
85
32
|
|
|
86
|
-
|
|
87
|
-
import { serve } from 'weifuwu'
|
|
88
|
-
import type { Server } from 'weifuwu'
|
|
33
|
+
### serve()
|
|
89
34
|
|
|
35
|
+
```ts
|
|
90
36
|
const server = serve(handler, { port: 3000 })
|
|
91
37
|
await server.ready
|
|
92
|
-
console.log(`Listening on http://localhost:${server.port}`)
|
|
93
38
|
```
|
|
94
39
|
|
|
95
|
-
### Options
|
|
96
|
-
|
|
97
40
|
| Option | Type | Default | Description |
|
|
98
41
|
|--------|------|---------|-------------|
|
|
99
|
-
| `port` | `number` | `0`
|
|
42
|
+
| `port` | `number` | `0` | Listen port |
|
|
100
43
|
| `hostname` | `string` | `'0.0.0.0'` | Listen address |
|
|
101
44
|
| `signal` | `AbortSignal` | — | Shutdown on abort |
|
|
102
45
|
| `websocket` | `WsUpgradeHandler` | — | WebSocket upgrade handler |
|
|
103
|
-
| `maxBodySize` | `number` | — | Max
|
|
104
|
-
| `shutdown` | `boolean` | `true` | Auto
|
|
105
|
-
|
|
106
|
-
Graceful shutdown is **enabled by default** — `serve()` registers `SIGTERM` and `SIGINT` handlers that call `server.close()`. Set `shutdown: false` to disable.
|
|
107
|
-
|
|
108
|
-
### Server
|
|
109
|
-
|
|
110
|
-
```ts
|
|
111
|
-
interface Server {
|
|
112
|
-
stop: () => void
|
|
113
|
-
readonly port: number
|
|
114
|
-
readonly hostname: string
|
|
115
|
-
ready: Promise<void>
|
|
116
|
-
}
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
### createTestServer
|
|
46
|
+
| `maxBodySize` | `number` | — | Max body bytes |
|
|
47
|
+
| `shutdown` | `boolean` | `true` | Auto SIGTERM/SIGINT |
|
|
120
48
|
|
|
121
49
|
```ts
|
|
50
|
+
interface Server { stop: () => void; readonly port: number; readonly hostname: string; ready: Promise<void> }
|
|
122
51
|
const { server, url } = await createTestServer(handler)
|
|
123
|
-
// url = 'http://localhost:PORT'
|
|
124
52
|
```
|
|
125
53
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
## Router
|
|
54
|
+
### Router
|
|
129
55
|
|
|
130
56
|
```ts
|
|
131
|
-
import { Router } from 'weifuwu'
|
|
132
|
-
|
|
133
57
|
const app = new Router()
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
})
|
|
141
|
-
```
|
|
58
|
+
app.get('/hello/:name', (req, ctx) => Response.json({ message: `Hello, ${ctx.params.name}!` }))
|
|
59
|
+
app.post('/data', async (req, ctx) => { const body = await req.json(); return Response.json(body, { status: 201 }) })
|
|
60
|
+
app.use('/admin', authMW) // path-scoped middleware
|
|
61
|
+
app.use('/admin', adminRouter) // sub-router (flattened into parent trie)
|
|
62
|
+
app.ws('/echo', { open(ws) { ws.send('connected') }, message(ws, _ctx, data) { ws.send(`echo: ${data}`) } })
|
|
63
|
+
app.onError((err, req, ctx) => Response.json({ error: err.message }, { status: 500 }))
|
|
142
64
|
|
|
143
|
-
|
|
65
|
+
const handler = app.handler()
|
|
66
|
+
const wsHandler = app.websocketHandler()
|
|
67
|
+
serve(handler, { port: 3000, websocket: wsHandler })
|
|
68
|
+
```
|
|
144
69
|
|
|
145
70
|
| Pattern | Example | Match |
|
|
146
71
|
|---------|---------|-------|
|
|
147
|
-
| Static | `/about` |
|
|
148
|
-
| Param | `/users/:id` | `/users/42` → `ctx.params.id
|
|
72
|
+
| Static | `/about` | exact |
|
|
73
|
+
| Param | `/users/:id` | `/users/42` → `ctx.params.id` |
|
|
149
74
|
| Wildcard | `/static/*` | `/static/js/app.js` |
|
|
150
75
|
|
|
151
|
-
Query params
|
|
76
|
+
Query params → `ctx.query`.
|
|
152
77
|
|
|
153
|
-
###
|
|
78
|
+
### Middleware
|
|
154
79
|
|
|
155
80
|
```ts
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
app.use('/admin', admin)
|
|
161
|
-
// Mounts admin routes at /admin/dashboard, etc.
|
|
81
|
+
type Middleware = (req: Request, ctx: Context, next: Handler) => Response | Promise<Response>
|
|
82
|
+
app.use(mw) // global
|
|
83
|
+
app.use('/admin', mw) // path-scoped
|
|
84
|
+
app.get('/admin', mw, handler) // route-level
|
|
162
85
|
```
|
|
163
86
|
|
|
164
|
-
|
|
87
|
+
---
|
|
165
88
|
|
|
166
|
-
|
|
167
|
-
app.ws('/echo', {
|
|
168
|
-
open(ws, ctx) { ws.send('connected') },
|
|
169
|
-
message(ws, ctx, data) { ws.send(`echo: ${data}`) },
|
|
170
|
-
close(ws, ctx) { /* cleanup */ },
|
|
171
|
-
error(ws, ctx, err) { /* log */ },
|
|
172
|
-
})
|
|
89
|
+
## Module Patterns
|
|
173
90
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
91
|
+
All modules follow one of 5 patterns. The pattern letter is marked in each module's heading.
|
|
92
|
+
|
|
93
|
+
| Pattern | How to mount | Example |
|
|
94
|
+
|---------|-------------|---------|
|
|
95
|
+
| `[A]` | `app.use(mod())` | `compress()`, `preferences()` |
|
|
96
|
+
| `[B]` | `app.use(mod())` + call `.stop()` / `.close()` etc. | `rateLimit({...})` |
|
|
97
|
+
| `[C]` | `app.use(mod.middleware())` + `app.use('/', mod.router())` | `analytics()`, `user()` |
|
|
98
|
+
| `[D]` | `app.use(mod().handler())` | `health()`, `seo()` |
|
|
99
|
+
| `[E]` | `app.use('/', g.router().handler())` | `graphql(handler)` |
|
|
100
|
+
|
|
101
|
+
---
|
|
179
102
|
|
|
180
|
-
|
|
103
|
+
## Module Reference
|
|
104
|
+
|
|
105
|
+
### agent [C]
|
|
181
106
|
|
|
182
107
|
```ts
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
)
|
|
108
|
+
const a = agent({ pg })
|
|
109
|
+
await a.migrate()
|
|
110
|
+
app.use('/api', a.router())
|
|
111
|
+
await a.addKnowledge(agentId, 'Title', 'docs')
|
|
112
|
+
a.run(agentId, { task: 'summarize' })
|
|
186
113
|
```
|
|
187
114
|
|
|
188
|
-
|
|
115
|
+
### aiStream [E]
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const chat = await aiStream(async (req) => ({ model: openai('gpt-4o'), messages: (await req.json()).messages }))
|
|
119
|
+
app.use('/chat', chat.router().handler())
|
|
120
|
+
```
|
|
189
121
|
|
|
190
|
-
|
|
122
|
+
### analytics [C]
|
|
191
123
|
|
|
192
|
-
|
|
124
|
+
In-memory or PostgreSQL page view tracking with built-in dashboard.
|
|
193
125
|
|
|
194
126
|
```ts
|
|
195
|
-
|
|
196
|
-
app.use(
|
|
197
|
-
app.
|
|
127
|
+
const a = analytics()
|
|
128
|
+
app.use(a.middleware())
|
|
129
|
+
app.use('/', a.router()) // GET /__analytics (dashboard), GET /__analytics/data?days=7 (JSON)
|
|
198
130
|
```
|
|
199
131
|
|
|
200
|
-
|
|
|
201
|
-
|
|
202
|
-
| `
|
|
203
|
-
| `
|
|
204
|
-
| `csrf(options?)` | Double-submit cookie CSRF protection |
|
|
205
|
-
| `logger(options?)` | Request logging with duration |
|
|
206
|
-
| `rateLimit(options?)` | In-memory rate limiting with headers |
|
|
207
|
-
| `compress(options?)` | Brotli / Gzip / Deflate compression |
|
|
208
|
-
| `validate(schemas)` | Zod validation (body, query, params) |
|
|
209
|
-
| `upload(options?)` | Multipart file upload |
|
|
210
|
-
| `preferences(options?)` | Locale + theme detection, `ctx.t()`, `ctx.prefs`, `ctx.setPref()` |
|
|
211
|
-
| `seoMiddleware(options?)` | `X-Robots-Tag` header — string or path-based function |
|
|
212
|
-
| `helmet(options?)` | Security headers — CSP, HSTS, X-Frame-Options, etc. |
|
|
213
|
-
| `requestId(options?)` | `X-Request-ID` header + `ctx.requestId` |
|
|
214
|
-
| `health(options?)` | `GET /health` endpoint with custom checks |
|
|
215
|
-
|
|
216
|
-
### auth
|
|
132
|
+
| Option | Type | Default | Description |
|
|
133
|
+
|--------|------|---------|-------------|
|
|
134
|
+
| `pg` | `object` | — | PostgreSQL client for persistence |
|
|
135
|
+
| `excluded` | `string[]` | `['/__analytics', '/__wfw', '/static']` | Paths to skip |
|
|
217
136
|
|
|
218
137
|
```ts
|
|
219
|
-
|
|
138
|
+
// With PostgreSQL
|
|
139
|
+
const a = analytics({ pg })
|
|
140
|
+
await a.migrate()
|
|
141
|
+
app.use(a.middleware())
|
|
142
|
+
app.use('/', a.router())
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### auth [A]
|
|
220
146
|
|
|
147
|
+
```ts
|
|
221
148
|
app.use(auth({ token: 'sk-123' })) // static token
|
|
222
149
|
app.use(auth({ header: 'X-API-Key', token: 'my-key' })) // custom header
|
|
223
150
|
app.use(auth({ verify: async (token) => ({ sub: 'abc' }) })) // custom verify
|
|
224
151
|
app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
|
|
225
152
|
```
|
|
226
153
|
|
|
227
|
-
###
|
|
154
|
+
### compress [A]
|
|
228
155
|
|
|
229
156
|
```ts
|
|
230
|
-
|
|
157
|
+
app.use(compress()) // brotli > gzip > deflate
|
|
158
|
+
app.use(compress({ threshold: 2048 })) // only > 2KB
|
|
159
|
+
```
|
|
231
160
|
|
|
232
|
-
|
|
233
|
-
|
|
161
|
+
### cors [A]
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
app.use(cors()) // allow all
|
|
165
|
+
app.use(cors({ origin: ['https://example.com'] })) // whitelist
|
|
234
166
|
app.use(cors({ origin: (o) => o.endsWith('.trusted.com') && o }))
|
|
235
167
|
app.use(cors({ credentials: true, maxAge: 3600 }))
|
|
236
168
|
```
|
|
237
169
|
|
|
238
|
-
### csrf
|
|
239
|
-
|
|
240
|
-
Double-submit cookie pattern. Sets `_csrf` cookie on GET, validates `X-CSRF-Token` header (or `_csrf` body field) on POST/PUT/DELETE/PATCH.
|
|
170
|
+
### csrf [A]
|
|
241
171
|
|
|
242
172
|
```ts
|
|
243
|
-
import { csrf } from 'weifuwu'
|
|
244
|
-
|
|
245
173
|
app.use(csrf())
|
|
246
|
-
|
|
247
174
|
// ctx.csrfToken available in handlers
|
|
248
|
-
|
|
249
|
-
return new Response(`<input name="_csrf" value="${ctx.csrfToken}" hidden />`, {
|
|
250
|
-
headers: { 'content-type': 'text/html' },
|
|
251
|
-
})
|
|
252
|
-
})
|
|
175
|
+
// Auto-validates X-CSRF-Token header on POST/PUT/DELETE/PATCH
|
|
253
176
|
```
|
|
254
177
|
|
|
255
178
|
| Option | Default | Description |
|
|
256
179
|
|--------|---------|-------------|
|
|
257
180
|
| `cookie` | `'_csrf'` | Cookie name |
|
|
258
181
|
| `header` | `'x-csrf-token'` | Header name |
|
|
259
|
-
| `key` | `'_csrf'` | Body field
|
|
260
|
-
| `excludeMethods` | `['GET',
|
|
182
|
+
| `key` | `'_csrf'` | Body field fallback |
|
|
183
|
+
| `excludeMethods` | `['GET','HEAD','OPTIONS']` | Skip validation |
|
|
261
184
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
### logger
|
|
265
|
-
|
|
266
|
-
```ts
|
|
267
|
-
import { logger } from 'weifuwu'
|
|
268
|
-
|
|
269
|
-
app.use(logger()) // GET /hello 200 5ms
|
|
270
|
-
app.use(logger({ format: 'combined' })) // with query params
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
### rateLimit
|
|
185
|
+
### deploy
|
|
274
186
|
|
|
275
187
|
```ts
|
|
276
|
-
import {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
app.get('/api', rateLimit({ max: 10 }), handler) // per-route
|
|
280
|
-
app.use(rateLimit({ key: (req) => req.headers.get('x-api-key') ?? 'anonymous' }))
|
|
188
|
+
import { deploy, defineConfig } from 'weifuwu'
|
|
189
|
+
const config = defineConfig({ apps: [{ name: 'api', dir: './api', domain: 'api.example.com', port: 3001 }] })
|
|
190
|
+
await deploy(config)
|
|
281
191
|
```
|
|
282
192
|
|
|
283
|
-
###
|
|
193
|
+
### health [D]
|
|
284
194
|
|
|
285
195
|
```ts
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
app.use(compress()) // brotli > gzip > deflate
|
|
289
|
-
app.use(compress({ threshold: 2048 })) // only > 2KB
|
|
196
|
+
app.use(health()) // GET /health → 200
|
|
197
|
+
app.use(health({ checks: { db: async () => { await pg.sql`SELECT 1`; return { ok: true } } } }))
|
|
290
198
|
```
|
|
291
199
|
|
|
292
|
-
###
|
|
293
|
-
|
|
294
|
-
```ts
|
|
295
|
-
import { z } from 'zod'
|
|
296
|
-
import { validate } from 'weifuwu'
|
|
297
|
-
|
|
298
|
-
const CreateUser = z.object({ name: z.string().min(1), email: z.string().email() })
|
|
299
|
-
router.post('/users', validate({ body: CreateUser }), (req, ctx) => {
|
|
300
|
-
// ctx.parsed.body — typed & validated
|
|
301
|
-
})
|
|
302
|
-
```
|
|
200
|
+
### helmet [A]
|
|
303
201
|
|
|
304
|
-
|
|
202
|
+
13 security headers: CSP, HSTS, X-Frame-Options, X-Content-Type-Options, etc.
|
|
305
203
|
|
|
306
204
|
```ts
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
router.post('/upload', upload({ dir: './uploads', maxFileSize: 10_485_760 }), (req, ctx) => {
|
|
310
|
-
// ctx.parsed.files.avatar → { name, type, size, path }
|
|
311
|
-
// ctx.parsed.fields.title → 'hello'
|
|
312
|
-
})
|
|
205
|
+
app.use(helmet())
|
|
206
|
+
app.use(helmet({ contentSecurityPolicy: "default-src 'self'", xFrameOptions: 'DENY' }))
|
|
313
207
|
```
|
|
314
208
|
|
|
315
|
-
###
|
|
209
|
+
### iii [C] — Worker / Function / Trigger
|
|
316
210
|
|
|
317
211
|
```ts
|
|
318
|
-
|
|
212
|
+
const engine = iii({ pg, redis })
|
|
213
|
+
app.use('/iii', engine.router())
|
|
319
214
|
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
215
|
+
const w = createWorker('orders')
|
|
216
|
+
w.registerFunction('orders::create', async (payload) => db.query('INSERT INTO orders ...', [payload.items]))
|
|
217
|
+
engine.addWorker(w)
|
|
218
|
+
await engine.trigger({ function_id: 'orders::create', payload: { items: ['apple'] } })
|
|
324
219
|
```
|
|
325
220
|
|
|
326
|
-
|
|
|
327
|
-
|
|
328
|
-
| `
|
|
329
|
-
|
|
|
330
|
-
| `
|
|
331
|
-
| `expires` | `Date` | Expiration |
|
|
332
|
-
| `httpOnly` | `boolean` | Not accessible to JS |
|
|
333
|
-
| `secure` | `boolean` | HTTPS only |
|
|
334
|
-
| `sameSite` | `'strict' \| 'lax' \| 'none'` | SameSite policy |
|
|
335
|
-
|
|
336
|
-
### serveStatic
|
|
221
|
+
| Method | Description |
|
|
222
|
+
|--------|-------------|
|
|
223
|
+
| `.addWorker(w)` | Register a worker |
|
|
224
|
+
| `.trigger({ function_id, payload, action? })` | Invoke a function (sync or void) |
|
|
225
|
+
| `.router()` | REST + WS API |
|
|
337
226
|
|
|
338
|
-
|
|
339
|
-
import { serveStatic } from 'weifuwu'
|
|
340
|
-
|
|
341
|
-
router.get('/static/*', serveStatic('./public'))
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
20+ MIME types, ETag + 304, directory index, path traversal protection, Cache-Control.
|
|
227
|
+
### logdb [C]
|
|
345
228
|
|
|
346
|
-
|
|
229
|
+
PostgreSQL structured event logging with monthly partitioning.
|
|
347
230
|
|
|
348
231
|
```ts
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
app.use(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
contentSecurityPolicy: "default-src 'self'",
|
|
355
|
-
xFrameOptions: 'DENY',
|
|
356
|
-
strictTransportSecurity: 'max-age=63072000; includeSubDomains; preload',
|
|
357
|
-
}))
|
|
232
|
+
const logger = logdb({ pg })
|
|
233
|
+
await logger.migrate()
|
|
234
|
+
app.use('/logs', logger.router())
|
|
235
|
+
await logger.clean(12) // drop partitions older than 12 months
|
|
236
|
+
await logger.log({ level: 'info', source: 'app', message: 'hello' })
|
|
358
237
|
```
|
|
359
238
|
|
|
360
|
-
|
|
239
|
+
| Method | Path | Description |
|
|
240
|
+
|--------|------|-------------|
|
|
241
|
+
| POST | `/` | Create log entry |
|
|
242
|
+
| GET | `/` | Query (`?level=`, `?source=`, `?after=`, `?before=`, `?meta.*=`) |
|
|
243
|
+
| GET | `/:id` | Get single entry |
|
|
361
244
|
|
|
362
|
-
###
|
|
245
|
+
### logger [A]
|
|
363
246
|
|
|
364
247
|
```ts
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
app.use(requestId())
|
|
368
|
-
// Sets X-Request-ID header on responses, available as ctx.requestId
|
|
248
|
+
app.use(logger()) // GET /hello 200 5ms
|
|
249
|
+
app.use(logger({ format: 'combined' })) // with query params
|
|
369
250
|
```
|
|
370
251
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
## React SSR (tsx)
|
|
252
|
+
### mailer
|
|
374
253
|
|
|
375
254
|
```ts
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const app = new Router()
|
|
379
|
-
app.use('/', await tsx({ dir: './ui/' }))
|
|
380
|
-
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
### Directory structure
|
|
384
|
-
|
|
385
|
-
```
|
|
386
|
-
ui/
|
|
387
|
-
├── pages/
|
|
388
|
-
│ ├── page.tsx → GET / (React component)
|
|
389
|
-
│ ├── layout.tsx → root layout (HTML shell, receives req/ctx)
|
|
390
|
-
│ ├── not-found.tsx → 404 page
|
|
391
|
-
│ ├── about/page.tsx → GET /about
|
|
392
|
-
│ ├── blog/[slug]/
|
|
393
|
-
│ │ ├── page.tsx → GET /blog/:slug
|
|
394
|
-
│ │ ├── load.ts → data fetching (server-only)
|
|
395
|
-
│ │ └── route.ts → POST /blog/:slug (API, named exports)
|
|
396
|
-
│ ├── blog/layout.tsx → /blog/* layout (UI structure, hydrated)
|
|
397
|
-
│ └── api/search/
|
|
398
|
-
│ └── route.ts → GET /api/search
|
|
399
|
-
└── components/
|
|
400
|
-
└── button.tsx
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
### page.tsx — page component
|
|
404
|
-
|
|
405
|
-
Components receive `{ params, query }` from routing and can use hooks for
|
|
406
|
-
context, data fetching, state, URL sync, and meta tags (see [exports table](#react-exports-weifuwureact)):
|
|
407
|
-
|
|
408
|
-
```tsx
|
|
409
|
-
import { Head, useCtx, useData, createStore, useQueryState } from 'weifuwu/react'
|
|
410
|
-
|
|
411
|
-
const useFilters = createStore({ category: '' })
|
|
412
|
-
|
|
413
|
-
export default function Page({ params, query }: { params: { slug: string }; query: Record<string, string> }) {
|
|
414
|
-
const { t, locale, theme } = useCtx() // i18n + theme + prefs
|
|
415
|
-
const [page, setPage] = useQueryState('page', '1') // URL sync
|
|
416
|
-
const { data, loading, mutate } = useData(`/api/posts?page=${page}`) // data fetching
|
|
417
|
-
|
|
418
|
-
return (
|
|
419
|
-
<>
|
|
420
|
-
<Head><title>{t('page.title')}</title></Head>
|
|
421
|
-
{loading ? <Skeleton /> : data.posts.map(p => <Card key={p.id} />)}
|
|
422
|
-
</>
|
|
423
|
-
)
|
|
424
|
-
}
|
|
425
|
-
```
|
|
426
|
-
|
|
427
|
-
### load.ts — data fetching (server-only)
|
|
428
|
-
|
|
429
|
-
```ts
|
|
430
|
-
export default async function load({ params, query }: {
|
|
431
|
-
params: Record<string, string>
|
|
432
|
-
query: Record<string, string>
|
|
433
|
-
}) {
|
|
434
|
-
const data = await db.query(params.slug)
|
|
435
|
-
return { data } // merged into page component props
|
|
436
|
-
}
|
|
437
|
-
```
|
|
438
|
-
|
|
439
|
-
### layout.tsx
|
|
440
|
-
|
|
441
|
-
**Root layout** (`pages/layout.tsx`) — receives `{ children, req, ctx }`:
|
|
442
|
-
|
|
443
|
-
> 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.
|
|
444
|
-
|
|
445
|
-
```tsx
|
|
446
|
-
export default function RootLayout({ children, req, ctx }: {
|
|
447
|
-
children: React.ReactNode
|
|
448
|
-
req: Request
|
|
449
|
-
ctx: Context
|
|
450
|
-
}) {
|
|
451
|
-
return (
|
|
452
|
-
<html>
|
|
453
|
-
<head><title>App</title></head>
|
|
454
|
-
<body><main>{children}</main></body>
|
|
455
|
-
</html>
|
|
456
|
-
)
|
|
457
|
-
}
|
|
255
|
+
const mail = mailer({ host: 'smtp.example.com', port: 587, auth: { user, pass } })
|
|
256
|
+
await mail.send({ to: 'user@test.com', subject: 'Hello', text: 'Body', html: '<p>Body</p>' })
|
|
458
257
|
```
|
|
459
258
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
Page components access preferences, i18n, and theme via `useCtx()` — see [Preferences](#preferences).
|
|
259
|
+
### messager [C]
|
|
463
260
|
|
|
464
|
-
|
|
261
|
+
Real-time chat with channels, WebSocket, agent routing.
|
|
465
262
|
|
|
466
263
|
```ts
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
```
|
|
472
|
-
|
|
473
|
-
### Public environment variables
|
|
474
|
-
|
|
475
|
-
Prefix with `WEIFUWU_PUBLIC_` for automatic inlining into the client hydration bundle:
|
|
476
|
-
|
|
477
|
-
```bash
|
|
478
|
-
WEIFUWU_PUBLIC_API_URL=https://api.example.com
|
|
479
|
-
```
|
|
480
|
-
|
|
481
|
-
```tsx
|
|
482
|
-
// page.tsx — works on both server and client
|
|
483
|
-
const apiUrl = process.env.WEIFUWU_PUBLIC_API_URL
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
The hydration bundle also injects `self.process = { env: {} }` as a safety net so any `process.env.*` reference in bundled dependencies won't throw.
|
|
487
|
-
|
|
488
|
-
### Streaming SSR
|
|
489
|
-
|
|
490
|
-
HTML is streamed via `TransformStream` — the browser starts rendering before the full page is ready. `<head>` content (theme blocking script, locale data, CSS) is injected at the `</head>` boundary and sent immediately.
|
|
491
|
-
|
|
492
|
-
### Persistent layout
|
|
493
|
-
|
|
494
|
-
The client hydration bundle creates a persistent `App` root that wraps all pages. Client-side navigation via `<Link>` (or `navigate()`) replaces the page component in-place instead of unmounting and re-hydrating. This means:
|
|
495
|
-
- `TsxContext.Provider` stays alive across navigations
|
|
496
|
-
- `createStore()` state persists (no cache-busting on bundle imports)
|
|
497
|
-
- Faster navigation — React only re-renders the page component
|
|
498
|
-
|
|
499
|
-
### Client-side hooks
|
|
500
|
-
|
|
501
|
-
#### useWebsocket — auto-reconnecting WebSocket
|
|
502
|
-
|
|
503
|
-
```tsx
|
|
504
|
-
import { useWebsocket } from 'weifuwu/react'
|
|
505
|
-
|
|
506
|
-
function Chat() {
|
|
507
|
-
const { send, lastMessage, readyState, close, reconnect } = useWebsocket('/ws/chat', {
|
|
508
|
-
onMessage: (data) => console.log('received', data),
|
|
509
|
-
reconnect: { maxRetries: 10, delay: 3000 },
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
return <div>
|
|
513
|
-
<p>Status: {readyState === 1 ? 'Connected' : readyState === 0 ? 'Connecting...' : 'Disconnected'}</p>
|
|
514
|
-
<button onClick={() => send('Hello')}>Send</button>
|
|
515
|
-
{lastMessage && <p>Last: {lastMessage}</p>}
|
|
516
|
-
</div>
|
|
517
|
-
}
|
|
518
|
-
```
|
|
519
|
-
|
|
520
|
-
`url` accepts `string`, `URL`, or `() => string | URL | null` (function form avoids reconnecting on every render). `close()` disables auto-reconnect; `reconnect()` resets retry count.
|
|
521
|
-
|
|
522
|
-
#### useAction — async form submission
|
|
523
|
-
|
|
524
|
-
```tsx
|
|
525
|
-
import { useAction } from 'weifuwu/react'
|
|
526
|
-
|
|
527
|
-
function FeedbackForm() {
|
|
528
|
-
const { submit, data, error, pending, reset } = useAction('/api/feedback', { method: 'POST' })
|
|
529
|
-
|
|
530
|
-
return <form onSubmit={() => submit({ name, email })}>
|
|
531
|
-
<button disabled={pending}>{pending ? 'Saving...' : 'Submit'}</button>
|
|
532
|
-
{error && <p className="text-red-500">{error.message}</p>}
|
|
533
|
-
{data && <p>Saved: {data.id}</p>}
|
|
534
|
-
</form>
|
|
535
|
-
}
|
|
536
|
-
```
|
|
537
|
-
|
|
538
|
-
Auto-serializes JSON, auto-reads `_csrf` cookie and sends as `X-CSRF-Token`. Returns `{ submit, data, error, pending, reset }`. `submit(body?)` returns `Promise<T>`.
|
|
539
|
-
|
|
540
|
-
### Client-side navigation
|
|
541
|
-
|
|
542
|
-
```tsx
|
|
543
|
-
import { Link, useNavigate, useNavigating } from 'weifuwu/react'
|
|
544
|
-
|
|
545
|
-
function Nav() {
|
|
546
|
-
const navigate = useNavigate()
|
|
547
|
-
const loading = useNavigating()
|
|
548
|
-
return (
|
|
549
|
-
<nav className={loading ? 'opacity-50' : ''}>
|
|
550
|
-
<Link href="/about" prefetch>About</Link>
|
|
551
|
-
<button onClick={() => navigate('/contact')}>Contact</button>
|
|
552
|
-
</nav>
|
|
553
|
-
)
|
|
554
|
-
}
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
`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.
|
|
558
|
-
|
|
559
|
-
Language and theme switching URLs (`/__lang/:locale`, `/__theme/:theme`) are handled by `navigate()` without page reload — the preference cookie is set via a JSON request and the context is updated via `setCtx()` (see [Preferences](#preferences)).
|
|
560
|
-
|
|
561
|
-
- `<Link prefetch>` — pre-fetches page data on hover / when entering viewport (200px margin)
|
|
562
|
-
- `useNavigating()` — reactive boolean, `true` while navigation is in-flight
|
|
563
|
-
- `isNavigating()` / `onNavigate(fn)` — non-hook alternatives
|
|
564
|
-
- Scroll position is saved before navigation and restored after the new page renders
|
|
565
|
-
|
|
566
|
-
### Head — per-page meta tags
|
|
567
|
-
|
|
568
|
-
```tsx
|
|
569
|
-
import { Head } from 'weifuwu/react'
|
|
570
|
-
|
|
571
|
-
export default function Page() {
|
|
572
|
-
return (
|
|
573
|
-
<>
|
|
574
|
-
<Head>
|
|
575
|
-
<title>My Page - App</title>
|
|
576
|
-
<meta name="description" content="Page description" />
|
|
577
|
-
<meta property="og:title" content="My Page" />
|
|
578
|
-
</Head>
|
|
579
|
-
<h1>Content</h1>
|
|
580
|
-
</>
|
|
581
|
-
)
|
|
582
|
-
}
|
|
264
|
+
const msg = messager({ pg, agents, redis: redis() })
|
|
265
|
+
await msg.migrate()
|
|
266
|
+
app.ws('/ws', u.middleware(), msg.wsHandler())
|
|
267
|
+
await msg.send(channelId, 'System message', { sender_type: 'system' })
|
|
583
268
|
```
|
|
584
269
|
|
|
585
|
-
|
|
270
|
+
### opencode [C]
|
|
586
271
|
|
|
587
|
-
|
|
272
|
+
AI programming assistant.
|
|
588
273
|
|
|
589
274
|
```ts
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
})) // → 302 with Set-Cookie: flash=...
|
|
595
|
-
})
|
|
596
|
-
```
|
|
597
|
-
|
|
598
|
-
```tsx
|
|
599
|
-
// Client: display flash from preferences
|
|
600
|
-
function Toast() {
|
|
601
|
-
const { prefs } = useCtx()
|
|
602
|
-
const flash = prefs?.flash ? JSON.parse(prefs.flash) : null
|
|
603
|
-
if (!flash) return null
|
|
604
|
-
return <div className={`toast ${flash.type}`}>{flash.message}</div>
|
|
605
|
-
}
|
|
606
|
-
```
|
|
607
|
-
|
|
608
|
-
Flash is read once from the cookie, then automatically cleared on the response. After page refresh the flash is gone.
|
|
609
|
-
|
|
610
|
-
### Client-side state management
|
|
611
|
-
|
|
612
|
-
#### createStore — shared state (replaces Zustand)
|
|
613
|
-
|
|
614
|
-
```tsx
|
|
615
|
-
import { createStore } from 'weifuwu/react'
|
|
616
|
-
|
|
617
|
-
const useStore = createStore({ count: 0, items: [] as string[] })
|
|
618
|
-
|
|
619
|
-
function Counter() {
|
|
620
|
-
const count = useStore(s => s.count) // selector
|
|
621
|
-
const { setState, getState } = useStore() // full state + API
|
|
622
|
-
return <button onClick={() => setState({ count: count + 1 })}>{count}</button>
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function List() {
|
|
626
|
-
const items = useStore(s => s.items)
|
|
627
|
-
return items.map(i => <div>{i}</div>)
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// Outside components:
|
|
631
|
-
useStore.getState()
|
|
632
|
-
useStore.setState({ count: 1 })
|
|
633
|
-
useStore.subscribe(() => {})
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
Uses `useSyncExternalStore` internally. No context provider needed. State persists across client-side navigations (no cache-busting on bundle imports).
|
|
637
|
-
|
|
638
|
-
#### useData — data fetching (replaces React Query / SWR)
|
|
639
|
-
|
|
640
|
-
```tsx
|
|
641
|
-
import { useData } from 'weifuwu/react'
|
|
642
|
-
|
|
643
|
-
// Client-only fetch (shows loading skeleton on first load)
|
|
644
|
-
function PostList() {
|
|
645
|
-
const { data, error, loading, mutate } = useData('/api/posts')
|
|
646
|
-
if (loading) return <Skeleton />
|
|
647
|
-
return <div>{data.posts.map(p => <PostCard key={p.id} post={p} />)}</div>
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// With SSR fallback — data from load.ts, client takes over after hydration
|
|
651
|
-
function PostList({ load }: { load: { posts: Post[] } }) {
|
|
652
|
-
const { data, mutate } = useData('/api/posts', { fallback: load })
|
|
653
|
-
return <div>{data.posts.map(p => <PostCard key={p.id} post={p} />)}</div>
|
|
654
|
-
}
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
In-memory cache with 60s TTL, concurrent request dedup. `mutate(data)` for optimistic updates, `mutate()` for revalidation.
|
|
658
|
-
|
|
659
|
-
#### useQueryState — URL query params
|
|
660
|
-
|
|
661
|
-
```tsx
|
|
662
|
-
import { useQueryState } from 'weifuwu/react'
|
|
663
|
-
|
|
664
|
-
function SearchPage() {
|
|
665
|
-
const [q, setQ] = useQueryState('q', '')
|
|
666
|
-
const [page, setPage] = useQueryState('page', '1')
|
|
667
|
-
const { data } = useData(`/api/search?q=${q}&page=${page}`)
|
|
668
|
-
|
|
669
|
-
return (
|
|
670
|
-
<>
|
|
671
|
-
<input value={q} onChange={e => { setQ(e.target.value); setPage('1') }} />
|
|
672
|
-
<Results items={data?.items} />
|
|
673
|
-
<Pagination page={Number(page)} onChange={setPage} />
|
|
674
|
-
</>
|
|
675
|
-
)
|
|
676
|
-
}
|
|
677
|
-
```
|
|
678
|
-
|
|
679
|
-
Synced with `window.location.search` via `useSyncExternalStore`. Back/forward navigation updates the state. Changes use `history.replaceState` and dispatch a synthetic `popstate` for reactivity.
|
|
680
|
-
|
|
681
|
-
### Development mode
|
|
682
|
-
|
|
683
|
-
Auto-detected when `NODE_ENV !== 'production'`. File watching (`chokidar`), single-file recompilation, WebSocket live reload (`/__weifuwu/livereload`), Tailwind CSS v4 auto-compilation.
|
|
684
|
-
|
|
685
|
-
### Tailwind CSS
|
|
686
|
-
|
|
687
|
-
If `ui/app.css` exists with `@import "tailwindcss"`, it's compiled automatically. If not found, one is created. PostCSS + `@tailwindcss/postcss`, zero config.
|
|
688
|
-
|
|
689
|
-
### shadcn/ui
|
|
690
|
-
|
|
691
|
-
Works out of the box:
|
|
692
|
-
|
|
693
|
-
```bash
|
|
694
|
-
npx shadcn@latest init
|
|
695
|
-
# Style: your preference
|
|
696
|
-
# Base color: your preference
|
|
697
|
-
# CSS file path: ui/app.css
|
|
698
|
-
# Import alias: @/ → ./ui/
|
|
275
|
+
const oc = await opencode({ pg, permissions: { bash: { allow: true }, write: { allow: false } } })
|
|
276
|
+
await oc.migrate()
|
|
277
|
+
app.use('/opencode', await oc.router())
|
|
278
|
+
app.ws('/opencode', oc.wsHandler())
|
|
699
279
|
```
|
|
700
280
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
## PostgreSQL
|
|
281
|
+
### postgres [B]
|
|
704
282
|
|
|
705
283
|
```ts
|
|
706
|
-
import { serve, Router, postgres, pgTable, serial, text, boolean, timestamps, sql } from 'weifuwu'
|
|
707
|
-
|
|
708
284
|
const pg = postgres() // reads DATABASE_URL
|
|
709
|
-
const app = new Router()
|
|
710
285
|
app.use(pg) // injects ctx.sql
|
|
711
286
|
```
|
|
712
287
|
|
|
713
|
-
### Type-safe DDL
|
|
714
|
-
|
|
715
288
|
```ts
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
name: text('name').notNull(),
|
|
719
|
-
email: text('email').unique().notNull(),
|
|
720
|
-
active: boolean('active').default(true),
|
|
721
|
-
...timestamps(),
|
|
722
|
-
})
|
|
723
|
-
|
|
289
|
+
// Type-safe DDL
|
|
290
|
+
const users = pgTable('_users', { id: serial('id').primaryKey(), name: text('name').notNull(), email: text('email').unique().notNull(), active: boolean('active').default(true), ...timestamps() })
|
|
724
291
|
await users.create()
|
|
725
292
|
await users.createIndex('email')
|
|
726
|
-
```
|
|
727
|
-
|
|
728
|
-
### BoundTable CRUD
|
|
729
293
|
|
|
730
|
-
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
const { count, data } = await users.readMany({ role: 'admin' }, { orderBy: { name: 'asc' }, limit: 10 })
|
|
737
|
-
await users.update(1, { name: 'Bob' })
|
|
738
|
-
await users.delete(1)
|
|
739
|
-
|
|
740
|
-
// Upsert
|
|
741
|
-
await users.upsert({ email: 'alice@test.com' }, 'email')
|
|
742
|
-
|
|
743
|
-
// Count
|
|
744
|
-
const total = await users.count()
|
|
745
|
-
const admins = await users.count({ role: 'admin' })
|
|
294
|
+
// BoundTable CRUD
|
|
295
|
+
const t = pg.table('_users', { ... })
|
|
296
|
+
await t.insert({ name: 'Alice' })
|
|
297
|
+
const { count, data } = await t.readMany({ role: 'admin' }, { orderBy: { name: 'asc' }, limit: 10 })
|
|
298
|
+
await t.upsert({ email: 'alice@test.com' }, 'email')
|
|
299
|
+
await pg.transaction(async (tx) => { const users = pg.table('_users', { ... }).withSql(tx); return users.insert({ name: 'Bob' }) })
|
|
746
300
|
```
|
|
747
301
|
|
|
748
|
-
|
|
302
|
+
Where helpers: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `isNull`, `isNotNull`, `like`, `contains`, `in_`, `and`, `or`, `not`.
|
|
749
303
|
|
|
750
|
-
|
|
751
|
-
import { eq, gte, contains, and, or } from 'weifuwu'
|
|
752
|
-
|
|
753
|
-
const { data } = await users.readMany(
|
|
754
|
-
and(eq('role', 'admin'), gte('created_at', '2026-01-01')),
|
|
755
|
-
{ orderBy: { name: 'asc' } },
|
|
756
|
-
)
|
|
757
|
-
```
|
|
304
|
+
### preferences [A]
|
|
758
305
|
|
|
759
|
-
|
|
760
|
-
|--------|-----|---------|
|
|
761
|
-
| `eq(col, val)` | `= $1` | `eq('level', 'error')` |
|
|
762
|
-
| `ne(col, val)` | `!= $1` | `ne('status', 'archived')` |
|
|
763
|
-
| `gt(col, val)` | `> $1` | `gt('age', 18)` |
|
|
764
|
-
| `gte(col, val)` | `>= $1` | `gte('created_at', '2026-01-01')` |
|
|
765
|
-
| `lt(col, val)` | `< $1` | `lt('id', beforeId)` |
|
|
766
|
-
| `lte(col, val)` | `<= $1` | `lte('score', 100)` |
|
|
767
|
-
| `isNull(col)` | `IS NULL` | `isNull('deleted_at')` |
|
|
768
|
-
| `isNotNull(col)` | `IS NOT NULL` | `isNotNull('email')` |
|
|
769
|
-
| `like(col, pattern)` | `LIKE $1` | `like('name', 'Alice%')` |
|
|
770
|
-
| `contains(col, obj)` | `@> $1::jsonb` | `contains('metadata', { service: 'auth' })` |
|
|
771
|
-
| `in_(col, arr)` | `= ANY($1)` | `in_('id', [1, 2, 3])` |
|
|
772
|
-
| `and(...conds)` | `(... AND ...)` | `and(eq('a', 1), eq('b', 2))` |
|
|
773
|
-
| `or(...conds)` | `(... OR ...)` | `or(eq('a', 1), eq('b', 2))` |
|
|
774
|
-
| `not(cond)` | `NOT (...)` | `not(eq('status', 'archived'))` |
|
|
775
|
-
|
|
776
|
-
### Transactions
|
|
306
|
+
Locale detection + theme + translations. `/__lang/:locale` and `/__theme/:theme` auto-routed.
|
|
777
307
|
|
|
778
308
|
```ts
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
})
|
|
309
|
+
app.use(preferences({ dir: './locales', locale: { default: 'en' }, theme: { default: 'system' } }))
|
|
310
|
+
// ctx.prefs.locale, ctx.prefs.theme, ctx.t('key'), ctx.setPref('locale', 'zh')
|
|
311
|
+
// GET /__lang/zh → 302 + Set-Cookie (or JSON if Accept: application/json)
|
|
312
|
+
// GET /__theme/dark → same pattern
|
|
784
313
|
```
|
|
785
314
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
|
789
|
-
|
|
790
|
-
| `
|
|
791
|
-
| `
|
|
792
|
-
| `
|
|
793
|
-
| `
|
|
794
|
-
| `boolean()` | `BOOLEAN` | `boolean` |
|
|
795
|
-
| `timestamptz()` | `TIMESTAMPTZ` | `string` |
|
|
796
|
-
| `jsonb<T>()` | `JSONB` | `T` |
|
|
797
|
-
| `textArray()` | `TEXT[]` | `string[]` |
|
|
798
|
-
| `vector(name, dims)` | `vector(N)` | `number[]` |
|
|
799
|
-
| `timestamps()` | Two TIMESTAMPTZ columns | `{ created_at, updated_at }` |
|
|
800
|
-
|
|
801
|
-
---
|
|
802
|
-
|
|
803
|
-
## Auth
|
|
804
|
-
|
|
805
|
-
```ts
|
|
806
|
-
import { user, postgres } from 'weifuwu'
|
|
807
|
-
|
|
808
|
-
const pg = postgres()
|
|
809
|
-
const auth = user({ pg, jwtSecret: process.env.JWT_SECRET! })
|
|
810
|
-
await auth.migrate()
|
|
811
|
-
|
|
812
|
-
app.use('/auth', auth.router())
|
|
813
|
-
|
|
814
|
-
// POST /auth/register { email, password, name }
|
|
815
|
-
// POST /auth/login { email, password }
|
|
315
|
+
| Option | Default | Description |
|
|
316
|
+
|--------|---------|-------------|
|
|
317
|
+
| `dir` | — | Translation JSON directory |
|
|
318
|
+
| `locale.default` | `'en'` | Fallback locale |
|
|
319
|
+
| `locale.cookie` | `'locale'` | Cookie name |
|
|
320
|
+
| `locale.fromAcceptLanguage` | `true` | Detect from header |
|
|
321
|
+
| `theme.default` | `'system'` | `'light'` \| `'dark'` \| `'system'` |
|
|
322
|
+
| `theme.cookie` | `'theme'` | Cookie name |
|
|
816
323
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
324
|
+
```tsx
|
|
325
|
+
// Client-side no-refresh switching with <Link>
|
|
326
|
+
<Link href="/__lang/zh">中文</Link>
|
|
820
327
|
```
|
|
821
328
|
|
|
822
|
-
###
|
|
329
|
+
### queue [B]
|
|
823
330
|
|
|
824
331
|
```ts
|
|
825
|
-
const
|
|
826
|
-
await
|
|
827
|
-
|
|
828
|
-
// Register OAuth2 client
|
|
829
|
-
const client = await auth.registerClient({ name: 'My App', redirectUris: ['https://app.com/cb'] })
|
|
830
|
-
|
|
831
|
-
// Authorization code + PKCE flow built-in
|
|
332
|
+
const q = queue({ redis })
|
|
333
|
+
await q.add('send-email', { to: 'user@test.com' }, { cron: '0 8 * * *' })
|
|
832
334
|
```
|
|
833
335
|
|
|
834
|
-
|
|
835
|
-
|-------|----------|
|
|
836
|
-
| `authorization_code` (client_secret) | Server-side apps |
|
|
837
|
-
| `authorization_code` (PKCE) | SPA / Mobile apps |
|
|
838
|
-
| `client_credentials` | Machine-to-machine |
|
|
839
|
-
|
|
840
|
-
---
|
|
841
|
-
|
|
842
|
-
## WebSocket & Real-time
|
|
843
|
-
|
|
844
|
-
### Server-side
|
|
336
|
+
### rateLimit [B]
|
|
845
337
|
|
|
846
338
|
```ts
|
|
847
|
-
app.
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
error(ws, ctx, err) { /* log */ },
|
|
852
|
-
})
|
|
339
|
+
app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min
|
|
340
|
+
app.get('/api', rateLimit({ max: 10 }), handler) // per-route
|
|
341
|
+
app.use(rateLimit({ key: (req) => req.headers.get('x-api-key') ?? 'anonymous' }))
|
|
342
|
+
// m.stop() — clear interval
|
|
853
343
|
```
|
|
854
344
|
|
|
855
|
-
###
|
|
345
|
+
### redis [B]
|
|
856
346
|
|
|
857
347
|
```ts
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
app.ws('/chat/:room', {
|
|
863
|
-
open(ws, ctx) { hub.join(`room:${ctx.params.room}`, ws) },
|
|
864
|
-
message(ws, ctx, data) { hub.broadcast(`room:${ctx.params.room}`, { text: data.toString() }) },
|
|
865
|
-
close(ws) { hub.leave(ws) },
|
|
866
|
-
})
|
|
348
|
+
const r = redis() // reads REDIS_URL
|
|
349
|
+
app.use(r) // injects ctx.redis
|
|
350
|
+
await ctx.redis.set('key', 'value')
|
|
351
|
+
// r.close() — cleanup
|
|
867
352
|
```
|
|
868
353
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
## AI
|
|
872
|
-
|
|
873
|
-
### Streaming
|
|
354
|
+
### requestId [A]
|
|
874
355
|
|
|
875
356
|
```ts
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
const chat = await aiStream(async (req) => ({
|
|
879
|
-
model: openai('gpt-4o'),
|
|
880
|
-
messages: (await req.json()).messages,
|
|
881
|
-
}))
|
|
882
|
-
app.use('/chat', chat.router())
|
|
357
|
+
app.use(requestId())
|
|
358
|
+
// Sets X-Request-ID header on responses, available as ctx.requestId
|
|
883
359
|
```
|
|
884
360
|
|
|
885
|
-
###
|
|
361
|
+
### seo [D] + seoMiddleware [A]
|
|
886
362
|
|
|
887
363
|
```ts
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
const agents = agent({ pg })
|
|
891
|
-
await agents.migrate()
|
|
892
|
-
app.use('/api', agents.router())
|
|
364
|
+
app.use(seo({ baseUrl: 'https://example.com', robots: [{ userAgent: '*', allow: '/' }], sitemap: { urls: [{ loc: '/' }] } }))
|
|
365
|
+
// GET /robots.txt, GET /sitemap.xml
|
|
893
366
|
|
|
894
|
-
|
|
367
|
+
app.use(seoMiddleware({ headers: { 'X-Robots-Tag': (path) => path.startsWith('/admin') ? 'noindex' : undefined } }))
|
|
895
368
|
```
|
|
896
369
|
|
|
897
|
-
###
|
|
370
|
+
### tenant [C]
|
|
898
371
|
|
|
899
|
-
|
|
900
|
-
import { runWorkflow, tool, streamText } from 'weifuwu'
|
|
901
|
-
import { z } from 'zod'
|
|
372
|
+
Multi-tenant BaaS with dynamic table API and GraphQL.
|
|
902
373
|
|
|
903
|
-
|
|
904
|
-
const
|
|
374
|
+
```ts
|
|
375
|
+
const t = tenant({ pg, usersTable: '_users' })
|
|
376
|
+
await t.migrate()
|
|
377
|
+
app.use('/api', t.middleware()) // → ctx.tenant
|
|
378
|
+
app.use('/api', t.router()) // dynamic CRUD
|
|
379
|
+
app.use('/graphql', t.graphql()) // dynamic GraphQL
|
|
905
380
|
```
|
|
906
381
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
## Data Layer
|
|
910
|
-
|
|
911
|
-
### Redis
|
|
382
|
+
### upload [A]
|
|
912
383
|
|
|
913
384
|
```ts
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
await ctx.redis.set('key', 'value')
|
|
385
|
+
app.post('/upload', upload({ dir: './uploads', maxFileSize: 10_485_760 }), (req, ctx) => {
|
|
386
|
+
// ctx.parsed.files.avatar → { name, type, size, path }
|
|
387
|
+
// ctx.parsed.fields.title → 'hello'
|
|
388
|
+
})
|
|
920
389
|
```
|
|
921
390
|
|
|
922
|
-
###
|
|
391
|
+
### user [C]
|
|
923
392
|
|
|
924
393
|
```ts
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
394
|
+
const auth = user({ pg, jwtSecret: process.env.JWT_SECRET! })
|
|
395
|
+
await auth.migrate()
|
|
396
|
+
app.use('/auth', auth.router()) // POST /register, POST /login
|
|
397
|
+
app.get('/me', auth.middleware(), (req, ctx) => Response.json(ctx.user))
|
|
929
398
|
```
|
|
930
399
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
## iii — Worker / Function / Trigger
|
|
934
|
-
|
|
935
|
-
Optional module for organizing service logic as Worker + Function + Trigger, plus a pure WebSocket SDK for remote workers.
|
|
400
|
+
### validate [A]
|
|
936
401
|
|
|
937
402
|
```ts
|
|
938
|
-
import {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
const w = createWorker('orders')
|
|
944
|
-
w.registerFunction('orders::create', async (payload) => {
|
|
945
|
-
return db.query('INSERT INTO orders ...', [payload.items])
|
|
403
|
+
import { z } from 'zod'
|
|
404
|
+
const CreateUser = z.object({ name: z.string().min(1), email: z.string().email() })
|
|
405
|
+
app.post('/users', validate({ body: CreateUser }), (req, ctx) => {
|
|
406
|
+
// ctx.parsed.body — typed & validated
|
|
946
407
|
})
|
|
947
|
-
engine.addWorker(w)
|
|
948
|
-
|
|
949
|
-
// Invoke
|
|
950
|
-
await engine.trigger({ function_id: 'orders::create', payload: { items: ['apple'] } })
|
|
951
408
|
```
|
|
952
409
|
|
|
953
|
-
### Built-in stream functions
|
|
954
|
-
|
|
955
|
-
| Function | Description |
|
|
956
|
-
|----------|-------------|
|
|
957
|
-
| `stream::set(stream_name, group_id, item_id, data)` | Write + persist + notify |
|
|
958
|
-
| `stream::get(stream_name, group_id, item_id)` | Read single item |
|
|
959
|
-
| `stream::delete(stream_name, group_id, item_id)` | Delete + notify |
|
|
960
|
-
| `stream::list(stream_name, group_id)` | List items in a group |
|
|
961
|
-
| `stream::list_groups(stream_name)` | List groups in a stream |
|
|
962
|
-
| `stream::list_all()` | List all streams |
|
|
963
|
-
| `stream::send(stream_name, group_id, type, data, id?)` | Push event without persisting |
|
|
964
|
-
|
|
965
|
-
### Storage backends
|
|
966
|
-
|
|
967
|
-
| Config | Persistence | Broadcast |
|
|
968
|
-
|--------|-------------|-----------|
|
|
969
|
-
| `iii({})` | In-memory | — |
|
|
970
|
-
| `iii({ pg })` | PG table | — |
|
|
971
|
-
| `iii({ redis })` | Redis Hash | Redis pub/sub |
|
|
972
|
-
| `iii({ pg, redis })` | PG table | Redis pub/sub |
|
|
973
|
-
|
|
974
|
-
### Trigger actions
|
|
975
|
-
|
|
976
|
-
| Action | Behavior |
|
|
977
|
-
|--------|----------|
|
|
978
|
-
| `'sync'` (default) | Wait for result |
|
|
979
|
-
| `'void'` | Fire-and-forget |
|
|
980
|
-
|
|
981
|
-
### REST API
|
|
982
|
-
|
|
983
|
-
| Path | Description |
|
|
984
|
-
|------|-------------|
|
|
985
|
-
| `GET /iii/workers` | List connected workers |
|
|
986
|
-
| `GET /iii/functions` | List registered functions |
|
|
987
|
-
| `GET /iii/triggers` | List registered triggers |
|
|
988
|
-
| `POST /iii/trigger/:fnId` | Invoke a function |
|
|
989
|
-
| `WS /iii/worker` | Remote worker connection |
|
|
990
|
-
|
|
991
410
|
---
|
|
992
411
|
|
|
993
|
-
##
|
|
412
|
+
## React SSR (tsx)
|
|
994
413
|
|
|
995
414
|
```ts
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
const t = tenant({ pg, usersTable: '_users' })
|
|
999
|
-
await t.migrate()
|
|
1000
|
-
|
|
1001
|
-
app.use('/api', t.middleware()) // → ctx.tenant
|
|
1002
|
-
app.use('/api', t.router()) // → dynamic CRUD
|
|
1003
|
-
app.use('/graphql', t.graphql()) // → dynamic GraphQL
|
|
1004
|
-
```
|
|
1005
|
-
|
|
1006
|
-
### Dynamic table API
|
|
1007
|
-
|
|
1008
|
-
```json
|
|
1009
|
-
POST /api/tables
|
|
1010
|
-
{ "slug": "articles", "fields": [
|
|
1011
|
-
{ "name": "title", "type": "string", "required": true },
|
|
1012
|
-
{ "name": "views", "type": "integer", "default": 0 }
|
|
1013
|
-
]}
|
|
415
|
+
app.use('/', await tsx({ dir: './ui/' }))
|
|
1014
416
|
```
|
|
1015
417
|
|
|
1016
|
-
Field types: `string`, `integer`, `float`, `boolean`, `text`, `datetime`, `date`, `enum`, `json`, `vector`.
|
|
1017
|
-
|
|
1018
|
-
### REST API
|
|
1019
|
-
|
|
1020
|
-
| Method | Path | Description |
|
|
1021
|
-
|--------|------|-------------|
|
|
1022
|
-
| GET/POST | `/sys/tables` | List / create dynamic tables |
|
|
1023
|
-
| GET/POST/PATCH/DELETE | `/:slug[/:id]` | Dynamic CRUD |
|
|
1024
|
-
| POST/POST | `/:slug/:id/:nested` | Related rows |
|
|
1025
|
-
|
|
1026
|
-
---
|
|
1027
|
-
|
|
1028
|
-
## Messager
|
|
1029
|
-
|
|
1030
|
-
Real-time chat with channels, WebSocket, and agent routing.
|
|
1031
|
-
|
|
1032
|
-
```ts
|
|
1033
|
-
import { messager, agent, redis } from 'weifuwu'
|
|
1034
|
-
|
|
1035
|
-
const msg = messager({ pg, agents, redis: redis() })
|
|
1036
|
-
await msg.migrate()
|
|
1037
|
-
app.ws('/ws', u.middleware(), msg.wsHandler())
|
|
1038
418
|
```
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
419
|
+
ui/
|
|
420
|
+
├── pages/
|
|
421
|
+
│ ├── page.tsx → GET /
|
|
422
|
+
│ ├── layout.tsx → root layout
|
|
423
|
+
│ ├── not-found.tsx → 404
|
|
424
|
+
│ ├── about/page.tsx → GET /about
|
|
425
|
+
│ ├── blog/[slug]/
|
|
426
|
+
│ │ ├── page.tsx → GET /blog/:slug
|
|
427
|
+
│ │ ├── load.ts → server data fetching
|
|
428
|
+
│ │ └── route.ts → API (named exports: POST, PUT...)
|
|
429
|
+
│ ├── blog/layout.tsx → nested layout
|
|
430
|
+
│ └── api/search/
|
|
431
|
+
│ └── route.ts → GET /api/search
|
|
432
|
+
└── components/
|
|
1046
433
|
```
|
|
1047
434
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
{
|
|
1052
|
-
|
|
1053
|
-
|
|
435
|
+
```tsx
|
|
436
|
+
// page.tsx
|
|
437
|
+
export default function Page({ params, query }: { params: { slug: string }; query: Record<string, string> }) {
|
|
438
|
+
const { t } = useCtx()
|
|
439
|
+
return <h1>{t('title')}</h1>
|
|
440
|
+
}
|
|
1054
441
|
```
|
|
1055
442
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
443
|
+
```tsx
|
|
444
|
+
// layout.tsx
|
|
445
|
+
export default function RootLayout({ children, req }: { children: React.ReactNode; req: Request }) {
|
|
446
|
+
return <html><head/><body><main>{children}</main></body></html>
|
|
447
|
+
}
|
|
1060
448
|
```
|
|
1061
449
|
|
|
1062
|
-
---
|
|
1063
|
-
|
|
1064
|
-
## LogDB
|
|
1065
|
-
|
|
1066
|
-
PostgreSQL-backed structured event logging with monthly partitioning.
|
|
1067
|
-
|
|
1068
450
|
```ts
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
const logger = logdb({ pg })
|
|
1072
|
-
await logger.migrate()
|
|
1073
|
-
app.use('/logs', logger.router())
|
|
451
|
+
// load.ts — server-only data fetching
|
|
452
|
+
export default async function load({ params, query }) { return { data: await db.query(params.slug) } }
|
|
1074
453
|
```
|
|
1075
454
|
|
|
1076
|
-
### REST API
|
|
1077
|
-
|
|
1078
|
-
| Method | Path | Description |
|
|
1079
|
-
|--------|------|-------------|
|
|
1080
|
-
| `POST` | `/` | Create log entry |
|
|
1081
|
-
| `GET` | `/` | Query entries (supports `?level=`, `?source=`, `?after=`, `?before=`, `?meta.*=`) |
|
|
1082
|
-
| `GET` | `/:id` | Get single entry |
|
|
1083
|
-
|
|
1084
|
-
### Retention
|
|
1085
|
-
|
|
1086
455
|
```ts
|
|
1087
|
-
|
|
456
|
+
// route.ts — API co-located with page
|
|
457
|
+
export const POST: Handler = async (req, ctx) => Response.json({ slug: ctx.params.slug })
|
|
1088
458
|
```
|
|
1089
459
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
## SEO
|
|
460
|
+
### Client-side navigation
|
|
1093
461
|
|
|
1094
|
-
```
|
|
1095
|
-
import {
|
|
1096
|
-
|
|
1097
|
-
app.use(seo({
|
|
1098
|
-
baseUrl: 'https://example.com',
|
|
1099
|
-
robots: [{ userAgent: '*', allow: '/', disallow: ['/admin'] }],
|
|
1100
|
-
sitemap: {
|
|
1101
|
-
urls: [{ loc: '/', changefreq: 'daily', priority: 1.0 }],
|
|
1102
|
-
async resolve() { /* dynamic URLs */ },
|
|
1103
|
-
cacheTTL: 3_600_000,
|
|
1104
|
-
},
|
|
1105
|
-
}))
|
|
1106
|
-
|
|
1107
|
-
// Middleware: X-Robots-Tag header
|
|
1108
|
-
app.use(seoMiddleware({ headers: { 'X-Robots-Tag': (path) => path.startsWith('/admin') ? 'noindex' : undefined } }))
|
|
462
|
+
```tsx
|
|
463
|
+
import { Link, useNavigate, useNavigating } from 'weifuwu/react'
|
|
1109
464
|
|
|
1110
|
-
//
|
|
1111
|
-
const
|
|
465
|
+
<Link href="/about" prefetch>About</Link> // client-side nav + prefetch on hover/visible
|
|
466
|
+
const navigate = useNavigate() // programmatic: navigate('/contact')
|
|
467
|
+
const loading = useNavigating() // reactive loading state
|
|
1112
468
|
```
|
|
1113
469
|
|
|
1114
|
-
|
|
1115
|
-
|----------|-------------|
|
|
1116
|
-
| `GET /robots.txt` | Generated robots.txt |
|
|
1117
|
-
| `GET /sitemap.xml` | Generated XML sitemap (cached) |
|
|
470
|
+
`navigate()` fetches SSR, extracts `__weifuwu_root`, replaces in-place. `load.ts` runs on server each nav. `/__lang/:locale` and `/__theme/:theme` intercepted for no-refresh switching.
|
|
1118
471
|
|
|
1119
|
-
|
|
472
|
+
### Client-side hooks
|
|
1120
473
|
|
|
1121
|
-
|
|
474
|
+
```tsx
|
|
475
|
+
import { useWebsocket, useAction, useData, useQueryState, createStore, Head, setCtx } from 'weifuwu/react'
|
|
1122
476
|
|
|
1123
|
-
|
|
477
|
+
// WebSocket — auto-reconnecting
|
|
478
|
+
const { send, lastMessage, readyState } = useWebsocket('/ws/chat', { onMessage: (d) => console.log(d), reconnect: { maxRetries: 10, delay: 3000 } })
|
|
1124
479
|
|
|
1125
|
-
|
|
1126
|
-
|
|
480
|
+
// Form action
|
|
481
|
+
const { submit, data, error, pending } = useAction('/api/feedback', { method: 'POST' })
|
|
482
|
+
// Auto-reads _csrf cookie, sends as X-CSRF-Token
|
|
1127
483
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
app.use('/opencode', await oc.router())
|
|
1131
|
-
app.ws('/opencode', oc.wsHandler())
|
|
1132
|
-
```
|
|
484
|
+
// Data fetching — cache + dedup + mutate
|
|
485
|
+
const { data, error, loading, mutate } = useData('/api/posts', { fallback: loadData })
|
|
1133
486
|
|
|
1134
|
-
|
|
487
|
+
// URL query state
|
|
488
|
+
const [q, setQ] = useQueryState('q', '')
|
|
489
|
+
const [page, setPage] = useQueryState('page', '1')
|
|
1135
490
|
|
|
1136
|
-
|
|
491
|
+
// Shared state — persists across client navs
|
|
492
|
+
const useStore = createStore({ count: 0 })
|
|
493
|
+
const count = useStore(s => s.count)
|
|
1137
494
|
|
|
1138
|
-
|
|
495
|
+
// Per-page meta tags
|
|
496
|
+
<Head><title>Page Title</title><meta name="description" content="..." /></Head>
|
|
1139
497
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
const config = defineConfig({
|
|
1144
|
-
apps: [{ name: 'api', dir: './api', domain: 'api.example.com', port: 3001 }],
|
|
1145
|
-
})
|
|
1146
|
-
await deploy(config)
|
|
498
|
+
// Update context (locale switch etc.)
|
|
499
|
+
setCtx({ locale: 'en', prefs: { locale: 'en' } })
|
|
1147
500
|
```
|
|
1148
501
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
## Analytics
|
|
1152
|
-
|
|
1153
|
-
In-memory page view tracking with a built-in dashboard. Zero extra dependencies.
|
|
502
|
+
### Flash messages
|
|
1154
503
|
|
|
1155
504
|
```ts
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
app.use(analytics()) // mounts middleware + /__analytics + /__analytics/data
|
|
1159
|
-
```
|
|
1160
|
-
|
|
1161
|
-
| Endpoint | Description |
|
|
1162
|
-
|----------|-------------|
|
|
1163
|
-
| `GET /__analytics` | Dashboard — PV trend, top pages, referrers, device breakdown |
|
|
1164
|
-
| `GET /__analytics/data?days=7` | Raw JSON data for custom dashboards |
|
|
1165
|
-
|
|
1166
|
-
Excluded paths (not recorded): `/__analytics/*`, `/__wfw/*`, `/static/*`.
|
|
1167
|
-
|
|
1168
|
-
### Dashboard
|
|
1169
|
-
|
|
1170
|
-
The built-in `/__analytics` page renders a server-generated HTML dashboard with:
|
|
1171
|
-
|
|
1172
|
-
- **Summary cards** — total PV, unique pages, mobile/desktop ratio
|
|
1173
|
-
- **Bar chart** — daily page views for the selected period
|
|
1174
|
-
- **Top pages table** — most visited paths ranked by views
|
|
1175
|
-
- **Referrers table** — top referring domains
|
|
505
|
+
// Server
|
|
506
|
+
return ctx.setPref('flash', JSON.stringify({ type: 'success', message: 'Done' })) // 302 + Set-Cookie
|
|
1176
507
|
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
```ts
|
|
1180
|
-
// GET /__analytics/data?days=30
|
|
1181
|
-
{
|
|
1182
|
-
"total_pv": 2847,
|
|
1183
|
-
"total_uv": 1231,
|
|
1184
|
-
"daily": [{ "date": "2026-06-05", "pv": 520, "uv": 310 }],
|
|
1185
|
-
"top_pages": [{ "path": "/tools/uppercase", "pv": 1284 }],
|
|
1186
|
-
"referrers": [{ "domain": "google.com", "count": 380 }],
|
|
1187
|
-
"devices": { "mobile": 45.2, "desktop": 54.8 }
|
|
1188
|
-
}
|
|
508
|
+
// Client (tsx)
|
|
509
|
+
function Toast() { const { prefs } = useCtx(); const flash = prefs?.flash ? JSON.parse(prefs.flash) : null; ... }
|
|
1189
510
|
```
|
|
1190
511
|
|
|
1191
|
-
###
|
|
512
|
+
### Dev mode
|
|
1192
513
|
|
|
1193
|
-
|
|
1194
|
-
app.use(analytics({
|
|
1195
|
-
excluded: ['/admin', '/api'], // custom exclude patterns (defaults listed above)
|
|
1196
|
-
}))
|
|
1197
|
-
```
|
|
514
|
+
Auto-detected when `NODE_ENV !== 'production'`. File watching, live reload, Tailwind v4 auto-compile.
|
|
1198
515
|
|
|
1199
516
|
---
|
|
1200
517
|
|
|
1201
|
-
##
|
|
518
|
+
## AI
|
|
1202
519
|
|
|
1203
520
|
```ts
|
|
1204
|
-
import {
|
|
1205
|
-
|
|
1206
|
-
app.use(health()) // GET /health → 200
|
|
1207
|
-
|
|
1208
|
-
// Custom checks
|
|
1209
|
-
app.use(health({
|
|
1210
|
-
checks: { db: async () => { await pg.sql`SELECT 1`; return { ok: true } } },
|
|
1211
|
-
}))
|
|
521
|
+
import { openai, streamText, generateText, streamObject, generateObject, tool, embed, embedMany } from 'weifuwu'
|
|
522
|
+
import { runWorkflow } from 'weifuwu'
|
|
1212
523
|
```
|
|
1213
524
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
## Preferences
|
|
525
|
+
### Streaming
|
|
1217
526
|
|
|
1218
527
|
```ts
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
app.use(preferences({
|
|
1222
|
-
dir: './locales', // translation directory (optional)
|
|
1223
|
-
locale: { default: 'en' }, // locale detection
|
|
1224
|
-
theme: { default: 'system' }, // 'light' | 'dark' | 'system'
|
|
1225
|
-
}))
|
|
1226
|
-
|
|
1227
|
-
// In handlers: ctx.t('greeting') → "Hello"
|
|
1228
|
-
// ctx.t('tools.uppercase.title') → "Uppercase" (nested key)
|
|
1229
|
-
// ctx.locale → "en"
|
|
1230
|
-
// ctx.theme → "light"
|
|
1231
|
-
// ctx.prefs → { locale: 'en', theme: 'light' }
|
|
1232
|
-
// ctx.setPref('locale', 'zh') → 302 + cookie
|
|
1233
|
-
// ctx.setPref('flash', '{"type":"success","message":"Done"}') → flash message
|
|
1234
|
-
// ctx.env → { WEIFUWU_PUBLIC_API_URL: '...' } (public env vars)
|
|
1235
|
-
|
|
1236
|
-
// In tsx components:
|
|
1237
|
-
const { t, locale, theme } = useCtx()
|
|
528
|
+
const chat = await aiStream(async (req) => ({ model: openai('gpt-4o'), messages: (await req.json()).messages }))
|
|
529
|
+
app.use('/chat', chat.router().handler())
|
|
1238
530
|
```
|
|
1239
531
|
|
|
1240
|
-
|
|
1241
|
-
Theme detection: cookie → default (`'system'`).
|
|
1242
|
-
Flash messages: set via `ctx.setPref('flash', ...)` → auto-read from cookie → cleared after rendering.
|
|
1243
|
-
|
|
1244
|
-
`ctx.t()` supports dot-path nested keys: `t('tools.uppercase.title')` traverses the JSON structure.
|
|
1245
|
-
`ctx.env` exposes `WEIFUWU_PUBLIC_*` environment variables on both server and client (via `useCtx().env`).
|
|
1246
|
-
|
|
1247
|
-
### Language / Theme switching
|
|
1248
|
-
|
|
1249
|
-
The middleware automatically handles `/__lang/:locale` and `/__theme/:theme` without needing extra routes:
|
|
532
|
+
### Agents
|
|
1250
533
|
|
|
1251
534
|
```ts
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
**Server behavior:**
|
|
1257
|
-
|
|
1258
|
-
| Mode | Request | Response |
|
|
1259
|
-
|------|---------|----------|
|
|
1260
|
-
| Redirect | `GET /__lang/zh` (browser) | 302 with `Set-Cookie: locale=zh` back to referer |
|
|
1261
|
-
| JSON | `GET /__lang/zh` + `Accept: application/json` | `{ ok: true, locale: 'zh', messages: { ... } }` + `Set-Cookie` |
|
|
1262
|
-
|
|
1263
|
-
**Client-side no-refresh switching:**
|
|
1264
|
-
|
|
1265
|
-
```tsx
|
|
1266
|
-
import { Link } from 'weifuwu/react'
|
|
1267
|
-
|
|
1268
|
-
<Link href="/__lang/zh">中文</Link> // Instant switch — no page reload
|
|
1269
|
-
<Link href="/__lang/en">English</Link> // Instant switch — no page reload
|
|
1270
|
-
<Link href="/__theme/dark">🌙</Link> // Instant switch — no page reload
|
|
535
|
+
const agents = agent({ pg })
|
|
536
|
+
await agents.migrate()
|
|
537
|
+
app.use('/api', agents.router())
|
|
538
|
+
await agents.addKnowledge(agentId, 'Title', 'content')
|
|
1271
539
|
```
|
|
1272
540
|
|
|
1273
|
-
|
|
1274
|
-
1. Fetches the endpoint with `Accept: application/json`
|
|
1275
|
-
2. Updates `window.__WEIFUWU_CTX` (locale, theme, messages)
|
|
1276
|
-
3. Calls `setCtx()` — React re-renders via `useSyncExternalStore`
|
|
1277
|
-
4. Page content stays intact, only translations/theme update
|
|
1278
|
-
|
|
1279
|
-
Plain `<a href="/__lang/zh">` still works via traditional 302 redirect for full backward compatibility.
|
|
1280
|
-
|
|
1281
|
-
### Options
|
|
1282
|
-
|
|
1283
|
-
```ts
|
|
1284
|
-
app.use(preferences({
|
|
1285
|
-
dir: './locales', // translation JSON directory
|
|
1286
|
-
locale: {
|
|
1287
|
-
default: 'en', // fallback when no cookie or Accept-Language
|
|
1288
|
-
cookie: 'locale', // cookie name (default: 'locale')
|
|
1289
|
-
fromAcceptLanguage: true, // detect from Accept-Language header
|
|
1290
|
-
},
|
|
1291
|
-
theme: {
|
|
1292
|
-
default: 'system', // 'light' | 'dark' | 'system'
|
|
1293
|
-
cookie: 'theme', // cookie name (default: 'theme')
|
|
1294
|
-
},
|
|
1295
|
-
}))
|
|
1296
|
-
|
|
1297
|
-
---
|
|
1298
|
-
|
|
1299
|
-
## Email
|
|
541
|
+
### DAG Workflow
|
|
1300
542
|
|
|
1301
543
|
```ts
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
const mail = mailer({ host: 'smtp.example.com', port: 587, auth: { user: 'user', pass: 'pass' } })
|
|
1305
|
-
await mail.send({ to: 'user@test.com', subject: 'Hello', text: 'Body', html: '<p>Body</p>' })
|
|
544
|
+
const tools = { queryUser: tool({ ... }) }
|
|
545
|
+
const wf = runWorkflow({ tools })
|
|
1306
546
|
```
|
|
1307
547
|
|
|
1308
548
|
---
|
|
@@ -1311,50 +551,26 @@ await mail.send({ to: 'user@test.com', subject: 'Hello', text: 'Body', html: '<p
|
|
|
1311
551
|
|
|
1312
552
|
```ts
|
|
1313
553
|
import { createSSEStream, formatSSE, formatSSEData } from 'weifuwu'
|
|
1314
|
-
|
|
1315
|
-
async function* events() {
|
|
1316
|
-
yield formatSSE('chat', 'Hello')
|
|
1317
|
-
yield formatSSE('chat', 'World')
|
|
1318
|
-
}
|
|
1319
|
-
|
|
554
|
+
async function* events() { yield formatSSE('chat', 'Hello'); yield formatSSE('chat', 'World') }
|
|
1320
555
|
app.get('/stream', (req, ctx) => createSSEStream(events()))
|
|
1321
556
|
```
|
|
1322
557
|
|
|
1323
558
|
---
|
|
1324
559
|
|
|
1325
|
-
## Utility
|
|
560
|
+
## Utility Functions
|
|
561
|
+
|
|
562
|
+
### Common
|
|
1326
563
|
|
|
1327
564
|
| Function | Description |
|
|
1328
565
|
|----------|-------------|
|
|
1329
566
|
| `loadEnv(path?)` | Load `.env` into `process.env` |
|
|
1330
|
-
| `serveStatic(root,
|
|
1331
|
-
| `getCookies(req)` | Parse cookies
|
|
1332
|
-
| `setCookie(res, name, value, opts?)` | Set cookie
|
|
1333
|
-
| `deleteCookie(res, name, opts?)` | Delete cookie
|
|
1334
|
-
| `createTestServer(handler)` |
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
| `formatSSEData(data)` | Format SSE data string |
|
|
1338
|
-
| `runWorkflow(options)` | DAG execution engine as AI SDK Tool |
|
|
1339
|
-
|
|
1340
|
-
### React exports (`weifuwu/react`)
|
|
1341
|
-
|
|
1342
|
-
| Hook / Component | Description |
|
|
1343
|
-
|-----------------|-------------|
|
|
1344
|
-
| `useCtx()` | Unified context — `{ prefs, locale, theme, t, params, query, env }` (requires `preferences` middleware) |
|
|
1345
|
-
| `setCtx(value)` | Update context — triggers re-render in all `useCtx()` consumers |
|
|
1346
|
-
| `createStore(initial)` | Zustand-compatible shared state — `getState`, `setState`, `subscribe` |
|
|
1347
|
-
| `useData(url, opts?)` | SWR-style data fetching — cache, dedup, mutate, fallback |
|
|
1348
|
-
| `useQueryState(key, default)` | URL query param sync — `?page=1` via `useSyncExternalStore` |
|
|
1349
|
-
| `useAction(url, opts?)` | Async form submission — `{ submit, data, error, pending }` |
|
|
1350
|
-
| `useWebsocket(url, opts?)` | Auto-reconnecting WebSocket — `{ send, lastMessage, readyState }` |
|
|
1351
|
-
| `useNavigate()` | Client-side navigation callback |
|
|
1352
|
-
| `useNavigating()` | Reactive loading state during navigation |
|
|
1353
|
-
| `navigate(href)` | Client-side page navigation (imperative) |
|
|
1354
|
-
| `Link` | `<Link href prefetch>` — prefetch on hover/visible |
|
|
1355
|
-
| `Head` | `<Head>` — per-page `<title>` / `<meta>` merged into `<head>` |
|
|
1356
|
-
|
|
1357
|
-
### AI SDK re-exports
|
|
567
|
+
| `serveStatic(root, opts?)` | Static file serving (20+ MIME, ETag, 304, path traversal protection) |
|
|
568
|
+
| `getCookies(req)` | Parse cookies |
|
|
569
|
+
| `setCookie(res, name, value, opts?)` | Set cookie |
|
|
570
|
+
| `deleteCookie(res, name, opts?)` | Delete cookie |
|
|
571
|
+
| `createTestServer(handler)` | `→ { server, url }` |
|
|
572
|
+
|
|
573
|
+
### AI re-exports
|
|
1358
574
|
|
|
1359
575
|
```ts
|
|
1360
576
|
streamText, generateText, streamObject, generateObject,
|
|
@@ -1365,10 +581,9 @@ openai, createOpenAI
|
|
|
1365
581
|
### pgTable helpers
|
|
1366
582
|
|
|
1367
583
|
```ts
|
|
1368
|
-
pgTable
|
|
584
|
+
pgTable, pg.table,
|
|
1369
585
|
serial, uuid, text, integer, boolean, timestamptz, jsonb, textArray, vector, timestamps,
|
|
1370
|
-
eq, ne, gt, gte, lt, lte, isNull, isNotNull, like, contains, in_, and, or, not
|
|
1371
|
-
PgModule
|
|
586
|
+
eq, ne, gt, gte, lt, lte, isNull, isNotNull, like, contains, in_, and, or, not
|
|
1372
587
|
```
|
|
1373
588
|
|
|
1374
589
|
---
|
|
@@ -1390,11 +605,8 @@ describe('hello', () => {
|
|
|
1390
605
|
})
|
|
1391
606
|
```
|
|
1392
607
|
|
|
1393
|
-
For end-to-end tests:
|
|
1394
|
-
|
|
1395
608
|
```ts
|
|
1396
609
|
import { createTestServer } from 'weifuwu'
|
|
1397
|
-
|
|
1398
610
|
const { server, url } = await createTestServer(handler)
|
|
1399
611
|
const res = await fetch(`${url}/api/ping`)
|
|
1400
612
|
```
|