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