weifuwu 0.4.0 → 0.5.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 +382 -239
- package/dist/ai.d.ts +7 -0
- package/dist/graphql.d.ts +12 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.js +1950 -1721
- package/dist/postgres/client.d.ts +2 -0
- package/dist/postgres/index.d.ts +2 -0
- package/dist/postgres/migrate.d.ts +3 -0
- package/dist/postgres/table.d.ts +4 -0
- package/dist/postgres/types.d.ts +51 -0
- package/dist/router.d.ts +0 -22
- package/dist/workflow/index.d.ts +2 -0
- package/dist/workflow/route.d.ts +11 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,22 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
weifuwu doesn't invent its own request/response abstraction. `Request` and `Response` are the same objects you use in `fetch()` — what you learn in the browser applies directly on the server. `ctx` is the only framework object, and it only carries what the router parsed for you (`params`, `query`).
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Everything follows the same `(req, ctx) => Response` contract. The Router handles HTTP routing and WebSocket. All other features — auth, validation, database, GraphQL, AI, workflow — are standalone modules you import and mount with `app.use()`.
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
13
|
- **Web Standard** — `Request` / `Response` / `ReadableStream`, zero abstractions
|
|
14
14
|
- **Trie router** — static > param > wildcard, sub-router mounting, path params
|
|
15
15
|
- **Middleware** — global, path-scoped, route-level — onion model, short-circuit
|
|
16
|
-
- **
|
|
16
|
+
- **Middleware modules** — `auth()`, `cors()`, `logger()`, `rateLimit()`, `compress()`, `validate()`, `upload()`
|
|
17
17
|
- **React SSR + Hydration** — `tsx({ dir })` — page.tsx / load.ts / layout.tsx / route.ts / not-found.tsx
|
|
18
18
|
- **WebSocket** — `router.ws()` with upgrade middleware (auth before connect)
|
|
19
|
-
- **GraphQL** — `
|
|
20
|
-
- **AI streaming** — `
|
|
21
|
-
- **AI workflows** — `
|
|
19
|
+
- **GraphQL** — `graphql(handler)` sub-Router with GraphiQL IDE
|
|
20
|
+
- **AI streaming** — `ai(handler)` sub-Router via Vercel AI SDK
|
|
21
|
+
- **AI workflows** — `workflow(handler)` sub-Router — intent-to-execution pipelines with `tool()` + SSE
|
|
22
|
+
- **PostgreSQL** — `postgres()` — zod-to-DDL, auto-migration, 6 CRUD methods, `ctx.sql` escape hatch
|
|
22
23
|
- **Static files** — `serveStatic()` with ETag, 304, MIME, directory index
|
|
23
|
-
- **Request validation** — `validate()` with Zod (body / query / params / headers)
|
|
24
|
-
- **File upload** — `upload()` multipart parser with disk save, size & type limits
|
|
25
24
|
- **Cookie** — `getCookies()`, `setCookie()`, `deleteCookie()` — immutable
|
|
26
25
|
- **Error handling** — global `onError()`
|
|
27
26
|
- **Zero build** — native TypeScript in Node.js v24+
|
|
@@ -31,176 +30,9 @@ Features like `tsx()`, WebSocket, GraphQL, and AI streaming all follow the same
|
|
|
31
30
|
|
|
32
31
|
```ts
|
|
33
32
|
import { serve } from 'weifuwu'
|
|
34
|
-
|
|
35
33
|
serve((req, ctx) => new Response('Hello, World!'), { port: 3000 })
|
|
36
34
|
```
|
|
37
35
|
|
|
38
|
-
## React pages with tsx()
|
|
39
|
-
|
|
40
|
-
```ts
|
|
41
|
-
import { serve, Router } from 'weifuwu'
|
|
42
|
-
import { tsx } from 'weifuwu/tsx'
|
|
43
|
-
|
|
44
|
-
const app = new Router()
|
|
45
|
-
app.use('/', await tsx({ dir: './pages/' }))
|
|
46
|
-
|
|
47
|
-
serve(app.handler(), { port: 3000 })
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
### File conventions
|
|
51
|
-
|
|
52
|
-
```
|
|
53
|
-
pages/
|
|
54
|
-
page.tsx → GET / (React component, default export)
|
|
55
|
-
layout.tsx → root layout (HTML shell, receives req/ctx, NOT hydrated)
|
|
56
|
-
not-found.tsx → 404 error page (rendered for unmatched routes, wrapped in layout)
|
|
57
|
-
about/page.tsx → GET /about
|
|
58
|
-
blog/[slug]/
|
|
59
|
-
page.tsx → GET /blog/:slug
|
|
60
|
-
load.ts → data fetching (server-only, default export)
|
|
61
|
-
route.ts → POST /blog/:slug (API, named exports POST/PUT/DELETE/...)
|
|
62
|
-
blog/layout.tsx → /blog/* layout (UI structure, receives children, hydrated)
|
|
63
|
-
api/search/
|
|
64
|
-
route.ts → GET /api/search (standalone API, no page.tsx needed)
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
### page.tsx — page component
|
|
68
|
-
|
|
69
|
-
```tsx
|
|
70
|
-
export default function Page({ params, query }: {
|
|
71
|
-
params: { slug: string }
|
|
72
|
-
query: Record<string, string>
|
|
73
|
-
}) {
|
|
74
|
-
return <article><h1>{params.slug}</h1></article>
|
|
75
|
-
}
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
### load.ts — data fetching (server-only)
|
|
79
|
-
|
|
80
|
-
```ts
|
|
81
|
-
import { db } from './db.ts'
|
|
82
|
-
|
|
83
|
-
export default async function load({ params, query }: {
|
|
84
|
-
params: Record<string, string>
|
|
85
|
-
query: Record<string, string>
|
|
86
|
-
}) {
|
|
87
|
-
const data = await db.query(params.slug)
|
|
88
|
-
return { data } // merged into props passed to page.tsx
|
|
89
|
-
}
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
`load()` runs only on the server. Its return value is merged with `{ params, query }` and passed to the page component. The merged props are serialized as `window.__WEIFUWU_PROPS` for client hydration.
|
|
93
|
-
|
|
94
|
-
### layout.tsx — root layout vs nested layouts
|
|
95
|
-
|
|
96
|
-
Two types of layouts, distinguished by their position in the directory tree:
|
|
97
|
-
|
|
98
|
-
**Root layout** (`pages/layout.tsx`) — receives `{ children, req, ctx }`:
|
|
99
|
-
|
|
100
|
-
```tsx
|
|
101
|
-
export default function RootLayout({ children, req, ctx }: {
|
|
102
|
-
children: React.ReactNode
|
|
103
|
-
req: Request
|
|
104
|
-
ctx: Context
|
|
105
|
-
}) {
|
|
106
|
-
const theme = req.headers.get('Cookie')?.includes('theme=dark') ? 'dark' : 'light'
|
|
107
|
-
return (
|
|
108
|
-
<html class={theme}>
|
|
109
|
-
<head><title>App</title></head>
|
|
110
|
-
<body>
|
|
111
|
-
<div id="__weifuwu_root">{children}</div>
|
|
112
|
-
</body>
|
|
113
|
-
</html>
|
|
114
|
-
)
|
|
115
|
-
}
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
- Controls the full HTML shell (`<html>`, `<head>`, `<body>`)
|
|
119
|
-
- Has access to `req`/`ctx` for cookie/header-based customization
|
|
120
|
-
- **Not hydrated** — safe to use `req`/`ctx` (never serialized to client)
|
|
121
|
-
|
|
122
|
-
**Nested layouts** (`pages/blog/layout.tsx`) — receives only `{ children }`:
|
|
123
|
-
|
|
124
|
-
```tsx
|
|
125
|
-
export default function BlogLayout({ children }: { children: React.ReactNode }) {
|
|
126
|
-
return <div className="sidebar-layout">{children}</div>
|
|
127
|
-
}
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
- Provide UI structure (sidebar, nav, search box)
|
|
131
|
-
- **Hydrated** on the client — can use `useState`, event handlers
|
|
132
|
-
- No access to `req`/`ctx` (not serializable)
|
|
133
|
-
|
|
134
|
-
Layouts auto-nest by directory depth — `pages/blog/layout.tsx` wraps `pages/blog/*` pages inside `pages/layout.tsx`.
|
|
135
|
-
|
|
136
|
-
### `TsxContext` and `useTsx()`
|
|
137
|
-
|
|
138
|
-
Any component in the tree can access routing context without prop drilling:
|
|
139
|
-
|
|
140
|
-
```tsx
|
|
141
|
-
import { useTsx } from 'weifuwu'
|
|
142
|
-
|
|
143
|
-
function Sidebar() {
|
|
144
|
-
const { params, query, user, parsed } = useTsx()
|
|
145
|
-
// params.slug, query.page, user.name — from any depth
|
|
146
|
-
return <aside>User: {user?.name}</aside>
|
|
147
|
-
}
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
Available fields:
|
|
151
|
-
|
|
152
|
-
| Field | Source | Description |
|
|
153
|
-
|-------|--------|-------------|
|
|
154
|
-
| `params` | URL path | Route parameters (`:slug`, `:id`) |
|
|
155
|
-
| `query` | URL search | Query string (`?page=1`) |
|
|
156
|
-
| `user` | `auth()` middleware | Set by `verify` callback |
|
|
157
|
-
| `parsed` | `validate()` / `upload()` | Validated body / uploaded files |
|
|
158
|
-
|
|
159
|
-
### route.ts — API (co-located with page)
|
|
160
|
-
|
|
161
|
-
```ts
|
|
162
|
-
export const POST: Handler = async (req, ctx) => {
|
|
163
|
-
const body = await req.json()
|
|
164
|
-
return Response.json({ ...body, slug: ctx.params.slug })
|
|
165
|
-
}
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
Route.ts exports `POST`/`PUT`/`DELETE`/`PATCH` (GET is handled by page.tsx). The same `route.ts` file coexists with `page.tsx` in the same directory for handling form submissions or AJAX requests. Standalone `route.ts` (without a co-located `page.tsx`) registers all methods including `GET`.
|
|
169
|
-
|
|
170
|
-
### not-found.tsx — 404 page
|
|
171
|
-
|
|
172
|
-
```tsx
|
|
173
|
-
// pages/not-found.tsx
|
|
174
|
-
export default function NotFound() {
|
|
175
|
-
return <h1 class="text-4xl">404 – Not Found</h1>
|
|
176
|
-
}
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
Automatically rendered for unmatched routes, wrapped in the full layout chain. Works with `use('/')` mounting and standalone usage.
|
|
180
|
-
|
|
181
|
-
### Usage within a full app
|
|
182
|
-
|
|
183
|
-
```ts
|
|
184
|
-
import { serve, Router } from 'weifuwu'
|
|
185
|
-
import { tsx } from 'weifuwu/tsx'
|
|
186
|
-
|
|
187
|
-
const r = new Router()
|
|
188
|
-
r.use('/', await tsx({ dir: './pages/' }))
|
|
189
|
-
|
|
190
|
-
// Other features coexist in the same process
|
|
191
|
-
r.ws('/chat', { message(ws, _, data) { ws.send(data) } })
|
|
192
|
-
r.graphql('/graphql', { schema: `...`, resolvers: { ... } })
|
|
193
|
-
|
|
194
|
-
serve(r.handler())
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
```bash
|
|
198
|
-
node --watch app.ts # development
|
|
199
|
-
node app.ts # production
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
No build step, no configuration file — just Node.js and React.
|
|
203
|
-
|
|
204
36
|
## Router
|
|
205
37
|
|
|
206
38
|
```ts
|
|
@@ -245,9 +77,6 @@ app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
|
|
|
245
77
|
|
|
246
78
|
// Custom header
|
|
247
79
|
app.use(auth({ header: 'X-API-Key', token: 'my-key' }))
|
|
248
|
-
|
|
249
|
-
// Token can also be passed via query string ?access_token=xxx
|
|
250
|
-
// Proxy forwards using the same method the client used (header ↔ query)
|
|
251
80
|
```
|
|
252
81
|
|
|
253
82
|
### CORS
|
|
@@ -294,16 +123,6 @@ app.use(compress()) // brotli > gzip > deflate
|
|
|
294
123
|
app.use(compress({ threshold: 2048 })) // only compress > 2KB
|
|
295
124
|
```
|
|
296
125
|
|
|
297
|
-
## Static files
|
|
298
|
-
|
|
299
|
-
```ts
|
|
300
|
-
import { serveStatic } from 'weifuwu'
|
|
301
|
-
|
|
302
|
-
router.get('/static/*', serveStatic('./public'))
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
Features: MIME type detection (20+ types), ETag + If-None-Match (304), directory index (index.html), path traversal protection, Cache-Control.
|
|
306
|
-
|
|
307
126
|
## Validation
|
|
308
127
|
|
|
309
128
|
```ts
|
|
@@ -321,16 +140,6 @@ router.post('/users',
|
|
|
321
140
|
// ctx.parsed.body — typed & validated
|
|
322
141
|
},
|
|
323
142
|
)
|
|
324
|
-
|
|
325
|
-
// Validate multiple dimensions at once
|
|
326
|
-
router.post('/data',
|
|
327
|
-
validate({
|
|
328
|
-
body: z.object({ value: z.number() }),
|
|
329
|
-
query: z.object({ token: z.string() }),
|
|
330
|
-
params: z.object({ id: z.string().length(24) }),
|
|
331
|
-
}),
|
|
332
|
-
handler,
|
|
333
|
-
)
|
|
334
143
|
```
|
|
335
144
|
|
|
336
145
|
## File upload
|
|
@@ -363,6 +172,92 @@ res = setCookie(res, 'session', 'token', { httpOnly: true, secure: true, maxAge:
|
|
|
363
172
|
res = deleteCookie(res, 'session')
|
|
364
173
|
```
|
|
365
174
|
|
|
175
|
+
## Static files
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { serveStatic } from 'weifuwu'
|
|
179
|
+
|
|
180
|
+
router.get('/static/*', serveStatic('./public'))
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Features: MIME type detection (20+ types), ETag + If-None-Match (304), directory index (index.html), path traversal protection, Cache-Control.
|
|
184
|
+
|
|
185
|
+
## PostgreSQL
|
|
186
|
+
|
|
187
|
+
Built-in PostgreSQL — zero config, zero ORM, zero migration files.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { serve, Router, postgres } from 'weifuwu'
|
|
191
|
+
import { z } from 'zod'
|
|
192
|
+
|
|
193
|
+
const app = new Router()
|
|
194
|
+
const pg = postgres()
|
|
195
|
+
|
|
196
|
+
const User = pg.table('users', {
|
|
197
|
+
id: z.number().optional(), // → SERIAL PRIMARY KEY
|
|
198
|
+
name: z.string().min(1), // → TEXT NOT NULL
|
|
199
|
+
email: z.string().email(), // → TEXT NOT NULL
|
|
200
|
+
age: z.number().optional(), // → INTEGER
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
await pg.migrate()
|
|
204
|
+
// Auto-creates tables / adds missing columns via information_schema
|
|
205
|
+
app.use(pg) // injects ctx.sql into handlers
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 6 methods — HTTP semantics
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
User.get(1) // GET /users/:id
|
|
212
|
+
User.list({ name: 'a' }, // GET /users?name=a
|
|
213
|
+
{ limit: 10, offset: 0, sort: { id: 'desc' } })
|
|
214
|
+
// → { rows: User[], count: number }
|
|
215
|
+
|
|
216
|
+
User.create({ name: 'A', email: 'a@b.com' }) // POST /users
|
|
217
|
+
User.patch(1, { name: 'B' }) // PATCH /users/:id
|
|
218
|
+
User.remove(1) // DELETE /users/:id
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Every method validates input against your zod schema automatically. Complex queries use `ctx.sql`:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
app.get('/users/stats', async (req, ctx) => {
|
|
225
|
+
const rows = await ctx.sql`
|
|
226
|
+
SELECT u.*, count(p.id) as posts
|
|
227
|
+
FROM users u LEFT JOIN posts p ON p.user_id = u.id
|
|
228
|
+
GROUP BY u.id
|
|
229
|
+
`
|
|
230
|
+
return Response.json(rows)
|
|
231
|
+
})
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Migration-free sync
|
|
235
|
+
|
|
236
|
+
`pg.migrate()` queries `information_schema.columns` and only runs the DDL needed:
|
|
237
|
+
|
|
238
|
+
- **Table missing** → `CREATE TABLE IF NOT EXISTS`
|
|
239
|
+
- **Column missing** → `ALTER TABLE ADD COLUMN IF NOT EXISTS`
|
|
240
|
+
- **Existing** → no-op
|
|
241
|
+
|
|
242
|
+
Safe for production: never drops or alters existing columns. Destructive operations (rename, type change, drop) are done via `ctx.sql`.
|
|
243
|
+
|
|
244
|
+
### Connection lifecycle
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
const pg = postgres() // reads DATABASE_URL
|
|
248
|
+
const pg = postgres('postgres://...') // explicit connection
|
|
249
|
+
const pg = postgres({ signal: ac.signal }) // abort → sql.end()
|
|
250
|
+
await pg.close() // explicit close
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Primary keys
|
|
254
|
+
|
|
255
|
+
| zod field | PostgreSQL |
|
|
256
|
+
|-----------|-----------|
|
|
257
|
+
| `id: z.number().optional()` | `SERIAL PRIMARY KEY` |
|
|
258
|
+
| `id: z.string().uuid().optional()` | `UUID PRIMARY KEY DEFAULT gen_random_uuid()` |
|
|
259
|
+
| `id: z.string()` | `TEXT PRIMARY KEY` (you pass the value) |
|
|
260
|
+
|
|
366
261
|
## WebSocket
|
|
367
262
|
|
|
368
263
|
```ts
|
|
@@ -397,69 +292,313 @@ app.ws('/secure',
|
|
|
397
292
|
|
|
398
293
|
## GraphQL
|
|
399
294
|
|
|
295
|
+
GraphQL endpoint with GraphiQL IDE. Mount as a sub-Router:
|
|
296
|
+
|
|
400
297
|
```ts
|
|
298
|
+
import { serve, Router, graphql } from 'weifuwu'
|
|
299
|
+
|
|
401
300
|
const app = new Router()
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
301
|
+
app.use('/graphql', graphql(() => ({
|
|
302
|
+
schema: `
|
|
303
|
+
type Query { hello: String }
|
|
304
|
+
type Mutation { setMessage(msg: String!): String }
|
|
305
|
+
`,
|
|
306
|
+
resolvers: {
|
|
307
|
+
Query: { hello: () => 'world' },
|
|
308
|
+
Mutation: { setMessage: (_, { msg }) => msg },
|
|
309
|
+
},
|
|
310
|
+
graphiql: true,
|
|
311
|
+
})))
|
|
413
312
|
|
|
414
313
|
serve(app.handler(), { port: 3000 })
|
|
415
314
|
```
|
|
416
315
|
|
|
316
|
+
The handler receives `(req, ctx)` so you can customize the schema based on the request.
|
|
317
|
+
|
|
417
318
|
## AI streaming
|
|
418
319
|
|
|
320
|
+
Server-sent event streaming via the Vercel AI SDK:
|
|
321
|
+
|
|
419
322
|
```ts
|
|
323
|
+
import { serve, Router, ai } from 'weifuwu'
|
|
420
324
|
import { openai } from '@ai-sdk/openai'
|
|
421
325
|
|
|
422
326
|
const app = new Router()
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
messages,
|
|
428
|
-
}
|
|
429
|
-
})
|
|
327
|
+
app.use('/chat', ai(async (req, ctx) => {
|
|
328
|
+
const { messages } = await req.json()
|
|
329
|
+
return { model: openai('gpt-4o'), messages }
|
|
330
|
+
}))
|
|
430
331
|
|
|
431
332
|
serve(app.handler(), { port: 3000 })
|
|
432
333
|
```
|
|
433
334
|
|
|
434
335
|
## Workflow
|
|
435
336
|
|
|
436
|
-
Define
|
|
337
|
+
Define business capabilities as **Tools** (`tool()`), then chain them into **workflows** for AI-driven multi-step execution. Works with or without an LLM — hand-write the workflow JSON or let AI generate it from a goal.
|
|
437
338
|
|
|
438
339
|
```ts
|
|
439
|
-
import { Router, tool,
|
|
340
|
+
import { Router, tool, workflow } from 'weifuwu'
|
|
440
341
|
import { z } from 'zod'
|
|
441
342
|
|
|
343
|
+
// 1. Define tools (business capabilities)
|
|
442
344
|
const tools = {
|
|
443
345
|
queryUser: tool({
|
|
444
|
-
description: '
|
|
346
|
+
description: 'Query user info, returns email, name',
|
|
445
347
|
inputSchema: z.object({ userId: z.string() }),
|
|
446
348
|
execute: async ({ userId }) => ({ id: userId, email: 'user@test.com', name: 'Test' }),
|
|
447
349
|
}),
|
|
350
|
+
sendEmail: tool({
|
|
351
|
+
description: 'Send an email',
|
|
352
|
+
inputSchema: z.object({ to: z.string(), subject: z.string() }),
|
|
353
|
+
execute: async ({ to, subject }) => ({ sent: true }),
|
|
354
|
+
}),
|
|
448
355
|
}
|
|
449
356
|
|
|
357
|
+
// 2. Mount workflow sub-router
|
|
450
358
|
const app = new Router()
|
|
359
|
+
app.use('/agent', workflow(() => ({ tools })))
|
|
360
|
+
// POST /agent { nodes: [...] } → 200 { workflow: {...}, result: ... }
|
|
451
361
|
|
|
452
|
-
//
|
|
453
|
-
app.
|
|
362
|
+
// With SSE streaming:
|
|
363
|
+
app.use('/agent-stream', workflow(() => ({ tools, stream: true })))
|
|
364
|
+
// POST /agent-stream { nodes: [...] }
|
|
365
|
+
// → 200 { workflowId: "xxx", eventsUrl: "/xxx/events" }
|
|
366
|
+
// GET /agent-stream/:workflowId/events
|
|
367
|
+
// → SSE: workflow-start → node-start → node-end → complete
|
|
454
368
|
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
369
|
+
// With LLM model (generates workflow from goal):
|
|
370
|
+
app.use('/agent-llm', workflow(() => ({
|
|
371
|
+
tools,
|
|
372
|
+
model: openai('gpt-4o'),
|
|
373
|
+
})))
|
|
374
|
+
// POST /agent-llm { goal: "给用户123发欢迎邮件" }
|
|
375
|
+
// ← LLM generates → executes → returns result
|
|
461
376
|
```
|
|
462
377
|
|
|
378
|
+
### Tool
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
import { tool } from 'weifuwu'
|
|
382
|
+
import { z } from 'zod'
|
|
383
|
+
|
|
384
|
+
const myTool = tool({
|
|
385
|
+
description: '做什么的,返回什么',
|
|
386
|
+
inputSchema: z.object({ key: z.string() }),
|
|
387
|
+
execute: async (input, ctx) => {
|
|
388
|
+
return { result: input.key }
|
|
389
|
+
},
|
|
390
|
+
})
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
`ctx.onStream` 用于流式推送(如 LLM token 输出):
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
const llmTool = tool({
|
|
397
|
+
description: '生成文本',
|
|
398
|
+
inputSchema: z.object({ prompt: z.string() }),
|
|
399
|
+
execute: async (input, ctx) => {
|
|
400
|
+
const stream = await openai.chat.completions.create({ ... })
|
|
401
|
+
let full = ''
|
|
402
|
+
for await (const chunk of stream) {
|
|
403
|
+
full += chunk.choices[0]?.delta?.content || ''
|
|
404
|
+
ctx.onStream?.({ type: 'llm-stream', chunk, accumulated: full })
|
|
405
|
+
}
|
|
406
|
+
return { text: full }
|
|
407
|
+
},
|
|
408
|
+
})
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Core Nodes
|
|
412
|
+
|
|
413
|
+
7 built-in node types:
|
|
414
|
+
|
|
415
|
+
| Node | Purpose | Input |
|
|
416
|
+
|------|---------|-------|
|
|
417
|
+
| `call` | Call a tool or sub-workflow | `{ tool: "name", args: {...} }` or `{ function: "name", args: {...} }` |
|
|
418
|
+
| `set` | Declare or assign a variable | `{ name: "x", value: 42 }` |
|
|
419
|
+
| `get` | Read a variable | `{ name: "x" }` |
|
|
420
|
+
| `eval` | Evaluate an expression | `{ expression: "$var.x + 1" }` |
|
|
421
|
+
| `if` | Conditional branch | `{ conditions: [{ test: ..., body: [nodes] }] }` |
|
|
422
|
+
| `while` | Loop | `{ condition: "$var.i < 5" }, body: [nodes]` |
|
|
423
|
+
| `http` | HTTP request | `{ url: "https://...", method: "GET" }` |
|
|
424
|
+
|
|
425
|
+
### Variable Reference Syntax
|
|
426
|
+
|
|
427
|
+
| Pattern | Meaning | Example |
|
|
428
|
+
|---------|---------|---------|
|
|
429
|
+
| `$var.x` | Variable `x` | `$var.counter` |
|
|
430
|
+
| `$nodes.u.output` | Full output of node `u` | `$nodes.u.output` |
|
|
431
|
+
| `$nodes.u.output.field` | Specific field | `$nodes.u.output.email` |
|
|
432
|
+
| `$input.userId` | Workflow input param | `$input.userId` |
|
|
433
|
+
| `42`, `true`, `"hello"` | Literal values | Passed as-is |
|
|
434
|
+
|
|
435
|
+
### Engine API
|
|
436
|
+
|
|
437
|
+
For programmatic use outside of Router:
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
import { createWorkflowEngine, createSSEManager } from 'weifuwu'
|
|
441
|
+
|
|
442
|
+
const sse = createSSEManager()
|
|
443
|
+
const engine = createWorkflowEngine({ tools, sseManager: sse })
|
|
444
|
+
|
|
445
|
+
// Sync execution
|
|
446
|
+
const result = await engine.execute({ nodes: [...] })
|
|
447
|
+
|
|
448
|
+
// Async execution with SSE
|
|
449
|
+
engine.runAsync('wf-1', { nodes: [...] })
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### SSE Events
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
const sse = createSSEManager()
|
|
456
|
+
const stream = sse.createStream('wf-1')
|
|
457
|
+
|
|
458
|
+
const reader = stream.getReader()
|
|
459
|
+
// event: workflow-start — { workflowId, goal }
|
|
460
|
+
// event: node-start — { nodeId, tool, input }
|
|
461
|
+
// event: node-end — { nodeId, output }
|
|
462
|
+
// event: llm-stream — { nodeId, chunk, accumulated }
|
|
463
|
+
// event: complete — { result, duration }
|
|
464
|
+
// event: error — { error }
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### Sub-workflows
|
|
468
|
+
|
|
469
|
+
Define reusable sub-workflows in the `functions` field:
|
|
470
|
+
|
|
471
|
+
```json
|
|
472
|
+
{
|
|
473
|
+
"functions": {
|
|
474
|
+
"double": {
|
|
475
|
+
"inputSchema": { "type": "object", "properties": { "x": { "type": "number" } } },
|
|
476
|
+
"workflow": {
|
|
477
|
+
"nodes": [
|
|
478
|
+
{ "id": "calc", "tool": "eval", "input": { "expression": "$input.x * 2" } }
|
|
479
|
+
]
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
"nodes": [
|
|
484
|
+
{ "id": "call_double", "tool": "call", "input": { "function": "double", "args": { "x": 21 } } }
|
|
485
|
+
]
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
## React pages with tsx()
|
|
490
|
+
|
|
491
|
+
```ts
|
|
492
|
+
import { serve, Router } from 'weifuwu'
|
|
493
|
+
import { tsx } from 'weifuwu/tsx'
|
|
494
|
+
|
|
495
|
+
const app = new Router()
|
|
496
|
+
app.use('/', await tsx({ dir: './pages/' }))
|
|
497
|
+
|
|
498
|
+
serve(app.handler(), { port: 3000 })
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### File conventions
|
|
502
|
+
|
|
503
|
+
```
|
|
504
|
+
pages/
|
|
505
|
+
page.tsx → GET / (React component, default export)
|
|
506
|
+
layout.tsx → root layout (HTML shell, receives req/ctx, NOT hydrated)
|
|
507
|
+
not-found.tsx → 404 error page (rendered for unmatched routes, wrapped in layout)
|
|
508
|
+
about/page.tsx → GET /about
|
|
509
|
+
blog/[slug]/
|
|
510
|
+
page.tsx → GET /blog/:slug
|
|
511
|
+
load.ts → data fetching (server-only, default export)
|
|
512
|
+
route.ts → POST /blog/:slug (API, named exports POST/PUT/DELETE/...)
|
|
513
|
+
blog/layout.tsx → /blog/* layout (UI structure, receives children, hydrated)
|
|
514
|
+
api/search/
|
|
515
|
+
route.ts → GET /api/search (standalone API, no page.tsx needed)
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### page.tsx — page component
|
|
519
|
+
|
|
520
|
+
```tsx
|
|
521
|
+
export default function Page({ params, query }: {
|
|
522
|
+
params: { slug: string }
|
|
523
|
+
query: Record<string, string>
|
|
524
|
+
}) {
|
|
525
|
+
return <article><h1>{params.slug}</h1></article>
|
|
526
|
+
}
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### load.ts — data fetching (server-only)
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
export default async function load({ params, query }: {
|
|
533
|
+
params: Record<string, string>
|
|
534
|
+
query: Record<string, string>
|
|
535
|
+
}) {
|
|
536
|
+
const data = await db.query(params.slug)
|
|
537
|
+
return { data } // merged into props passed to page.tsx
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### layout.tsx
|
|
542
|
+
|
|
543
|
+
**Root layout** (`pages/layout.tsx`) — receives `{ children, req, ctx }`:
|
|
544
|
+
|
|
545
|
+
```tsx
|
|
546
|
+
export default function RootLayout({ children, req, ctx }: {
|
|
547
|
+
children: React.ReactNode
|
|
548
|
+
req: Request
|
|
549
|
+
ctx: Context
|
|
550
|
+
}) {
|
|
551
|
+
return (
|
|
552
|
+
<html>
|
|
553
|
+
<head><title>App</title></head>
|
|
554
|
+
<body><div id="__weifuwu_root">{children}</div></body>
|
|
555
|
+
</html>
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
**Nested layouts** (`pages/blog/layout.tsx`) — receives only `{ children }`.
|
|
561
|
+
|
|
562
|
+
### route.ts — API (co-located with page)
|
|
563
|
+
|
|
564
|
+
```ts
|
|
565
|
+
export const POST: Handler = async (req, ctx) => {
|
|
566
|
+
const body = await req.json()
|
|
567
|
+
return Response.json({ ...body, slug: ctx.params.slug })
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
### not-found.tsx — 404 page
|
|
572
|
+
|
|
573
|
+
```tsx
|
|
574
|
+
export default function NotFound() {
|
|
575
|
+
return <h1 class="text-4xl">404 – Not Found</h1>
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Usage within a full app
|
|
580
|
+
|
|
581
|
+
```ts
|
|
582
|
+
import { serve, Router, ai, graphql, workflow } from 'weifuwu'
|
|
583
|
+
import { tsx } from 'weifuwu/tsx'
|
|
584
|
+
|
|
585
|
+
const app = new Router()
|
|
586
|
+
app.use('/', await tsx({ dir: './pages/' }))
|
|
587
|
+
app.use('/chat', ai(async (req) => ({ model: openai('gpt-4o'), messages: (await req.json()).messages })))
|
|
588
|
+
app.use('/graphql', graphql(() => ({ schema: `type Query { hello: String }`, resolvers: { Query: { hello: () => 'world' } } })))
|
|
589
|
+
app.use('/agent', workflow(() => ({ tools: myTools, stream: true })))
|
|
590
|
+
app.ws('/chat', { message(ws, _, data) { ws.send(data) } })
|
|
591
|
+
|
|
592
|
+
serve(app.handler())
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
```bash
|
|
596
|
+
node --watch app.ts # development
|
|
597
|
+
node app.ts # production
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
No build step, no configuration file — just Node.js.
|
|
601
|
+
|
|
463
602
|
## Graceful shutdown
|
|
464
603
|
|
|
465
604
|
```ts
|
|
@@ -470,7 +609,6 @@ const ac = new AbortController()
|
|
|
470
609
|
let server: Server
|
|
471
610
|
|
|
472
611
|
process.on('SIGTERM', () => {
|
|
473
|
-
console.log('shutting down…')
|
|
474
612
|
ac.abort()
|
|
475
613
|
server.stop()
|
|
476
614
|
})
|
|
@@ -480,7 +618,6 @@ server = serve((req, ctx) => new Response('Hello'), {
|
|
|
480
618
|
signal: ac.signal,
|
|
481
619
|
})
|
|
482
620
|
await server.ready
|
|
483
|
-
console.log(`listening on :${server.port}`)
|
|
484
621
|
```
|
|
485
622
|
|
|
486
623
|
### Using with WebSocket
|
|
@@ -519,10 +656,6 @@ Returns `{ stop, port, hostname, ready }`.
|
|
|
519
656
|
|
|
520
657
|
### `tsx(options)`
|
|
521
658
|
|
|
522
|
-
```ts
|
|
523
|
-
import { tsx } from 'weifuwu/tsx'
|
|
524
|
-
```
|
|
525
|
-
|
|
526
659
|
| Option | Default | Description |
|
|
527
660
|
|--------|---------|-------------|
|
|
528
661
|
| `dir` | — | Pages directory path |
|
|
@@ -536,33 +669,43 @@ Returns `Promise<Router>`.
|
|
|
536
669
|
| `get/post/put/delete/patch/head/options/all(path, ...mws, handler)` | Route registration |
|
|
537
670
|
| `use(mw)` / `use(path, mw)` / `use(path, subRouter)` | Middleware / sub-router |
|
|
538
671
|
| `ws(path, ...mws, handler)` | WebSocket route |
|
|
539
|
-
| `graphql(path, ...mws, options)` | GraphQL endpoint |
|
|
540
|
-
| `ai(path, ...mws, handler)` | AI streaming |
|
|
541
672
|
| `onError(handler)` | Global error handler |
|
|
542
673
|
| `handler()` | Returns `(req, ctx) => Response` for `serve()` |
|
|
543
674
|
| `websocketHandler()` | Returns upgrade handler for `serve({ websocket })` |
|
|
544
675
|
|
|
545
|
-
###
|
|
676
|
+
### Middleware modules
|
|
546
677
|
|
|
547
|
-
|
|
|
548
|
-
|
|
678
|
+
| Import | Description |
|
|
679
|
+
|--------|-------------|
|
|
549
680
|
| `auth(options)` | Bearer token / custom header / verify / proxy |
|
|
550
681
|
| `cors(options?)` | CORS with preflight, origin whitelist, credentials |
|
|
551
682
|
| `logger(options?)` | Request logging with duration |
|
|
552
683
|
| `rateLimit(options?)` | In-memory rate limiting with headers |
|
|
553
684
|
| `compress(options?)` | Brotli / Gzip / Deflate compression |
|
|
685
|
+
| `validate(schemas)` | Zod validation middleware |
|
|
686
|
+
| `upload(options?)` | Multipart file upload middleware |
|
|
687
|
+
|
|
688
|
+
### Sub-Router modules (mount via `app.use()`)
|
|
689
|
+
|
|
690
|
+
| Import | Description |
|
|
691
|
+
|--------|-------------|
|
|
692
|
+
| `postgres(options?)` | PostgreSQL connection + auto-migration + 6 CRUD methods |
|
|
693
|
+
| `graphql(handler)` | GraphQL endpoint (GET/POST + GraphiQL) |
|
|
694
|
+
| `ai(handler)` | AI streaming endpoint (POST) |
|
|
695
|
+
| `workflow(handler)` | Workflow engine (POST + SSE) |
|
|
554
696
|
|
|
555
697
|
### Utilities
|
|
556
698
|
|
|
557
699
|
| Function | Description |
|
|
558
700
|
|----------|-------------|
|
|
559
701
|
| `serveStatic(root, options?)` | Static file serving handler |
|
|
560
|
-
| `validate(schemas)` | Zod validation middleware |
|
|
561
|
-
| `upload(options?)` | Multipart file upload middleware |
|
|
562
702
|
| `getCookies(req)` | Parse Cookie header → object |
|
|
563
703
|
| `setCookie(res, name, value, options?)` | Set cookie (returns new Response) |
|
|
564
704
|
| `deleteCookie(res, name)` | Delete cookie (returns new Response) |
|
|
565
705
|
| `useTsx()` | Hook returning `{ params, query, user, parsed }` from `TsxContext` |
|
|
706
|
+
| `createWorkflowEngine(options)` | Programmatic workflow engine |
|
|
707
|
+
| `createSSEManager()` | SSE event manager for workflows |
|
|
708
|
+
| `tool(def)` | Define a workflow tool |
|
|
566
709
|
|
|
567
710
|
Import `useTsx` and `TsxContext` from `'weifuwu'`.
|
|
568
711
|
|