weifuwu 0.12.0 → 0.13.1
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 +95 -1427
- package/cli.ts +88 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +75 -0
- package/dist/env.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +86 -57
- package/docs/agent.md +44 -0
- package/docs/ai.md +93 -0
- package/docs/extra.md +67 -0
- package/docs/graphql.md +61 -0
- package/docs/messager.md +48 -0
- package/docs/middleware.md +131 -0
- package/docs/opencode.md +252 -0
- package/docs/postgres.md +162 -0
- package/docs/router.md +80 -0
- package/docs/tenant.md +174 -0
- package/docs/tsx.md +199 -0
- package/docs/user.md +167 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: weifuwu
|
|
3
|
+
description: Web-standard HTTP framework for Node.js — (req, ctx) => Response
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# weifuwu
|
|
2
7
|
|
|
3
8
|
**Web-standard HTTP framework for Node.js.** `(req, ctx) => Response` — no framework-specific objects, just the Web API your browser already speaks.
|
|
@@ -11,26 +16,22 @@ Everything follows the same `(req, ctx) => Response` contract. The Router handle
|
|
|
11
16
|
## Features
|
|
12
17
|
|
|
13
18
|
- **Web Standard** — `Request` / `Response` / `ReadableStream`, zero abstractions
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **Middleware
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
- **
|
|
21
|
-
- **
|
|
22
|
-
- **AI
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
29
|
-
- **
|
|
30
|
-
- **Error handling** — global `onError()`
|
|
31
|
-
- **Deploy** — `deploy()` — self-hosted PaaS: multi-app reverse proxy, subdomain routing, zero-downtime updates, auto SSL, Git-based deployment
|
|
32
|
-
- **Zero build** — native TypeScript in Node.js v24+
|
|
33
|
-
- **Zero deps** (core) — only `node:http` and `node:stream`
|
|
19
|
+
- **Zero build** — native TypeScript in Node.js v24+, zero deps (core)
|
|
20
|
+
- **Trie router** — static > param > wildcard, sub-router mounting, WebSocket
|
|
21
|
+
- **Middleware** — global/path-scoped/route-level — onion model with short-circuit
|
|
22
|
+
- **Modules** — auth, validation, upload, compression, rate-limit, cookies, static files, CORS, logging
|
|
23
|
+
- **React SSR** — `tsx()` — pages, layouts, loaders, route handlers, Tailwind CSS, HMR
|
|
24
|
+
- **PostgreSQL** — schema builder with type-safe DDL + CRUD, transactions, vector search
|
|
25
|
+
- **Auth** — password + JWT + OAuth2 Server (authorization code / PKCE / client_credentials)
|
|
26
|
+
- **Real-time** — WebSocket, messaging channels with agent routing
|
|
27
|
+
- **AI** — streaming endpoint, DAG workflow tool, AI agents with RAG and tool-use
|
|
28
|
+
- **Data** — Redis client, job queue with cron scheduling
|
|
29
|
+
- **Multi-tenant BaaS** — dynamic tables, auto REST + GraphQL, row-level isolation
|
|
30
|
+
- **Deploy** — self-hosted PaaS: multi-app proxy, zero-downtime updates, auto SSL
|
|
31
|
+
- **i18n** — locale detection, JSON translations, `ctx.t()`
|
|
32
|
+
- **Email** — SMTP or custom transport
|
|
33
|
+
- **Health check** — configurable `/health` endpoint
|
|
34
|
+
- **Test utilities** — `createTestServer()` — one-line test server setup
|
|
34
35
|
|
|
35
36
|
## Quick start
|
|
36
37
|
|
|
@@ -41,1444 +42,111 @@ import { serve } from 'weifuwu'
|
|
|
41
42
|
serve((req, ctx) => new Response('Hello, World!'), { port: 3000 })
|
|
42
43
|
```
|
|
43
44
|
|
|
44
|
-
###
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
|
-
npm install weifuwu
|
|
48
|
-
mkdir -p ui/pages
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
```ts
|
|
52
|
-
// app.ts
|
|
53
|
-
import { serve, Router } from 'weifuwu'
|
|
54
|
-
|
|
55
|
-
const app = new Router()
|
|
56
|
-
app.use('/', await tsx({ dir: './ui/' }))
|
|
57
|
-
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
```tsx
|
|
61
|
-
// ui/pages/page.tsx
|
|
62
|
-
export default function Home() {
|
|
63
|
-
return <h1 className="text-3xl font-bold text-blue-600">Hello</h1>
|
|
64
|
-
}
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
```bash
|
|
68
|
-
node app.ts
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Open http://localhost:3000 — Tailwind CSS is compiled automatically, pages hot-reload on save.
|
|
72
|
-
|
|
73
|
-
## Router
|
|
74
|
-
|
|
75
|
-
```ts
|
|
76
|
-
import { serve, Router } from 'weifuwu'
|
|
77
|
-
|
|
78
|
-
const app = new Router()
|
|
79
|
-
.use((req, ctx, next) => {
|
|
80
|
-
console.log(`${req.method} ${new URL(req.url).pathname}`)
|
|
81
|
-
return next(req, ctx)
|
|
82
|
-
})
|
|
83
|
-
.get('/hello/:name', (req, ctx) =>
|
|
84
|
-
Response.json({ message: `Hello, ${ctx.params.name}!` }),
|
|
85
|
-
)
|
|
86
|
-
.post('/data', async (req, ctx) => {
|
|
87
|
-
const body = await req.json()
|
|
88
|
-
return Response.json(body, { status: 201 })
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
serve(app.handler(), { port: 3000 })
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## Built-in middleware
|
|
95
|
-
|
|
96
|
-
### Auth
|
|
97
|
-
|
|
98
|
-
```ts
|
|
99
|
-
import { auth } from 'weifuwu'
|
|
100
|
-
|
|
101
|
-
// Static bearer token
|
|
102
|
-
app.use(auth({ token: 'sk-123' }))
|
|
103
|
-
|
|
104
|
-
// Custom verify (JWT, DB, etc.) — return object to set ctx.user
|
|
105
|
-
app.use(auth({
|
|
106
|
-
verify: async (token) => {
|
|
107
|
-
const user = await db.findUserByToken(token)
|
|
108
|
-
return user ? { sub: user.id, role: user.role } : null
|
|
109
|
-
},
|
|
110
|
-
}))
|
|
111
|
-
|
|
112
|
-
// Proxy validation to external auth service
|
|
113
|
-
app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
|
|
114
|
-
|
|
115
|
-
// Custom header
|
|
116
|
-
app.use(auth({ header: 'X-API-Key', token: 'my-key' }))
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
### CORS
|
|
120
|
-
|
|
121
|
-
```ts
|
|
122
|
-
import { cors } from 'weifuwu'
|
|
123
|
-
|
|
124
|
-
app.use(cors()) // allow all
|
|
125
|
-
app.use(cors({ origin: ['https://example.com'] })) // whitelist
|
|
126
|
-
app.use(cors({ origin: (o) => o.endsWith('.trusted.com') ? o : false }))
|
|
127
|
-
app.use(cors({ credentials: true, maxAge: 3600 }))
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
### Logger
|
|
131
|
-
|
|
132
|
-
```ts
|
|
133
|
-
import { logger } from 'weifuwu'
|
|
134
|
-
|
|
135
|
-
app.use(logger()) // GET /hello 200 5ms
|
|
136
|
-
app.use(logger({ format: 'combined' })) // with query params
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
### Rate limit
|
|
140
|
-
|
|
141
|
-
```ts
|
|
142
|
-
import { rateLimit } from 'weifuwu'
|
|
143
|
-
|
|
144
|
-
app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min
|
|
145
|
-
app.get('/api', rateLimit({ max: 10 }), handler) // per-route
|
|
146
|
-
|
|
147
|
-
// Custom key (by API key, user ID, etc.)
|
|
148
|
-
app.use(rateLimit({
|
|
149
|
-
max: 1000,
|
|
150
|
-
key: (req) => req.headers.get('x-api-key') ?? 'anonymous',
|
|
151
|
-
}))
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
### Compression
|
|
155
|
-
|
|
156
|
-
```ts
|
|
157
|
-
import { compress } from 'weifuwu'
|
|
158
|
-
|
|
159
|
-
app.use(compress()) // brotli > gzip > deflate
|
|
160
|
-
app.use(compress({ threshold: 2048 })) // only compress > 2KB
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
## Validation
|
|
164
|
-
|
|
165
|
-
```ts
|
|
166
|
-
import { z } from 'zod'
|
|
167
|
-
import { validate } from 'weifuwu'
|
|
168
|
-
|
|
169
|
-
const CreateUser = z.object({
|
|
170
|
-
name: z.string().min(1),
|
|
171
|
-
email: z.string().email(),
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
router.post('/users',
|
|
175
|
-
validate({ body: CreateUser }),
|
|
176
|
-
(req, ctx) => {
|
|
177
|
-
// ctx.parsed.body — typed & validated
|
|
178
|
-
},
|
|
179
|
-
)
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
## File upload
|
|
183
|
-
|
|
184
|
-
```ts
|
|
185
|
-
import { upload } from 'weifuwu'
|
|
186
|
-
|
|
187
|
-
router.post('/upload',
|
|
188
|
-
upload({ dir: './uploads', maxFileSize: 10_485_760 }),
|
|
189
|
-
(req, ctx) => {
|
|
190
|
-
// ctx.parsed.files.avatar → { name, type, size, path }
|
|
191
|
-
// ctx.parsed.fields.title → 'hello'
|
|
192
|
-
},
|
|
193
|
-
)
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
## Cookie
|
|
197
|
-
|
|
198
|
-
```ts
|
|
199
|
-
import { getCookies, setCookie, deleteCookie } from 'weifuwu'
|
|
200
|
-
|
|
201
|
-
// Read
|
|
202
|
-
const cookies = getCookies(req) // { session: 'abc' }
|
|
203
|
-
|
|
204
|
-
// Set (immutable — returns new Response)
|
|
205
|
-
let res = new Response('ok')
|
|
206
|
-
res = setCookie(res, 'session', 'token', { httpOnly: true, secure: true, maxAge: 3600 })
|
|
207
|
-
|
|
208
|
-
// Delete
|
|
209
|
-
res = deleteCookie(res, 'session')
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
## Static files
|
|
213
|
-
|
|
214
|
-
```ts
|
|
215
|
-
import { serveStatic } from 'weifuwu'
|
|
216
|
-
|
|
217
|
-
router.get('/static/*', serveStatic('./public'))
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
Features: MIME type detection (20+ types), ETag + If-None-Match (304), directory index (index.html), path traversal protection, Cache-Control.
|
|
221
|
-
|
|
222
|
-
## PostgreSQL
|
|
223
|
-
|
|
224
|
-
Built-in PostgreSQL client — connection management, type-safe DDL, transactions, and module lifecycle.
|
|
225
|
-
|
|
226
|
-
```ts
|
|
227
|
-
import { serve, Router, postgres } from 'weifuwu'
|
|
228
|
-
|
|
229
|
-
const app = new Router()
|
|
230
|
-
const pg = postgres() // reads DATABASE_URL
|
|
231
|
-
app.use(pg) // injects ctx.sql into handlers
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
### Type-safe DDL with schema builder
|
|
235
|
-
|
|
236
|
-
Define tables declaratively with type inference — no raw SQL for common operations, no Zod needed:
|
|
237
|
-
|
|
238
|
-
```ts
|
|
239
|
-
import { pgTable, serial, uuid, text, integer, boolean, timestamptz, jsonb, sql } from 'weifuwu'
|
|
240
|
-
|
|
241
|
-
const users = pgTable('_users', {
|
|
242
|
-
id: serial('id').primaryKey(),
|
|
243
|
-
name: text('name').notNull(),
|
|
244
|
-
email: text('email').unique().notNull(),
|
|
245
|
-
age: integer('age'),
|
|
246
|
-
active: boolean('active').default(true),
|
|
247
|
-
createdAt: timestamptz('created_at').default(sql`NOW()`),
|
|
248
|
-
metadata: jsonb<{ role: string }>('metadata'),
|
|
249
|
-
})
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
Supports 10 column types:
|
|
253
|
-
| Builder | DDL | TS Type |
|
|
254
|
-
|---------|-----|---------|
|
|
255
|
-
| `serial()` | `SERIAL` | `number` |
|
|
256
|
-
| `uuid()` | `UUID` | `string` |
|
|
257
|
-
| `text()` | `TEXT` | `string` |
|
|
258
|
-
| `integer()` | `INTEGER` | `number` |
|
|
259
|
-
| `boolean()` | `BOOLEAN` | `boolean` |
|
|
260
|
-
| `timestamptz()` | `TIMESTAMPTZ` | `string` |
|
|
261
|
-
| `jsonb<T>()` | `JSONB` | `T` |
|
|
262
|
-
| `textArray()` | `TEXT[]` | `string[]` |
|
|
263
|
-
| `vector(name, dims)` | `vector(N)` | `number[]` |
|
|
264
|
-
|
|
265
|
-
Column constraints chainable: `.primaryKey()`, `.notNull()`, `.nullable()`, `.default(value | sql\`...\`)`, `.unique()`, `.references(table, column?, onDelete?)`.
|
|
266
|
-
|
|
267
|
-
### DDL execution
|
|
268
|
-
|
|
269
|
-
```ts
|
|
270
|
-
await users.create() // CREATE TABLE IF NOT EXISTS
|
|
271
|
-
await users.createIndex('email') // CREATE INDEX
|
|
272
|
-
await users.createUniqueIndex('slug') // CREATE UNIQUE INDEX
|
|
273
|
-
await users.createIndex('created_at', { desc: true })
|
|
274
|
-
await users.createIndex(['a', 'b']) // multi-column
|
|
275
|
-
await users.createIndex('embedding', { // pgvector HNSW
|
|
276
|
-
type: 'hnsw', operator: 'vector_cosine_ops',
|
|
277
|
-
})
|
|
278
|
-
await users.drop({ cascade: true })
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
### Type-safe CRUD with BoundTable
|
|
282
|
-
|
|
283
|
-
Two usage paths — use `pg.table()` when you have a `pg` handle, or `pgTable()` with explicit `sql`:
|
|
284
|
-
|
|
285
|
-
```ts
|
|
286
|
-
// pg.table() — auto-binds sql, no need to pass it
|
|
287
|
-
const users = pg.table('_users', {
|
|
288
|
-
id: serial('id').primaryKey(),
|
|
289
|
-
name: text('name').notNull(),
|
|
290
|
-
email: text('email').unique(),
|
|
291
|
-
active: boolean('active').default(true),
|
|
292
|
-
createdAt: timestamptz('created_at').default(sql`NOW()`),
|
|
293
|
-
})
|
|
294
|
-
|
|
295
|
-
// INSERT ... RETURNING * — auto-strips serial id
|
|
296
|
-
const user = await users.insert({ name: 'Alice', email: 'alice@test.com' })
|
|
297
|
-
// → { id: 1, name: 'Alice', email: 'alice@test.com', active: true, ... }
|
|
298
|
-
|
|
299
|
-
// SELECT ... WHERE id = ? LIMIT 1
|
|
300
|
-
const found = await users.findById(1)
|
|
301
|
-
|
|
302
|
-
// SELECT ... WHERE ... [ORDER BY ...] [LIMIT ...] [OFFSET ...]
|
|
303
|
-
const admins = await users.find({ role: 'admin' })
|
|
304
|
-
const sorted = await users.find({ active: true }, { orderBy: { name: 'asc' } })
|
|
305
|
-
const page = await users.find(undefined, { limit: 10, offset: 0 })
|
|
306
|
-
const filtered = await users.find({ role: 'admin' }, { orderBy: { name: 'desc' }, limit: 5 })
|
|
307
|
-
|
|
308
|
-
// UPDATE ... SET ... WHERE ... RETURNING *
|
|
309
|
-
const updated = await users.update({ id: 1 }, { name: 'Bob' })
|
|
310
|
-
// With SQL expressions:
|
|
311
|
-
await users.update({ id: 1 }, { name: 'Bob', updated_at: sql`NOW()` })
|
|
312
|
-
|
|
313
|
-
// DELETE ... WHERE ... RETURNING 1
|
|
314
|
-
const ok = await users.delete({ id: 1 })
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
When using `pgTable()` directly (without `pg`), pass `sql` as the first argument:
|
|
318
|
-
|
|
319
|
-
```ts
|
|
320
|
-
const t = pgTable('_users', { ... })
|
|
321
|
-
await t.insert(ctx.sql, { name: 'Alice' })
|
|
322
|
-
await t.find(ctx.sql, { role: 'admin' }, { orderBy: { name: 'asc' } })
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
### Complex queries use raw SQL
|
|
326
|
-
|
|
327
|
-
```ts
|
|
328
|
-
app.get('/users/stats', async (req, ctx) => {
|
|
329
|
-
const rows = await ctx.sql`
|
|
330
|
-
SELECT u.*, count(p.id) as posts
|
|
331
|
-
FROM ${users} u LEFT JOIN posts p ON p.user_id = u.id
|
|
332
|
-
GROUP BY u.id
|
|
333
|
-
`
|
|
334
|
-
return Response.json(rows)
|
|
335
|
-
})
|
|
336
|
-
```
|
|
337
|
-
|
|
338
|
-
### Transactions
|
|
339
|
-
|
|
340
|
-
```ts
|
|
341
|
-
const result = await pg.transaction(async (tx) => {
|
|
342
|
-
const [user] = await tx`INSERT INTO "_users" (...) VALUES (...) RETURNING *`
|
|
343
|
-
const [wallet] = await tx`INSERT INTO "_wallets" ("user_id") VALUES (${user.id}) RETURNING *`
|
|
344
|
-
return { user, wallet }
|
|
345
|
-
})
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
### Connection lifecycle
|
|
349
|
-
|
|
350
|
-
```ts
|
|
351
|
-
const pg = postgres() // reads DATABASE_URL
|
|
352
|
-
const pg = postgres('postgres://...') // explicit connection
|
|
353
|
-
const pg = postgres({
|
|
354
|
-
connection: 'postgres://...',
|
|
355
|
-
max: 10, // pool size
|
|
356
|
-
ssl: { rejectUnauthorized: false }, // SSL options
|
|
357
|
-
idle_timeout: 30, // idle timeout (s)
|
|
358
|
-
connect_timeout: 10, // connection timeout (s)
|
|
359
|
-
closeTimeout: 5, // close grace period (s)
|
|
360
|
-
signal: ac.signal, // abort → sql.end()
|
|
361
|
-
})
|
|
362
|
-
await pg.close()
|
|
363
|
-
```
|
|
364
|
-
|
|
365
|
-
### Module base class
|
|
366
|
-
|
|
367
|
-
Every database module (`opencode`, `messager`, `tenant`, `agent`, `user`) extends `PgModule`:
|
|
368
|
-
|
|
369
|
-
```ts
|
|
370
|
-
import { PgModule } from 'weifuwu'
|
|
371
|
-
|
|
372
|
-
class MyModule extends PgModule {
|
|
373
|
-
constructor(pg: PostgresClient) {
|
|
374
|
-
super(pg) // sets this.sql = pg.sql
|
|
375
|
-
}
|
|
376
|
-
async migrate() { /* override */ }
|
|
377
|
-
// close() inherited — calls pg.close() automatically
|
|
378
|
-
}
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
## Opencode
|
|
382
|
-
|
|
383
|
-
AI programming assistant — chat with LLM agents that have access to filesystem tools, skills, and isolated session workspaces.
|
|
384
|
-
|
|
385
|
-
```ts
|
|
386
|
-
import { serve, Router, postgres, opencode } from 'weifuwu'
|
|
387
|
-
|
|
388
|
-
const app = new Router()
|
|
389
|
-
const pg = postgres()
|
|
390
|
-
const oc = await opencode({ pg, permissions: { ... } })
|
|
391
|
-
|
|
392
|
-
await oc.migrate()
|
|
393
|
-
app.use('/opencode', await oc.router())
|
|
394
|
-
app.ws('/opencode', oc.wsHandler())
|
|
395
|
-
|
|
396
|
-
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
### Session-isolated workspaces
|
|
400
|
-
|
|
401
|
-
Each session gets its own sandbox directory — tools operate within it, files cannot escape:
|
|
402
|
-
|
|
403
|
-
```
|
|
404
|
-
cwd/.sessions/opencode/1/ ← session 1's workspace
|
|
405
|
-
cwd/.sessions/opencode/2/ ← session 2's workspace
|
|
406
|
-
cwd/.sessions/chat/3/ ← different mount point
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
Workspaces are computed from `cwd { ctx.mountPath } { sessionId }`. The system prompt shows the session's workspace so the LLM knows where it is.
|
|
410
|
-
|
|
411
|
-
### Tools
|
|
412
|
-
|
|
413
|
-
| Tool | Description |
|
|
414
|
-
|------|-------------|
|
|
415
|
-
| `bash` | Execute shell commands in the workspace |
|
|
416
|
-
| `read` | Read files with offset/limit |
|
|
417
|
-
| `write` | Create or overwrite files |
|
|
418
|
-
| `edit` | Exact string replacements |
|
|
419
|
-
| `grep` | Regex content search |
|
|
420
|
-
| `glob` | Glob pattern file search |
|
|
421
|
-
| `web` | Fetch URL content |
|
|
422
|
-
| `question` | Ask the user for input |
|
|
423
|
-
| `skill` | Load a skill on demand |
|
|
424
|
-
|
|
425
|
-
### Skills
|
|
426
|
-
|
|
427
|
-
Skills are discovered from filesystem and loaded on demand via the `skill` tool — no system prompt bloat:
|
|
428
|
-
|
|
429
|
-
- Project: `.opencode/skills/{name}/SKILL.md`
|
|
430
|
-
- Global: `~/.config/opencode/skills/{name}/SKILL.md`
|
|
431
|
-
- Also reads: `.claude/skills/`, `.agents/skills/` (project + global)
|
|
432
|
-
|
|
433
|
-
```ts
|
|
434
|
-
const oc = await opencode({
|
|
435
|
-
pg,
|
|
436
|
-
skills: [{ name: 'git', description: 'Git workflow', content: '...' }],
|
|
437
|
-
})
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
### Permissions
|
|
441
|
-
|
|
442
|
-
Control tool access per conversation:
|
|
45
|
+
### Full app
|
|
443
46
|
|
|
444
47
|
```ts
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
permissions: {
|
|
448
|
-
bash: { allow: true },
|
|
449
|
-
read: { allow: true },
|
|
450
|
-
write: { allow: false },
|
|
451
|
-
edit: { allow: false },
|
|
452
|
-
skill: { '*': { allow: true }, 'internal-*': { allow: false } },
|
|
453
|
-
},
|
|
454
|
-
})
|
|
455
|
-
```
|
|
456
|
-
|
|
457
|
-
### Workspace isolation
|
|
458
|
-
|
|
459
|
-
```ts
|
|
460
|
-
const oc = await opencode({ pg, permissions })
|
|
461
|
-
// All sessions inherit the instance's workspace (default: process.cwd())
|
|
462
|
-
// Sessions cannot override their workspace
|
|
463
|
-
// Different mount points = different opencode() instances = isolated workspaces
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
```ts
|
|
467
|
-
import { serve, Router, postgres, user } from 'weifuwu'
|
|
48
|
+
import { serve, Router, postgres, user, aiStream, graphql } from 'weifuwu'
|
|
49
|
+
import { openai } from '@ai-sdk/openai'
|
|
468
50
|
|
|
469
51
|
const app = new Router()
|
|
470
52
|
const pg = postgres()
|
|
471
|
-
await pg.migrate()
|
|
472
53
|
|
|
54
|
+
// Auth
|
|
473
55
|
const auth = user({ pg, jwtSecret: process.env.JWT_SECRET! })
|
|
474
|
-
|
|
475
|
-
// POST /auth/register { email, password, name }
|
|
476
|
-
// POST /auth/login { email, password }
|
|
477
|
-
// GET /auth/oauth/authorize?client_id=...&redirect_uri=...&response_type=code
|
|
478
|
-
// POST /auth/oauth/consent
|
|
479
|
-
// POST /auth/oauth/token (grant_type=authorization_code|client_credentials)
|
|
56
|
+
await auth.migrate()
|
|
480
57
|
app.use('/auth', auth.router())
|
|
481
58
|
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
Password hashing uses `crypto.scryptSync` + `timingSafeEqual` (Node.js built-in, zero deps). JWT tokens use the `jsonwebtoken` package. The users table (`_users` by default) is auto-created on first `migrate()`.
|
|
490
|
-
|
|
491
|
-
### OAuth2 Server
|
|
492
|
-
|
|
493
|
-
Enable OAuth2 Server to let third-party apps (SPA, mobile, microservices) authenticate users through your app.
|
|
494
|
-
|
|
495
|
-
```ts
|
|
496
|
-
const auth = user({
|
|
497
|
-
pg,
|
|
498
|
-
jwtSecret: process.env.JWT_SECRET!,
|
|
499
|
-
oauth2: { server: true },
|
|
500
|
-
})
|
|
501
|
-
|
|
502
|
-
await auth.migrate() // creates _users + _oauth2_clients + _oauth2_codes + _oauth2_tokens
|
|
503
|
-
|
|
504
|
-
// Register a client app (programmatic — CLI, admin UI, seed script)
|
|
505
|
-
const client = await auth.registerClient({
|
|
506
|
-
name: 'My SPA',
|
|
507
|
-
redirectUris: ['https://myapp.com/callback'],
|
|
508
|
-
})
|
|
509
|
-
// → { clientId, clientSecret, name, redirectUris }
|
|
510
|
-
|
|
511
|
-
// Use auth middleware to protect routes — OAuth2 JWT tokens work seamlessly
|
|
512
|
-
app.get('/api/data', auth.middleware(), handler)
|
|
513
|
-
```
|
|
514
|
-
|
|
515
|
-
#### Supported Grant Types
|
|
516
|
-
|
|
517
|
-
| Grant | Use Case | PKCE |
|
|
518
|
-
|-------|----------|------|
|
|
519
|
-
| `authorization_code` (with client_secret) | Server-side apps | Optional |
|
|
520
|
-
| `authorization_code` (with `code_challenge`/`code_verifier`) | SPA / Mobile apps | Required |
|
|
521
|
-
| `client_credentials` | Machine-to-machine | — |
|
|
522
|
-
|
|
523
|
-
#### Flow (Authorization Code + PKCE)
|
|
524
|
-
|
|
525
|
-
```
|
|
526
|
-
1. 第三方 App 引导用户:
|
|
527
|
-
GET /oauth/authorize?client_id=xxx&redirect_uri=https://app.com/cb
|
|
528
|
-
&response_type=code&code_challenge=S256&state=yyy
|
|
529
|
-
|
|
530
|
-
2. 用户未登录 → 302 到 /login?redirect=... → 登录后自动回到授权页
|
|
531
|
-
|
|
532
|
-
3. 用户确认授权 → POST /oauth/consent { approve: true, client_id, ... }
|
|
533
|
-
302 redirect_uri?code=xxx&state=yyy
|
|
534
|
-
|
|
535
|
-
4. 第三方 App POST /oauth/token
|
|
536
|
-
{ grant_type: authorization_code, code, client_id, client_secret,
|
|
537
|
-
redirect_uri, code_verifier }
|
|
538
|
-
→ { access_token, token_type: "Bearer", expires_in, refresh_token }
|
|
539
|
-
|
|
540
|
-
5. access_token 是标准 JWT,auth.middleware() 和 auth.verify() 直接可用
|
|
541
|
-
```
|
|
542
|
-
|
|
543
|
-
#### Client Management
|
|
544
|
-
|
|
545
|
-
```ts
|
|
546
|
-
const client = await auth.registerClient({ name, redirectUris })
|
|
547
|
-
const found = await auth.getClient(client.clientId)
|
|
548
|
-
await auth.revokeClient(client.clientId)
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
#### Using OAuth2 Tokens with the Built-in Auth Middleware
|
|
552
|
-
|
|
553
|
-
OAuth2 Server 签发的 `access_token` 与密码登录的 JWT 使用同一 `jwtSecret`,payload 向下兼容(`sub`、`email`、`role`),所以 `auth()` 无需任何修改即可验证 OAuth2 签发的 token:
|
|
554
|
-
|
|
555
|
-
```ts
|
|
556
|
-
import { auth } from 'weifuwu'
|
|
557
|
-
|
|
558
|
-
// 同一个 auth() 中间件同时支持密码登录 JWT 和 OAuth2 JWT
|
|
559
|
-
app.get('/api', auth({ verify: (token) => auth.verify(token) }), handler)
|
|
560
|
-
```
|
|
561
|
-
|
|
562
|
-
For `client_credentials` tokens (machine-to-machine), `verify()` returns `null` since no user is associated.
|
|
563
|
-
|
|
564
|
-
## Tenant BaaS
|
|
565
|
-
|
|
566
|
-
Built-in multi-tenant backend-as-a-service — define tables at runtime via API, get RESTful CRUD + GraphQL automatically, with row-level tenant isolation.
|
|
567
|
-
|
|
568
|
-
```ts
|
|
569
|
-
import { serve, Router, postgres, user, tenant } from 'weifuwu'
|
|
570
|
-
|
|
571
|
-
const pg = postgres()
|
|
572
|
-
const u = user({ pg, jwtSecret: process.env.JWT_SECRET! })
|
|
573
|
-
const t = tenant({ pg, usersTable: '_users' })
|
|
574
|
-
|
|
575
|
-
await pg.migrate()
|
|
576
|
-
await u.migrate()
|
|
577
|
-
await t.migrate() // creates _tenants, _tenant_members, _user_tables
|
|
578
|
-
|
|
579
|
-
const app = new Router()
|
|
580
|
-
app.use('/auth', u.router())
|
|
581
|
-
app.use('/api', u.middleware()) // → ctx.user
|
|
582
|
-
app.use('/api', t.middleware()) // → ctx.tenant
|
|
583
|
-
app.use('/api', t.router()) // → management + data CRUD
|
|
584
|
-
app.use('/graphql', t.graphql()) // → dynamic GraphQL
|
|
585
|
-
```
|
|
586
|
-
|
|
587
|
-
### System tables
|
|
588
|
-
|
|
589
|
-
| Table | Purpose |
|
|
590
|
-
|-------|---------|
|
|
591
|
-
| `_tenants` | Tenant records (`id TEXT PK DEFAULT gen_random_uuid()`, `name`, `created_at`) |
|
|
592
|
-
| `_tenant_members` | User-tenant membership (`tenant_id`, `user_id`, `role`) |
|
|
593
|
-
| `_user_tables` | Dynamic table definitions (`tenant_id`, `slug`, `fields JSONB`) |
|
|
594
|
-
|
|
595
|
-
### Dynamic table API
|
|
596
|
-
|
|
597
|
-
Create a table at runtime:
|
|
598
|
-
|
|
599
|
-
```json
|
|
600
|
-
POST /api/tables
|
|
601
|
-
{
|
|
602
|
-
"slug": "articles",
|
|
603
|
-
"fields": [
|
|
604
|
-
{ "name": "title", "type": "string", "required": true },
|
|
605
|
-
{ "name": "content", "type": "text" },
|
|
606
|
-
{ "name": "status", "type": "enum", "options": ["draft", "published"], "default": "draft" },
|
|
607
|
-
{ "name": "views", "type": "integer", "default": 0 },
|
|
608
|
-
{ "name": "embedding", "type": "vector", "dimensions": 1536, "index": "hnsw" }
|
|
609
|
-
]
|
|
610
|
-
}
|
|
611
|
-
```
|
|
612
|
-
|
|
613
|
-
→ Creates a PostgreSQL table with `id SERIAL PK`, `tenant_id TEXT NOT NULL`, and the specified columns, plus indexes. The table name is internally scoped to the tenant.
|
|
614
|
-
|
|
615
|
-
### Field types
|
|
616
|
-
|
|
617
|
-
| type | PostgreSQL | Index support |
|
|
618
|
-
|------|-----------|---------------|
|
|
619
|
-
| `string` | `TEXT` | `true`, `unique` |
|
|
620
|
-
| `integer` | `INTEGER` | `true`, `desc`, `unique` |
|
|
621
|
-
| `float` | `DOUBLE PRECISION` | `true`, `desc` |
|
|
622
|
-
| `boolean` | `BOOLEAN` | `true` |
|
|
623
|
-
| `text` | `TEXT` | `true` |
|
|
624
|
-
| `datetime` | `TIMESTAMPTZ` | `true`, `desc` |
|
|
625
|
-
| `date` | `DATE` | `true`, `desc` |
|
|
626
|
-
| `enum` | `TEXT` (with validation) | `true` |
|
|
627
|
-
| `json` | `JSONB` | `gin` |
|
|
628
|
-
| `vector` | `vector(n)` (pgvector) | `hnsw` (HNSW, vector_cosine_ops) |
|
|
629
|
-
|
|
630
|
-
### Relationships
|
|
631
|
-
|
|
632
|
-
Declare a foreign key via the `relation` field:
|
|
633
|
-
|
|
634
|
-
```json
|
|
635
|
-
{ "name": "article_id", "type": "integer", "relation": { "table": "articles", "onDelete": "cascade" } }
|
|
636
|
-
```
|
|
637
|
-
|
|
638
|
-
Supported relationship patterns:
|
|
639
|
-
|
|
640
|
-
| Pattern | Detection | REST | GraphQL |
|
|
641
|
-
|---------|-----------|------|---------|
|
|
642
|
-
| **belongs_to** | Field with `relation` | — | `comment.article` resolver |
|
|
643
|
-
| **has_many** | Another table has a relation pointing here | `GET /api/articles/:id/comments` | `article.comments` resolver |
|
|
644
|
-
| **M2M** | Junction table with exactly two relation fields | `GET /api/articles/:id/tags` (bypasses junction) | `article.tags` / `tag.articles` resolver |
|
|
645
|
-
| **Self-ref** | Relation field pointing to same table | — | With depth control |
|
|
646
|
-
|
|
647
|
-
### RESTful API
|
|
648
|
-
|
|
649
|
-
All routes require `ctx.tenant` (set by `t.middleware()`). All queries automatically filter by `tenant_id`.
|
|
650
|
-
|
|
651
|
-
| Route | Method | Description |
|
|
652
|
-
|-------|--------|-------------|
|
|
653
|
-
| `/sys/tenants` | POST | Create tenant, caller becomes admin |
|
|
654
|
-
| `/sys/tenants` | GET | List user's tenants |
|
|
655
|
-
| `/sys/tenants/invite` | POST | Invite user by email (admin) |
|
|
656
|
-
| `/sys/tenants/members/:userId` | DELETE | Remove member (admin) |
|
|
657
|
-
| `/sys/tables` | POST/GET | Create / list dynamic tables |
|
|
658
|
-
| `/sys/tables/:slug` | GET/PATCH/DELETE | Get schema / add fields / drop table |
|
|
659
|
-
| `/:slug` | GET | List rows (limit, offset, sort) |
|
|
660
|
-
| `/:slug` | POST | Create row |
|
|
661
|
-
| `/:slug/:id` | GET/PATCH/DELETE | Get / update / delete row |
|
|
662
|
-
| `/:slug/:id/:_nested` | GET | List related rows (has_many / M2M) |
|
|
663
|
-
| `/:slug/:id/:_nested` | POST | Create related row (auto-fills relation field) |
|
|
664
|
-
|
|
665
|
-
### Vector search
|
|
666
|
-
|
|
667
|
-
```http
|
|
668
|
-
GET /api/articles?search_vector=[0.1,0.2,...]&search_field=embedding&search_limit=10
|
|
669
|
-
```
|
|
670
|
-
|
|
671
|
-
Returns rows ordered by cosine distance (`<=>`), includes `_distance` field. Supports `l2` (`<->`) and `ip` (`<#>`):
|
|
672
|
-
|
|
673
|
-
```http
|
|
674
|
-
GET /api/articles?search_vector=[...]&search_field=embedding&search_distance=l2
|
|
675
|
-
```
|
|
676
|
-
|
|
677
|
-
### GraphQL
|
|
678
|
-
|
|
679
|
-
Dynamic GraphQL schema generated per-request based on the authenticated tenant's tables:
|
|
680
|
-
|
|
681
|
-
```graphql
|
|
682
|
-
type Article {
|
|
683
|
-
id: ID!
|
|
684
|
-
title: String!
|
|
685
|
-
content: String
|
|
686
|
-
status: String
|
|
687
|
-
comments(limit: Int, offset: Int): [Comment!]!
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
type Query {
|
|
691
|
-
articles(limit: Int, offset: Int): [Article!]!
|
|
692
|
-
getArticle(id: ID!): Article
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
type Mutation {
|
|
696
|
-
createArticle(data: CreateArticleInput!): Article!
|
|
697
|
-
updateArticle(id: ID!, data: PatchArticleInput!): Article!
|
|
698
|
-
deleteArticle(id: ID!): Boolean!
|
|
699
|
-
}
|
|
700
|
-
```
|
|
701
|
-
|
|
702
|
-
Built with `graphql-js` native constructors (`GraphQLObjectType`), no SDL generation, no `makeExecutableSchema`.
|
|
703
|
-
|
|
704
|
-
### Middleware
|
|
705
|
-
|
|
706
|
-
`t.middleware()` extracts the tenant context:
|
|
707
|
-
|
|
708
|
-
1. Requires `ctx.user` (from `u.middleware()`)
|
|
709
|
-
2. Looks up user's tenant memberships
|
|
710
|
-
3. Single tenant → automatically set `ctx.tenant`
|
|
711
|
-
4. Multiple tenants → require `X-Tenant-ID` header, return 300 with tenant list if missing
|
|
712
|
-
5. No tenants → 403
|
|
713
|
-
|
|
714
|
-
### Tenant lifecycle
|
|
715
|
-
|
|
716
|
-
```ts
|
|
717
|
-
const t = tenant({ pg, usersTable: '_users' })
|
|
718
|
-
|
|
719
|
-
// Create a tenant — the caller becomes admin
|
|
720
|
-
const tenant = await (await fetch('http://localhost/api/sys/tenants', {
|
|
721
|
-
method: 'POST',
|
|
722
|
-
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer <jwt>' },
|
|
723
|
-
body: JSON.stringify({ name: 'Acme Corp' }),
|
|
724
|
-
})).json()
|
|
725
|
-
// → { id: "uuid", name: "Acme Corp", created_at: "..." }
|
|
726
|
-
|
|
727
|
-
// Invite a member
|
|
728
|
-
await fetch('http://localhost/api/sys/tenants/invite', {
|
|
729
|
-
method: 'POST',
|
|
730
|
-
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer <jwt>' },
|
|
731
|
-
body: JSON.stringify({ email: 'colleague@acme.com', role: 'member' }),
|
|
732
|
-
})
|
|
733
|
-
```
|
|
734
|
-
|
|
735
|
-
## AI Agent
|
|
736
|
-
|
|
737
|
-
Server-side AI agents with OpenAI-compatible API. Built-in chat, tool-use (tool-calling), and knowledge (RAG) types. Works out of the box with Ollama or any OpenAI-compatible provider.
|
|
738
|
-
|
|
739
|
-
```ts
|
|
740
|
-
import { agent } from 'weifuwu'
|
|
741
|
-
|
|
742
|
-
const agents = agent({ pg })
|
|
743
|
-
|
|
744
|
-
await agents.migrate()
|
|
745
|
-
app.use('/api', agents.router())
|
|
746
|
-
```
|
|
747
|
-
|
|
748
|
-
| Type | Description | Execution |
|
|
749
|
-
|------|-------------|-----------|
|
|
750
|
-
| `chat` | Pure conversation | `streamText()` / `generateText()` |
|
|
751
|
-
| `tool-use` | Tool-calling agent | `streamText({ tools })` |
|
|
752
|
-
|
|
753
|
-
### Knowledge (RAG)
|
|
754
|
-
|
|
755
|
-
Add documents to any agent — `searchKnowledge` tool auto-injected:
|
|
756
|
-
|
|
757
|
-
```ts
|
|
758
|
-
await agents.addKnowledge(agentId, 'Title', 'Document content...')
|
|
759
|
-
// The agent automatically calls searchKnowledge when answering
|
|
760
|
-
```
|
|
761
|
-
|
|
762
|
-
### Streaming
|
|
763
|
-
|
|
764
|
-
```http
|
|
765
|
-
POST /agents/:id/run { input: "hello", stream: true }
|
|
766
|
-
→ event-stream (fullStream SSE: text-delta, tool-call, tool-result, finish)
|
|
767
|
-
```
|
|
768
|
-
|
|
769
|
-
### Programmatic API
|
|
770
|
-
|
|
771
|
-
```ts
|
|
772
|
-
const result = await agents.run(agentId, { input: 'hello', stream: false })
|
|
773
|
-
// { output: "Hello!", elapsed: 1234 }
|
|
774
|
-
```
|
|
775
|
-
|
|
776
|
-
## Messager
|
|
777
|
-
|
|
778
|
-
Real-time chat with channels, WebSocket, and agent routing.
|
|
779
|
-
|
|
780
|
-
```ts
|
|
781
|
-
import { messager, agent } from 'weifuwu'
|
|
782
|
-
|
|
783
|
-
const agents = agent({ pg })
|
|
784
|
-
const msg = messager({ pg, agents })
|
|
785
|
-
|
|
786
|
-
await msg.migrate()
|
|
787
|
-
app.use('/api', msg.router())
|
|
788
|
-
app.ws('/ws', u.middleware(), msg.wsHandler())
|
|
789
|
-
```
|
|
790
|
-
|
|
791
|
-
### Channels
|
|
792
|
-
|
|
793
|
-
```http
|
|
794
|
-
POST /channels name, type (channel|dm), members
|
|
795
|
-
GET /channels
|
|
796
|
-
GET /channels/:id
|
|
797
|
-
```
|
|
798
|
-
|
|
799
|
-
### Messages
|
|
800
|
-
|
|
801
|
-
```http
|
|
802
|
-
GET /channels/:id/messages ?limit=50&before={id}
|
|
803
|
-
POST /channels/:id/messages content, sender_type, type
|
|
804
|
-
POST /channels/:id/read last_message_id
|
|
805
|
-
```
|
|
806
|
-
|
|
807
|
-
### WebSocket
|
|
808
|
-
|
|
809
|
-
```json
|
|
810
|
-
{ "type": "message", "channel_id": 1, "content": "Hi" }
|
|
811
|
-
{ "type": "typing", "channel_id": 1, "is_typing": true }
|
|
812
|
-
{ "type": "read", "channel_id": 1, "last_message_id": 42 }
|
|
813
|
-
```
|
|
814
|
-
|
|
815
|
-
### Programmatic send
|
|
816
|
-
|
|
817
|
-
```ts
|
|
818
|
-
await msg.send(channelId, 'System message', { sender_type: 'system' })
|
|
819
|
-
```
|
|
820
|
-
|
|
821
|
-
## WebSocket
|
|
822
|
-
message(ws, ctx, data) {
|
|
823
|
-
ws.send(`echo: ${data}`)
|
|
824
|
-
},
|
|
825
|
-
close(ws, ctx) {
|
|
826
|
-
console.log('disconnected')
|
|
827
|
-
},
|
|
828
|
-
})
|
|
829
|
-
|
|
830
|
-
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
831
|
-
```
|
|
832
|
-
|
|
833
|
-
Middleware runs **before** WebSocket upgrade — you can reject connections with HTTP status codes:
|
|
834
|
-
|
|
835
|
-
```ts
|
|
836
|
-
app.ws('/secure',
|
|
837
|
-
(req, _ctx, next) => {
|
|
838
|
-
const auth = req.headers.get('Authorization')
|
|
839
|
-
if (!auth) return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
840
|
-
return next(req, _ctx)
|
|
841
|
-
},
|
|
842
|
-
{ open(ws) { ws.send('authorized') } },
|
|
843
|
-
)
|
|
844
|
-
```
|
|
845
|
-
|
|
846
|
-
## GraphQL
|
|
847
|
-
|
|
848
|
-
GraphQL endpoint with GraphiQL IDE. Mount as a sub-Router:
|
|
849
|
-
|
|
850
|
-
```ts
|
|
851
|
-
import { serve, Router, graphql } from 'weifuwu'
|
|
59
|
+
// AI streaming
|
|
60
|
+
const chat = await aiStream(async (req) => ({
|
|
61
|
+
model: openai('gpt-4o'),
|
|
62
|
+
messages: (await req.json()).messages,
|
|
63
|
+
}))
|
|
64
|
+
app.use('/chat', chat.router())
|
|
852
65
|
|
|
853
|
-
|
|
66
|
+
// GraphQL
|
|
854
67
|
const gql = graphql(() => ({
|
|
855
|
-
schema: `
|
|
856
|
-
|
|
857
|
-
type Mutation { setMessage(msg: String!): String }
|
|
858
|
-
`,
|
|
859
|
-
resolvers: {
|
|
860
|
-
Query: { hello: () => 'world' },
|
|
861
|
-
Mutation: { setMessage: (_, { msg }) => msg },
|
|
862
|
-
},
|
|
863
|
-
graphiql: true,
|
|
68
|
+
schema: `type Query { hello: String }`,
|
|
69
|
+
resolvers: { Query: { hello: () => 'world' } },
|
|
864
70
|
}))
|
|
865
71
|
app.use('/graphql', gql.router())
|
|
866
72
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
The handler receives `(req, ctx)` so you can customize the schema based on the request.
|
|
871
|
-
|
|
872
|
-
## AI streaming
|
|
873
|
-
|
|
874
|
-
Server-sent event streaming via the Vercel AI SDK:
|
|
875
|
-
|
|
876
|
-
```ts
|
|
877
|
-
import { serve, Router, aiStream } from 'weifuwu'
|
|
878
|
-
import { openai } from '@ai-sdk/openai'
|
|
879
|
-
|
|
880
|
-
const app = new Router()
|
|
881
|
-
const chat = await aiStream(async (req, ctx) => {
|
|
882
|
-
const { messages } = await req.json()
|
|
883
|
-
return { model: openai('gpt-4o'), messages }
|
|
884
|
-
})
|
|
885
|
-
app.use('/chat', chat.router())
|
|
73
|
+
// Static files
|
|
74
|
+
app.get('/static/*', serveStatic('./public'))
|
|
886
75
|
|
|
887
76
|
serve(app.handler(), { port: 3000 })
|
|
888
77
|
```
|
|
889
78
|
|
|
890
|
-
## runWorkflow
|
|
891
|
-
|
|
892
|
-
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.
|
|
893
|
-
|
|
894
|
-
```ts
|
|
895
|
-
import { tool, streamText } from 'ai'
|
|
896
|
-
import { runWorkflow } from 'weifuwu'
|
|
897
|
-
import { z } from 'zod'
|
|
898
|
-
|
|
899
|
-
const tools = {
|
|
900
|
-
queryUser: tool({
|
|
901
|
-
description: 'Query user info',
|
|
902
|
-
inputSchema: z.object({ userId: z.string() }),
|
|
903
|
-
execute: async ({ userId }) => ({ id: userId, email: 'user@test.com', name: 'Test' }),
|
|
904
|
-
}),
|
|
905
|
-
sendEmail: tool({
|
|
906
|
-
description: 'Send an email',
|
|
907
|
-
inputSchema: z.object({ to: z.string(), subject: z.string() }),
|
|
908
|
-
execute: async ({ to, subject }) => ({ sent: true }),
|
|
909
|
-
}),
|
|
910
|
-
runWF: runWorkflow({ tools: { queryUser, sendEmail } }),
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// Use in any streamText call — the LLM can decide when to trigger a workflow
|
|
914
|
-
const result = await streamText({
|
|
915
|
-
model,
|
|
916
|
-
tools,
|
|
917
|
-
messages: [{ role: 'user', content: '查询用户123,如果存在则发送欢迎邮件' }],
|
|
918
|
-
})
|
|
919
|
-
```
|
|
920
|
-
|
|
921
|
-
### Node types
|
|
922
|
-
|
|
923
|
-
7 built-in node types for defining the execution graph:
|
|
924
|
-
|
|
925
|
-
| Node | Purpose | Input |
|
|
926
|
-
|------|---------|-------|
|
|
927
|
-
| `call` | Call a registered AI SDK Tool | `{ tool: "name", args: {...} }` |
|
|
928
|
-
| `set` | Assign a variable | `{ name: "x", value: 42 }` |
|
|
929
|
-
| `get` | Read a variable | `{ name: "x" }` |
|
|
930
|
-
| `eval` | Evaluate an expression | `{ expression: "$var.x + 1" }` |
|
|
931
|
-
| `if` | Conditional branch | `{ conditions: [{ test: ..., body: [nodes] }] }` |
|
|
932
|
-
| `while` | Loop | `{ condition: "$var.i < 5" }, body: [nodes]` |
|
|
933
|
-
| `http` | HTTP request | `{ url: "https://...", method: "GET" }` |
|
|
934
|
-
|
|
935
|
-
### Reference syntax
|
|
936
|
-
|
|
937
|
-
| Pattern | Meaning | Example |
|
|
938
|
-
|---------|---------|---------|
|
|
939
|
-
| `$var.x` | Variable `x` | `$var.counter` |
|
|
940
|
-
| `$nodes.u.output` | Full output of node `u` | `$nodes.u.output` |
|
|
941
|
-
| `$nodes.u.output.field` | Specific field | `$nodes.u.output.email` |
|
|
942
|
-
| `$input.userId` | Input param | `$input.userId` |
|
|
943
|
-
|
|
944
|
-
### LLM generation
|
|
945
|
-
|
|
946
|
-
Pass a `model` to `runWorkflow` — the LLM generates the workflow JSON from a goal:
|
|
947
|
-
|
|
948
|
-
```ts
|
|
949
|
-
const runWF = runWorkflow({
|
|
950
|
-
tools: { queryUser, sendEmail },
|
|
951
|
-
model: openai('gpt-4o'),
|
|
952
|
-
})
|
|
953
|
-
|
|
954
|
-
const result = await streamText({
|
|
955
|
-
model,
|
|
956
|
-
tools: { runWF },
|
|
957
|
-
})
|
|
958
|
-
```
|
|
959
|
-
|
|
960
|
-
The LLM calls `runWF` with a goal, and `runWorkflow` internally calls `generateText` to produce the workflow nodes, then executes them.
|
|
961
|
-
|
|
962
|
-
## React pages with tsx()
|
|
963
|
-
|
|
964
|
-
```ts
|
|
965
|
-
import { serve, Router } from 'weifuwu'
|
|
966
|
-
import { tsx } from 'weifuwu/tsx'
|
|
967
|
-
|
|
968
|
-
const app = new Router()
|
|
969
|
-
app.use('/', await tsx({ dir: './ui/' }))
|
|
970
|
-
|
|
971
|
-
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
972
|
-
```
|
|
973
|
-
|
|
974
|
-
### Directory structure
|
|
975
|
-
|
|
976
|
-
```
|
|
977
|
-
ui/
|
|
978
|
-
├── pages/ ← 页面文件
|
|
979
|
-
│ ├── page.tsx → GET / (React component, default export)
|
|
980
|
-
│ ├── layout.tsx → root layout (HTML shell, receives req/ctx, NOT hydrated)
|
|
981
|
-
│ ├── not-found.tsx → 404 error page (rendered for unmatched routes, wrapped in layout)
|
|
982
|
-
│ ├── about/page.tsx → GET /about
|
|
983
|
-
│ ├── blog/[slug]/
|
|
984
|
-
│ │ ├── page.tsx → GET /blog/:slug
|
|
985
|
-
│ │ ├── load.ts → data fetching (server-only, default export)
|
|
986
|
-
│ │ └── route.ts → POST /blog/:slug (API, named exports POST/PUT/DELETE/...)
|
|
987
|
-
│ ├── blog/layout.tsx → /blog/* layout (UI structure, receives children, hydrated)
|
|
988
|
-
│ └── api/search/
|
|
989
|
-
│ └── route.ts → GET /api/search (standalone API, no page.tsx needed)
|
|
990
|
-
└── components/ ← 组件文件(会被热更自动感知)
|
|
991
|
-
└── button.tsx
|
|
992
|
-
```
|
|
993
|
-
|
|
994
|
-
### Development mode
|
|
995
|
-
|
|
996
|
-
tsx() runs in development mode automatically when `NODE_ENV !== 'production'`:
|
|
997
|
-
|
|
998
|
-
- **File watching** — chokidar watches the `dir` directory for `.tsx`/`.ts` changes
|
|
999
|
-
- Page files in `pages/` → single-file recompilation + registry update
|
|
1000
|
-
- Component files in `components/` → full rebuild of all pages
|
|
1001
|
-
- New files are detected automatically
|
|
1002
|
-
- **Live reload** — Compiled via esbuild `write: false` + `vm.Script.runInContext` (no disk writes, no `node --watch` conflict)
|
|
1003
|
-
- **WebSocket auto-refresh** — `/__weifuwu/livereload` endpoint pushes reload signals; browser refreshes automatically
|
|
1004
|
-
- **`node --watch` compatible** — External files (`app.ts`, `middleware/`) handled by `--watch` restart; `ui/` changes handled by tsx() without conflict
|
|
1005
|
-
|
|
1006
|
-
```bash
|
|
1007
|
-
node app.ts # development (auto-reload + live refresh)
|
|
1008
|
-
NODE_ENV=production node app.ts # production
|
|
1009
|
-
```
|
|
1010
|
-
|
|
1011
|
-
### Tailwind CSS
|
|
1012
|
-
|
|
1013
|
-
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:
|
|
1014
|
-
|
|
1015
|
-
```css
|
|
1016
|
-
@import "tailwindcss";
|
|
1017
|
-
```
|
|
1018
|
-
|
|
1019
|
-
Write `className` directly in your components — no CLI, no configuration:
|
|
1020
|
-
|
|
1021
|
-
```tsx
|
|
1022
|
-
export default function Home() {
|
|
1023
|
-
return <h1 className="text-3xl font-bold text-blue-600">Hello</h1>
|
|
1024
|
-
}
|
|
1025
|
-
```
|
|
1026
|
-
|
|
1027
|
-
In development mode, Tailwind is reprocessed whenever a `.tsx` file changes (new class names are picked up automatically).
|
|
1028
|
-
|
|
1029
|
-
### `@` alias
|
|
1030
|
-
|
|
1031
|
-
If your project has a `tsconfig.json` or `jsconfig.json` with `compilerOptions.paths`, tsx() reads it automatically and passes aliases to all esbuild builds (SSR compilation, hydration bundles, and hot reload):
|
|
1032
|
-
|
|
1033
|
-
```json
|
|
1034
|
-
{
|
|
1035
|
-
"compilerOptions": {
|
|
1036
|
-
"paths": {
|
|
1037
|
-
"@/*": ["./ui/*"]
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
```
|
|
1042
|
-
|
|
1043
|
-
This enables imports like `@/components/button` or `@/lib/utils` in both server-rendered and client-hydrated code.
|
|
1044
|
-
|
|
1045
|
-
### shadcn/ui
|
|
1046
|
-
|
|
1047
|
-
tsx() works with [shadcn/ui](https://ui.shadcn.com) out of the box. The `@` alias and Tailwind CSS are handled automatically.
|
|
1048
|
-
|
|
1049
|
-
```bash
|
|
1050
|
-
# 1. Install shadcn CLI and init (select "other" framework)
|
|
1051
|
-
npx shadcn@latest init
|
|
1052
|
-
|
|
1053
|
-
# 2. When prompted, configure:
|
|
1054
|
-
# - Style: your preference
|
|
1055
|
-
# - Base color: your preference
|
|
1056
|
-
# - CSS file path: ui/app.css
|
|
1057
|
-
# - Import alias: @/ → ./ui/
|
|
1058
|
-
# - React hooks: yes
|
|
1059
|
-
```
|
|
1060
|
-
|
|
1061
|
-
```json
|
|
1062
|
-
// tsconfig.json (generated by shadcn init)
|
|
1063
|
-
{
|
|
1064
|
-
"compilerOptions": {
|
|
1065
|
-
"paths": {
|
|
1066
|
-
"@/*": ["./ui/*"]
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
```
|
|
1071
|
-
|
|
1072
|
-
Add components:
|
|
1073
|
-
|
|
1074
|
-
```bash
|
|
1075
|
-
npx shadcn@latest add button card dialog
|
|
1076
|
-
```
|
|
1077
|
-
|
|
1078
|
-
Use them in your pages:
|
|
1079
|
-
|
|
1080
|
-
```tsx
|
|
1081
|
-
// ui/pages/page.tsx
|
|
1082
|
-
import { Button } from '@/components/ui/button'
|
|
1083
|
-
|
|
1084
|
-
export default function Home() {
|
|
1085
|
-
return <Button variant="outline">Click me</Button>
|
|
1086
|
-
}
|
|
1087
79
|
```
|
|
1088
|
-
|
|
1089
|
-
```bash
|
|
1090
80
|
node app.ts
|
|
1091
81
|
```
|
|
1092
82
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
**Nested layouts** (`pages/blog/layout.tsx`) — receives only `{ children }`.
|
|
1140
|
-
|
|
1141
|
-
### route.ts — API (co-located with page)
|
|
1142
|
-
|
|
1143
|
-
```ts
|
|
1144
|
-
export const POST: Handler = async (req, ctx) => {
|
|
1145
|
-
const body = await req.json()
|
|
1146
|
-
return Response.json({ ...body, slug: ctx.params.slug })
|
|
1147
|
-
}
|
|
1148
|
-
```
|
|
1149
|
-
|
|
1150
|
-
### not-found.tsx — 404 page
|
|
1151
|
-
|
|
1152
|
-
```tsx
|
|
1153
|
-
export default function NotFound() {
|
|
1154
|
-
return <h1 class="text-4xl">404 – Not Found</h1>
|
|
1155
|
-
}
|
|
1156
|
-
```
|
|
1157
|
-
|
|
1158
|
-
## Health check
|
|
1159
|
-
|
|
1160
|
-
```ts
|
|
1161
|
-
import { serve, Router, health } from 'weifuwu'
|
|
1162
|
-
|
|
1163
|
-
const app = new Router()
|
|
1164
|
-
app.use(health()) // GET /health → 200
|
|
1165
|
-
app.use(health({ path: '/healthz' })) // custom path
|
|
1166
|
-
app.use(health({
|
|
1167
|
-
check: async () => { await db.sql`SELECT 1` }, // fail → 503
|
|
1168
|
-
}))
|
|
1169
|
-
serve(app.handler(), { port: 3000 })
|
|
1170
|
-
```
|
|
1171
|
-
|
|
1172
|
-
Returns a `Router` — mount with `app.use()`.
|
|
1173
|
-
|
|
1174
|
-
## Internationalization
|
|
1175
|
-
|
|
1176
|
-
```ts
|
|
1177
|
-
import { i18n } from 'weifuwu'
|
|
1178
|
-
|
|
1179
|
-
app.use(i18n({ dir: './locales', defaultLocale: 'en' }))
|
|
1180
|
-
|
|
1181
|
-
// In any handler after i18n middleware:
|
|
1182
|
-
app.get('/hello', (req, ctx) => {
|
|
1183
|
-
const msg = ctx.t('greeting', { name: 'World' })
|
|
1184
|
-
return Response.json({ message: msg, locale: ctx.locale })
|
|
1185
|
-
})
|
|
1186
|
-
```
|
|
1187
|
-
|
|
1188
|
-
Locale detection: `Cookie: locale=zh` → `Accept-Language: zh-CN` → `defaultLocale`.
|
|
1189
|
-
|
|
1190
|
-
## Email
|
|
1191
|
-
|
|
1192
|
-
```ts
|
|
1193
|
-
import { mailer } from 'weifuwu'
|
|
1194
|
-
|
|
1195
|
-
// SMTP transport
|
|
1196
|
-
const mail = mailer({
|
|
1197
|
-
transport: 'smtp://user:pass@smtp.example.com',
|
|
1198
|
-
from: 'noreply@example.com',
|
|
1199
|
-
})
|
|
1200
|
-
await mail.send({ to: 'user@example.com', subject: 'Welcome', html: '<h1>Hi!</h1>' })
|
|
1201
|
-
await mail.close()
|
|
1202
|
-
|
|
1203
|
-
// Custom transport (Resend, SES, SendGrid, etc.)
|
|
1204
|
-
const mail2 = mailer({
|
|
1205
|
-
send: async (msg) => { await resend.emails.send(msg) },
|
|
1206
|
-
})
|
|
1207
|
-
await mail2.send({ to: 'user@example.com', subject: 'Hi', text: 'Hello' })
|
|
1208
|
-
await mail2.close()
|
|
1209
|
-
```
|
|
1210
|
-
|
|
1211
|
-
## Test utilities
|
|
1212
|
-
|
|
1213
|
-
```ts
|
|
1214
|
-
import { createTestServer } from 'weifuwu'
|
|
1215
|
-
|
|
1216
|
-
const { server, url } = await createTestServer(app.handler())
|
|
1217
|
-
const res = await fetch(`${url}/api/users`)
|
|
1218
|
-
assert.equal(res.status, 200)
|
|
1219
|
-
server.stop()
|
|
1220
|
-
```
|
|
1221
|
-
|
|
1222
|
-
## Usage within a full app
|
|
1223
|
-
|
|
1224
|
-
```ts
|
|
1225
|
-
import { serve, Router, aiStream, graphql } from 'weifuwu'
|
|
1226
|
-
|
|
1227
|
-
const app = new Router()
|
|
1228
|
-
app.use('/', await tsx({ dir: './pages/' }))
|
|
1229
|
-
const chat = await aiStream(async (req) => ({ model: openai('gpt-4o'), messages: (await req.json()).messages }))
|
|
1230
|
-
app.use('/chat', chat.router())
|
|
1231
|
-
const gql = graphql(() => ({ schema: `type Query { hello: String }`, resolvers: { Query: { hello: () => 'world' } } }))
|
|
1232
|
-
app.use('/graphql', gql.router())
|
|
1233
|
-
app.ws('/chat', { message(ws, _, data) { ws.send(data) } })
|
|
1234
|
-
|
|
1235
|
-
serve(app.handler(), { websocket: app.websocketHandler() })
|
|
1236
|
-
```
|
|
1237
|
-
|
|
1238
|
-
```bash
|
|
1239
|
-
node app.ts # development (auto-reload + live refresh)
|
|
1240
|
-
NODE_ENV=production node app.ts # production
|
|
1241
|
-
```
|
|
1242
|
-
|
|
1243
|
-
No build step, no configuration file — just Node.js.
|
|
1244
|
-
|
|
1245
|
-
## Graceful shutdown
|
|
1246
|
-
|
|
1247
|
-
```ts
|
|
1248
|
-
import { serve } from 'weifuwu'
|
|
1249
|
-
import type { Server } from 'weifuwu'
|
|
1250
|
-
|
|
1251
|
-
const ac = new AbortController()
|
|
1252
|
-
let server: Server
|
|
1253
|
-
|
|
1254
|
-
process.on('SIGTERM', () => {
|
|
1255
|
-
ac.abort()
|
|
1256
|
-
server.stop()
|
|
1257
|
-
})
|
|
1258
|
-
|
|
1259
|
-
server = serve((req, ctx) => new Response('Hello'), {
|
|
1260
|
-
port: 3000,
|
|
1261
|
-
signal: ac.signal,
|
|
1262
|
-
})
|
|
1263
|
-
await server.ready
|
|
1264
|
-
```
|
|
1265
|
-
|
|
1266
|
-
### Using with WebSocket
|
|
1267
|
-
|
|
1268
|
-
```ts
|
|
1269
|
-
const app = new Router().ws('/chat', { … })
|
|
1270
|
-
const server = serve(app.handler(), {
|
|
1271
|
-
port: 3000,
|
|
1272
|
-
signal: ac.signal,
|
|
1273
|
-
websocket: app.websocketHandler(),
|
|
1274
|
-
})
|
|
1275
|
-
```
|
|
1276
|
-
|
|
1277
|
-
## Error handling
|
|
1278
|
-
|
|
1279
|
-
```ts
|
|
1280
|
-
const app = new Router()
|
|
1281
|
-
.onError((err, req, ctx) =>
|
|
1282
|
-
Response.json({ error: err.message }, { status: 500 }),
|
|
1283
|
-
)
|
|
1284
|
-
.get('/crash', () => { throw new Error('boom') })
|
|
1285
|
-
```
|
|
1286
|
-
|
|
1287
|
-
## Deploy
|
|
1288
|
-
|
|
1289
|
-
See [deploy.md](./deploy.md) for complete documentation — VPS setup, subdomain routing, blue-green zero-downtime, WebSocket bridge, Git webhook, auto SSL, and management API.
|
|
1290
|
-
|
|
1291
|
-
Quick start on a fresh VPS:
|
|
1292
|
-
|
|
1293
|
-
```bash
|
|
1294
|
-
# 1. Install Node.js
|
|
1295
|
-
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
|
|
1296
|
-
apt-get install -y nodejs git
|
|
1297
|
-
|
|
1298
|
-
# 2. Create deploy project
|
|
1299
|
-
mkdir -p /opt/deploy && cd /opt/deploy
|
|
1300
|
-
npm init -y && npm install weifuwu
|
|
1301
|
-
|
|
1302
|
-
# 3. Write deploy.ts
|
|
1303
|
-
cat > deploy.ts << 'EOF'
|
|
1304
|
-
import { deploy, defineConfig } from 'weifuwu'
|
|
1305
|
-
await deploy(defineConfig({
|
|
1306
|
-
domain: 'example.com',
|
|
1307
|
-
deployToken: process.env.DEPLOY_TOKEN,
|
|
1308
|
-
apps: {
|
|
1309
|
-
blog: {
|
|
1310
|
-
repo: 'https://github.com/me/my-blog.git',
|
|
1311
|
-
subdomain: 'blog',
|
|
1312
|
-
entry: 'app.ts',
|
|
1313
|
-
port: 3001,
|
|
1314
|
-
},
|
|
1315
|
-
},
|
|
1316
|
-
}))
|
|
1317
|
-
EOF
|
|
1318
|
-
|
|
1319
|
-
# 4. Run
|
|
1320
|
-
DEPLOY_TOKEN='my-secret' node deploy.ts
|
|
1321
|
-
```
|
|
1322
|
-
|
|
1323
|
-
## API
|
|
1324
|
-
|
|
1325
|
-
### `serve(handler, options?)`
|
|
1326
|
-
|
|
1327
|
-
| Option | Default | Description |
|
|
1328
|
-
|--------|---------|-------------|
|
|
1329
|
-
| `port` | `0` | Listen port (`0` = random) |
|
|
1330
|
-
| `hostname` | `'0.0.0.0'` | Bind address |
|
|
1331
|
-
| `signal` | — | `AbortSignal` for graceful shutdown |
|
|
1332
|
-
| `websocket` | — | Upgrade handler from `router.websocketHandler()` |
|
|
1333
|
-
|
|
1334
|
-
Returns `{ stop, port, hostname, ready }`.
|
|
1335
|
-
|
|
1336
|
-
### `user(options)`
|
|
1337
|
-
|
|
1338
|
-
| Option | Default | Description |
|
|
1339
|
-
|--------|---------|-------------|
|
|
1340
|
-
| `pg` | — | PostgreSQL client from `postgres()` |
|
|
1341
|
-
| `jwtSecret` | — | Secret key for JWT signing |
|
|
1342
|
-
| `table` | `'_users'` | Users table name |
|
|
1343
|
-
| `expiresIn` | `'24h'` | JWT expiration |
|
|
1344
|
-
| `oauth2.server` | `false` | Enable OAuth2 Server |
|
|
1345
|
-
|
|
1346
|
-
Returns `UserModule` — `{ router, middleware, migrate, register, login, verify, registerClient, getClient, revokeClient, close }`.
|
|
1347
|
-
|
|
1348
|
-
### `tenant(options)`
|
|
1349
|
-
|
|
1350
|
-
| Option | Default | Description |
|
|
1351
|
-
|--------|---------|-------------|
|
|
1352
|
-
| `pg` | — | PostgreSQL client from `postgres()` |
|
|
1353
|
-
| `usersTable` | — | Users table name (matching the `table` option passed to `user()`) |
|
|
1354
|
-
|
|
1355
|
-
Returns `TenantModule` — `{ migrate, middleware, router, graphql, close }`.
|
|
1356
|
-
|
|
1357
|
-
### `agent(options)`
|
|
1358
|
-
|
|
1359
|
-
| Option | Default | Description |
|
|
1360
|
-
|--------|---------|-------------|
|
|
1361
|
-
| `pg` | — | PostgreSQL client from `postgres()` |
|
|
1362
|
-
| `model` | env `OPENAI_MODEL` → Ollama | `LanguageModel` from ai SDK |
|
|
1363
|
-
| `embeddingModel` | env `OPENAI_EMBEDDING_MODEL` → Ollama | `EmbeddingModel` for knowledge RAG |
|
|
1364
|
-
| `embeddingDimension` | `1024` | Vector dimension for pgvector |
|
|
1365
|
-
| `tools` | — | Tools for tool-use agents (ai SDK `Tool` objects) |
|
|
1366
|
-
|
|
1367
|
-
Returns `AgentModule` — `{ migrate, router, run, addKnowledge, close }`.
|
|
1368
|
-
|
|
1369
|
-
### `opencode(options)`
|
|
1370
|
-
|
|
1371
|
-
| Option | Default | Description |
|
|
1372
|
-
|--------|---------|-------------|
|
|
1373
|
-
| `pg` | — | PostgreSQL client from `postgres()` |
|
|
1374
|
-
| `workspace` | `process.cwd()` | Base directory for `.sessions` |
|
|
1375
|
-
| `model` | `'deepseek-v4-flash'` | LLM model name |
|
|
1376
|
-
| `baseURL` | env `DEEPSEEK_BASE_URL` | API base URL |
|
|
1377
|
-
| `apiKey` | env `DEEPSEEK_API_KEY` | API key |
|
|
1378
|
-
| `systemPrompt` | — | Custom system prompt |
|
|
1379
|
-
| `skills` | `[]` | Static skill definitions |
|
|
1380
|
-
| `permissions` | — | Tool permission config |
|
|
1381
|
-
|
|
1382
|
-
Returns `OpencodeModule` — `{ migrate, router, wsHandler, close }`.
|
|
1383
|
-
|
|
1384
|
-
### `messager(options)`
|
|
1385
|
-
|
|
1386
|
-
| Option | Default | Description |
|
|
1387
|
-
|--------|---------|-------------|
|
|
1388
|
-
| `pg` | — | PostgreSQL client from `postgres()` |
|
|
1389
|
-
| `agents` | — | `AgentModule` instance (enables agent message routing) |
|
|
1390
|
-
|
|
1391
|
-
Returns `MessagerModule` — `{ migrate, router, wsHandler, send, close }`.
|
|
1392
|
-
|
|
1393
|
-
### `tsx(options)`
|
|
1394
|
-
|
|
1395
|
-
| Option | Default | Description |
|
|
1396
|
-
|--------|---------|-------------|
|
|
1397
|
-
| `dir` | — | UI directory path (containing `pages/` and optionally `components/`) |
|
|
1398
|
-
|
|
1399
|
-
Returns `Promise<Router>`.
|
|
1400
|
-
|
|
1401
|
-
Auto-detected features (no configuration needed):
|
|
1402
|
-
|
|
1403
|
-
| Feature | Behavior |
|
|
1404
|
-
|---------|----------|
|
|
1405
|
-
| **File watching** | Enabled in dev mode. Watches `dir` for changes, recompiles on the fly, sends reload via WebSocket |
|
|
1406
|
-
| **WebSocket live reload** | Endpoint at `/__weifuwu/livereload`. Browser auto-refreshes on file changes or server restart |
|
|
1407
|
-
| **Tailwind CSS** | Auto-detected when `app.css` exists. Compiled through PostCSS + `@tailwindcss/postcss`. Served at `/__wfw/style.css`, auto-injected into HTML `<head>` |
|
|
1408
|
-
| **`@` alias** | Read from `tsconfig.json` / `jsconfig.json` `compilerOptions.paths`. Passed to all esbuild builds |
|
|
1409
|
-
| **Process state** | Dev mode keeps the process alive on file changes. DB connections, WebSockets, in-memory caches persist |
|
|
1410
|
-
|
|
1411
|
-
To use WebSocket features, pass `router.websocketHandler()` to `serve()`:
|
|
1412
|
-
|
|
1413
|
-
```ts
|
|
1414
|
-
serve(app.handler(), { websocket: app.websocketHandler() })
|
|
1415
|
-
```
|
|
1416
|
-
|
|
1417
|
-
### `Router`
|
|
1418
|
-
|
|
1419
|
-
| Method | Description |
|
|
1420
|
-
|--------|-------------|
|
|
1421
|
-
| `get/post/put/delete/patch/head/options/all(path, ...mws, handler)` | Route registration |
|
|
1422
|
-
| `use(mw)` / `use(path, mw)` / `use(path, subRouter)` | Middleware / sub-router |
|
|
1423
|
-
| `ws(path, ...mws, handler)` | WebSocket route |
|
|
1424
|
-
| `onError(handler)` | Global error handler |
|
|
1425
|
-
| `handler()` | Returns `(req, ctx) => Response` for `serve()` |
|
|
1426
|
-
| `websocketHandler()` | Returns upgrade handler for `serve({ websocket })` |
|
|
1427
|
-
|
|
1428
|
-
### Middleware modules
|
|
1429
|
-
|
|
1430
|
-
| Import | Description |
|
|
1431
|
-
|--------|-------------|
|
|
83
|
+
## Documentation
|
|
84
|
+
|
|
85
|
+
| Module | Doc | Description |
|
|
86
|
+
|--------|-----|-------------|
|
|
87
|
+
| **Router** | [docs/router.md](./docs/router.md) | Routes, middleware, WebSocket, error handling |
|
|
88
|
+
| **Middleware** | [docs/middleware.md](./docs/middleware.md) | auth, cors, logger, rateLimit, compress, validate, upload, cookie, static |
|
|
89
|
+
| **PostgreSQL** | [docs/postgres.md](./docs/postgres.md) | Schema builder, CRUD, DDL, transactions, PgModule |
|
|
90
|
+
| **Auth & User** | [docs/user.md](./docs/user.md) | Password, JWT, OAuth2 Server, Social Login cookbook |
|
|
91
|
+
| **React SSR** | [docs/tsx.md](./docs/tsx.md) | pages, layouts, loaders, Tailwind, shadcn/ui |
|
|
92
|
+
| **AI** | [docs/ai.md](./docs/ai.md) | `aiStream()`, `runWorkflow()` |
|
|
93
|
+
| **AI Agent** | [docs/agent.md](./docs/agent.md) | Chat, tool-use, RAG knowledge |
|
|
94
|
+
| **Opencode** | [docs/opencode.md](./docs/opencode.md) | Programming assistant, skills, sessions, permissions |
|
|
95
|
+
| **Messager** | [docs/messager.md](./docs/messager.md) | Real-time chat, channels, WebSocket, agent routing |
|
|
96
|
+
| **GraphQL** | [docs/graphql.md](./docs/graphql.md) | GraphQL endpoint with GraphiQL |
|
|
97
|
+
| **Tenant BaaS** | [docs/tenant.md](./docs/tenant.md) | Dynamic tables, auto REST + GraphQL, row isolation |
|
|
98
|
+
| **Extra** | [docs/extra.md](./docs/extra.md) | Health check, i18n, email, test utilities |
|
|
99
|
+
|
|
100
|
+
### Infrastructure
|
|
101
|
+
|
|
102
|
+
| Module | Import | What it gives you |
|
|
103
|
+
|--------|--------|-------------------|
|
|
104
|
+
| PostgreSQL | `postgres(options?)` | Connection pool + schema builder + CRUD + transactions |
|
|
105
|
+
| Redis | `redis(options?)` | ioredis client injected as `ctx.redis` |
|
|
106
|
+
| Queue | `queue(options?)` | Redis-backed job queue with cron scheduling |
|
|
107
|
+
| Deploy | `deploy(config)` | Self-hosted PaaS, see [deploy.md](./deploy.md) |
|
|
108
|
+
|
|
109
|
+
### Mountable modules
|
|
110
|
+
|
|
111
|
+
All use the same pattern — `const m = module(options)` → `app.use('/path', m.router())`:
|
|
112
|
+
|
|
113
|
+
| Module | Purpose | Also provides |
|
|
114
|
+
|--------|---------|---------------|
|
|
115
|
+
| `user(options)` | Auth (password + JWT + OAuth2) | `migrate()`, `middleware()`, `register()`, `login()`, `verify()`, `close()` |
|
|
116
|
+
| `tenant(options)` | Multi-tenant BaaS | `migrate()`, `middleware()`, `graphql()`, `close()` |
|
|
117
|
+
| `agent(options)` | AI agents | `migrate()`, `run()`, `addKnowledge()`, `close()` |
|
|
118
|
+
| `opencode(options)` | Programming assistant | `migrate()`, `wsHandler()`, `close()` |
|
|
119
|
+
| `messager(options)` | Real-time messaging | `migrate()`, `wsHandler()`, `send()`, `close()` |
|
|
120
|
+
| `aiStream(handler)` | AI streaming endpoint | — |
|
|
121
|
+
| `graphql(handler)` | GraphQL endpoint | — |
|
|
122
|
+
| `health(options?)` | Health check | — |
|
|
123
|
+
|
|
124
|
+
### Middleware (all `(req, ctx, next) => Response`)
|
|
125
|
+
|
|
126
|
+
| Middleware | Description |
|
|
127
|
+
|-----------|-------------|
|
|
1432
128
|
| `auth(options)` | Bearer token / custom header / verify / proxy |
|
|
1433
129
|
| `cors(options?)` | CORS with preflight, origin whitelist, credentials |
|
|
1434
130
|
| `logger(options?)` | Request logging with duration |
|
|
1435
131
|
| `rateLimit(options?)` | In-memory rate limiting with headers |
|
|
1436
132
|
| `compress(options?)` | Brotli / Gzip / Deflate compression |
|
|
1437
|
-
| `validate(schemas)` | Zod validation
|
|
1438
|
-
| `upload(options?)` | Multipart file upload
|
|
1439
|
-
|
|
1440
|
-
### Sub-Router modules (mount via `app.use()`)
|
|
1441
|
-
|
|
1442
|
-
| Import | Description |
|
|
1443
|
-
|--------|-------------|
|
|
1444
|
-
| `postgres(options?)` | PostgreSQL connection + DDL schema builder + transactions + module lifecycle |
|
|
1445
|
-
| `redis(options?)` | Redis client (ioredis) — injects `ctx.redis` |
|
|
1446
|
-
| `queue(options?)` | Redis-backed job queue — immediate, delayed, cron scheduling |
|
|
1447
|
-
| `user(options)` | Built-in authentication (password + OAuth2 Server + JWT, middleware) |
|
|
1448
|
-
| `tenant(options)` | Multi-tenant BaaS — dynamic tables, REST + GraphQL auto-generation, row-level isolation |
|
|
1449
|
-
| `agent(options)` | AI Agent — chat/tool-use/knowledge, Ollama-ready, programmatic API |
|
|
1450
|
-
| `messager(options)` | Real-time messaging — channels, WebSocket, agent routing, webhooks |
|
|
1451
|
-
| `opencode(options)` | AI programming assistant — chat agents with tools, skills, permissions, isolated workspaces |
|
|
1452
|
-
| `graphql(handler)` | GraphQL endpoint (GET/POST + GraphiQL) |
|
|
1453
|
-
| `aiStream(handler)` | AI streaming endpoint (POST) |
|
|
1454
|
-
| `runWorkflow(options)` | DAG execution engine as an AI SDK `Tool` — use with `streamText()` |
|
|
133
|
+
| `validate(schemas)` | Zod validation (body, query, params) |
|
|
134
|
+
| `upload(options?)` | Multipart file upload |
|
|
135
|
+
| `i18n(options)` | Internationalization — `ctx.t()`, locale detection |
|
|
1455
136
|
|
|
1456
|
-
###
|
|
1457
|
-
|
|
1458
|
-
| Import | Description |
|
|
1459
|
-
|--------|-------------|
|
|
1460
|
-
| `deploy(config)` | Start the deployment platform — see [deploy.md](./deploy.md) |
|
|
1461
|
-
| `defineConfig(config)` | Type-safe config helper with validation — see [deploy.md](./deploy.md) |
|
|
1462
|
-
|
|
1463
|
-
### Utilities
|
|
137
|
+
### Utility functions
|
|
1464
138
|
|
|
1465
139
|
| Function | Description |
|
|
1466
140
|
|----------|-------------|
|
|
1467
|
-
| `serveStatic(root, options?)` | Static file serving
|
|
1468
|
-
| `getCookies(req)`
|
|
1469
|
-
| `
|
|
1470
|
-
| `
|
|
1471
|
-
| `
|
|
1472
|
-
| `
|
|
1473
|
-
| `
|
|
1474
|
-
| `
|
|
1475
|
-
| `
|
|
1476
|
-
| `sql(strings, ...)` | SQL expression literal for defaults and SET values (e.g. `sql\`NOW()\``) |
|
|
1477
|
-
| `PgModule` | Base class for database-backed modules (provides `sql`, `close()`) |
|
|
1478
|
-
| `BoundTable` | Table with pre-bound `sql` — returned by `pg.table()` |
|
|
1479
|
-
| `FindOptions` | Query options: `{ orderBy?, limit?, offset? }` for `find()` |
|
|
1480
|
-
|
|
1481
|
-
Import `useTsx` and `TsxContext` from `'weifuwu'`.
|
|
141
|
+
| `serveStatic(root, options?)` | Static file serving |
|
|
142
|
+
| `getCookies(req)` / `setCookie(res, ...)` / `deleteCookie(res, ...)` | Cookie helpers |
|
|
143
|
+
| `mailer(options)` | Email sender (SMTP or custom) |
|
|
144
|
+
| `createTestServer(handler)` | Start test server → `{ server, url }` |
|
|
145
|
+
| `runWorkflow(options)` | DAG execution engine as AI SDK `Tool` |
|
|
146
|
+
| `pgTable(name, columns)` | Type-safe table schema builder |
|
|
147
|
+
| `pg.table(name, columns)` | Pre-bound table (no `sql` param needed) |
|
|
148
|
+
| `serial()`, `uuid()`, `text()`, ... | Column type builders |
|
|
149
|
+
| `PgModule` | Base class for DB-backed modules |
|
|
1482
150
|
|
|
1483
151
|
## License
|
|
1484
152
|
|