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