weifuwu 0.3.0 → 0.5.0
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 +399 -226
- package/dist/ai.d.ts +7 -0
- package/dist/graphql.d.ts +12 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +900 -139
- 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 -16
- package/dist/workflow/engine.d.ts +7 -0
- package/dist/workflow/index.d.ts +8 -0
- package/dist/workflow/llm.d.ts +10 -0
- package/dist/workflow/nodes.d.ts +10 -0
- package/dist/workflow/reference.d.ts +3 -0
- package/dist/workflow/route.d.ts +11 -0
- package/dist/workflow/sse.d.ts +2 -0
- package/dist/workflow/tool.d.ts +8 -0
- package/dist/workflow/types.d.ts +86 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,21 +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** — `
|
|
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
|
|
21
23
|
- **Static files** — `serveStatic()` with ETag, 304, MIME, directory index
|
|
22
|
-
- **Request validation** — `validate()` with Zod (body / query / params / headers)
|
|
23
|
-
- **File upload** — `upload()` multipart parser with disk save, size & type limits
|
|
24
24
|
- **Cookie** — `getCookies()`, `setCookie()`, `deleteCookie()` — immutable
|
|
25
25
|
- **Error handling** — global `onError()`
|
|
26
26
|
- **Zero build** — native TypeScript in Node.js v24+
|
|
@@ -30,176 +30,9 @@ Features like `tsx()`, WebSocket, GraphQL, and AI streaming all follow the same
|
|
|
30
30
|
|
|
31
31
|
```ts
|
|
32
32
|
import { serve } from 'weifuwu'
|
|
33
|
-
|
|
34
33
|
serve((req, ctx) => new Response('Hello, World!'), { port: 3000 })
|
|
35
34
|
```
|
|
36
35
|
|
|
37
|
-
## React pages with tsx()
|
|
38
|
-
|
|
39
|
-
```ts
|
|
40
|
-
import { serve, Router } from 'weifuwu'
|
|
41
|
-
import { tsx } from 'weifuwu/tsx'
|
|
42
|
-
|
|
43
|
-
const app = new Router()
|
|
44
|
-
app.use('/', await tsx({ dir: './pages/' }))
|
|
45
|
-
|
|
46
|
-
serve(app.handler(), { port: 3000 })
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
### File conventions
|
|
50
|
-
|
|
51
|
-
```
|
|
52
|
-
pages/
|
|
53
|
-
page.tsx → GET / (React component, default export)
|
|
54
|
-
layout.tsx → root layout (HTML shell, receives req/ctx, NOT hydrated)
|
|
55
|
-
not-found.tsx → 404 error page (rendered for unmatched routes, wrapped in layout)
|
|
56
|
-
about/page.tsx → GET /about
|
|
57
|
-
blog/[slug]/
|
|
58
|
-
page.tsx → GET /blog/:slug
|
|
59
|
-
load.ts → data fetching (server-only, default export)
|
|
60
|
-
route.ts → POST /blog/:slug (API, named exports POST/PUT/DELETE/...)
|
|
61
|
-
blog/layout.tsx → /blog/* layout (UI structure, receives children, hydrated)
|
|
62
|
-
api/search/
|
|
63
|
-
route.ts → GET /api/search (standalone API, no page.tsx needed)
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
### page.tsx — page component
|
|
67
|
-
|
|
68
|
-
```tsx
|
|
69
|
-
export default function Page({ params, query }: {
|
|
70
|
-
params: { slug: string }
|
|
71
|
-
query: Record<string, string>
|
|
72
|
-
}) {
|
|
73
|
-
return <article><h1>{params.slug}</h1></article>
|
|
74
|
-
}
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
### load.ts — data fetching (server-only)
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
import { db } from './db.ts'
|
|
81
|
-
|
|
82
|
-
export default async function load({ params, query }: {
|
|
83
|
-
params: Record<string, string>
|
|
84
|
-
query: Record<string, string>
|
|
85
|
-
}) {
|
|
86
|
-
const data = await db.query(params.slug)
|
|
87
|
-
return { data } // merged into props passed to page.tsx
|
|
88
|
-
}
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
`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.
|
|
92
|
-
|
|
93
|
-
### layout.tsx — root layout vs nested layouts
|
|
94
|
-
|
|
95
|
-
Two types of layouts, distinguished by their position in the directory tree:
|
|
96
|
-
|
|
97
|
-
**Root layout** (`pages/layout.tsx`) — receives `{ children, req, ctx }`:
|
|
98
|
-
|
|
99
|
-
```tsx
|
|
100
|
-
export default function RootLayout({ children, req, ctx }: {
|
|
101
|
-
children: React.ReactNode
|
|
102
|
-
req: Request
|
|
103
|
-
ctx: Context
|
|
104
|
-
}) {
|
|
105
|
-
const theme = req.headers.get('Cookie')?.includes('theme=dark') ? 'dark' : 'light'
|
|
106
|
-
return (
|
|
107
|
-
<html class={theme}>
|
|
108
|
-
<head><title>App</title></head>
|
|
109
|
-
<body>
|
|
110
|
-
<div id="__weifuwu_root">{children}</div>
|
|
111
|
-
</body>
|
|
112
|
-
</html>
|
|
113
|
-
)
|
|
114
|
-
}
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
- Controls the full HTML shell (`<html>`, `<head>`, `<body>`)
|
|
118
|
-
- Has access to `req`/`ctx` for cookie/header-based customization
|
|
119
|
-
- **Not hydrated** — safe to use `req`/`ctx` (never serialized to client)
|
|
120
|
-
|
|
121
|
-
**Nested layouts** (`pages/blog/layout.tsx`) — receives only `{ children }`:
|
|
122
|
-
|
|
123
|
-
```tsx
|
|
124
|
-
export default function BlogLayout({ children }: { children: React.ReactNode }) {
|
|
125
|
-
return <div className="sidebar-layout">{children}</div>
|
|
126
|
-
}
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
- Provide UI structure (sidebar, nav, search box)
|
|
130
|
-
- **Hydrated** on the client — can use `useState`, event handlers
|
|
131
|
-
- No access to `req`/`ctx` (not serializable)
|
|
132
|
-
|
|
133
|
-
Layouts auto-nest by directory depth — `pages/blog/layout.tsx` wraps `pages/blog/*` pages inside `pages/layout.tsx`.
|
|
134
|
-
|
|
135
|
-
### `TsxContext` and `useTsx()`
|
|
136
|
-
|
|
137
|
-
Any component in the tree can access routing context without prop drilling:
|
|
138
|
-
|
|
139
|
-
```tsx
|
|
140
|
-
import { useTsx } from 'weifuwu'
|
|
141
|
-
|
|
142
|
-
function Sidebar() {
|
|
143
|
-
const { params, query, user, parsed } = useTsx()
|
|
144
|
-
// params.slug, query.page, user.name — from any depth
|
|
145
|
-
return <aside>User: {user?.name}</aside>
|
|
146
|
-
}
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
Available fields:
|
|
150
|
-
|
|
151
|
-
| Field | Source | Description |
|
|
152
|
-
|-------|--------|-------------|
|
|
153
|
-
| `params` | URL path | Route parameters (`:slug`, `:id`) |
|
|
154
|
-
| `query` | URL search | Query string (`?page=1`) |
|
|
155
|
-
| `user` | `auth()` middleware | Set by `verify` callback |
|
|
156
|
-
| `parsed` | `validate()` / `upload()` | Validated body / uploaded files |
|
|
157
|
-
|
|
158
|
-
### route.ts — API (co-located with page)
|
|
159
|
-
|
|
160
|
-
```ts
|
|
161
|
-
export const POST: Handler = async (req, ctx) => {
|
|
162
|
-
const body = await req.json()
|
|
163
|
-
return Response.json({ ...body, slug: ctx.params.slug })
|
|
164
|
-
}
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
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`.
|
|
168
|
-
|
|
169
|
-
### not-found.tsx — 404 page
|
|
170
|
-
|
|
171
|
-
```tsx
|
|
172
|
-
// pages/not-found.tsx
|
|
173
|
-
export default function NotFound() {
|
|
174
|
-
return <h1 class="text-4xl">404 – Not Found</h1>
|
|
175
|
-
}
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
Automatically rendered for unmatched routes, wrapped in the full layout chain. Works with `use('/')` mounting and standalone usage.
|
|
179
|
-
|
|
180
|
-
### Usage within a full app
|
|
181
|
-
|
|
182
|
-
```ts
|
|
183
|
-
import { serve, Router } from 'weifuwu'
|
|
184
|
-
import { tsx } from 'weifuwu/tsx'
|
|
185
|
-
|
|
186
|
-
const r = new Router()
|
|
187
|
-
r.use('/', await tsx({ dir: './pages/' }))
|
|
188
|
-
|
|
189
|
-
// Other features coexist in the same process
|
|
190
|
-
r.ws('/chat', { message(ws, _, data) { ws.send(data) } })
|
|
191
|
-
r.graphql('/graphql', { schema: `...`, resolvers: { ... } })
|
|
192
|
-
|
|
193
|
-
serve(r.handler())
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
```bash
|
|
197
|
-
node --watch app.ts # development
|
|
198
|
-
node app.ts # production
|
|
199
|
-
```
|
|
200
|
-
|
|
201
|
-
No build step, no configuration file — just Node.js and React.
|
|
202
|
-
|
|
203
36
|
## Router
|
|
204
37
|
|
|
205
38
|
```ts
|
|
@@ -244,9 +77,6 @@ app.get('/protected', auth({ proxy: 'http://auth:3000/validate' }), handler)
|
|
|
244
77
|
|
|
245
78
|
// Custom header
|
|
246
79
|
app.use(auth({ header: 'X-API-Key', token: 'my-key' }))
|
|
247
|
-
|
|
248
|
-
// Token can also be passed via query string ?access_token=xxx
|
|
249
|
-
// Proxy forwards using the same method the client used (header ↔ query)
|
|
250
80
|
```
|
|
251
81
|
|
|
252
82
|
### CORS
|
|
@@ -293,16 +123,6 @@ app.use(compress()) // brotli > gzip > deflate
|
|
|
293
123
|
app.use(compress({ threshold: 2048 })) // only compress > 2KB
|
|
294
124
|
```
|
|
295
125
|
|
|
296
|
-
## Static files
|
|
297
|
-
|
|
298
|
-
```ts
|
|
299
|
-
import { serveStatic } from 'weifuwu'
|
|
300
|
-
|
|
301
|
-
router.get('/static/*', serveStatic('./public'))
|
|
302
|
-
```
|
|
303
|
-
|
|
304
|
-
Features: MIME type detection (20+ types), ETag + If-None-Match (304), directory index (index.html), path traversal protection, Cache-Control.
|
|
305
|
-
|
|
306
126
|
## Validation
|
|
307
127
|
|
|
308
128
|
```ts
|
|
@@ -320,16 +140,6 @@ router.post('/users',
|
|
|
320
140
|
// ctx.parsed.body — typed & validated
|
|
321
141
|
},
|
|
322
142
|
)
|
|
323
|
-
|
|
324
|
-
// Validate multiple dimensions at once
|
|
325
|
-
router.post('/data',
|
|
326
|
-
validate({
|
|
327
|
-
body: z.object({ value: z.number() }),
|
|
328
|
-
query: z.object({ token: z.string() }),
|
|
329
|
-
params: z.object({ id: z.string().length(24) }),
|
|
330
|
-
}),
|
|
331
|
-
handler,
|
|
332
|
-
)
|
|
333
143
|
```
|
|
334
144
|
|
|
335
145
|
## File upload
|
|
@@ -362,6 +172,92 @@ res = setCookie(res, 'session', 'token', { httpOnly: true, secure: true, maxAge:
|
|
|
362
172
|
res = deleteCookie(res, 'session')
|
|
363
173
|
```
|
|
364
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
|
+
|
|
365
261
|
## WebSocket
|
|
366
262
|
|
|
367
263
|
```ts
|
|
@@ -396,40 +292,313 @@ app.ws('/secure',
|
|
|
396
292
|
|
|
397
293
|
## GraphQL
|
|
398
294
|
|
|
295
|
+
GraphQL endpoint with GraphiQL IDE. Mount as a sub-Router:
|
|
296
|
+
|
|
399
297
|
```ts
|
|
298
|
+
import { serve, Router, graphql } from 'weifuwu'
|
|
299
|
+
|
|
400
300
|
const app = new Router()
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
+
})))
|
|
412
312
|
|
|
413
313
|
serve(app.handler(), { port: 3000 })
|
|
414
314
|
```
|
|
415
315
|
|
|
316
|
+
The handler receives `(req, ctx)` so you can customize the schema based on the request.
|
|
317
|
+
|
|
416
318
|
## AI streaming
|
|
417
319
|
|
|
320
|
+
Server-sent event streaming via the Vercel AI SDK:
|
|
321
|
+
|
|
418
322
|
```ts
|
|
323
|
+
import { serve, Router, ai } from 'weifuwu'
|
|
419
324
|
import { openai } from '@ai-sdk/openai'
|
|
420
325
|
|
|
421
326
|
const app = new Router()
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
327
|
+
app.use('/chat', ai(async (req, ctx) => {
|
|
328
|
+
const { messages } = await req.json()
|
|
329
|
+
return { model: openai('gpt-4o'), messages }
|
|
330
|
+
}))
|
|
331
|
+
|
|
332
|
+
serve(app.handler(), { port: 3000 })
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## Workflow
|
|
336
|
+
|
|
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.
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
import { Router, tool, workflow } from 'weifuwu'
|
|
341
|
+
import { z } from 'zod'
|
|
342
|
+
|
|
343
|
+
// 1. Define tools (business capabilities)
|
|
344
|
+
const tools = {
|
|
345
|
+
queryUser: tool({
|
|
346
|
+
description: 'Query user info, returns email, name',
|
|
347
|
+
inputSchema: z.object({ userId: z.string() }),
|
|
348
|
+
execute: async ({ userId }) => ({ id: userId, email: 'user@test.com', name: 'Test' }),
|
|
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
|
+
}),
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 2. Mount workflow sub-router
|
|
358
|
+
const app = new Router()
|
|
359
|
+
app.use('/agent', workflow(() => ({ tools })))
|
|
360
|
+
// POST /agent { nodes: [...] } → 200 { workflow: {...}, result: ... }
|
|
361
|
+
|
|
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
|
|
368
|
+
|
|
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
|
|
376
|
+
```
|
|
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 })
|
|
427
405
|
}
|
|
428
|
-
|
|
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/' }))
|
|
429
497
|
|
|
430
498
|
serve(app.handler(), { port: 3000 })
|
|
431
499
|
```
|
|
432
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
|
+
|
|
433
602
|
## Graceful shutdown
|
|
434
603
|
|
|
435
604
|
```ts
|
|
@@ -440,7 +609,6 @@ const ac = new AbortController()
|
|
|
440
609
|
let server: Server
|
|
441
610
|
|
|
442
611
|
process.on('SIGTERM', () => {
|
|
443
|
-
console.log('shutting down…')
|
|
444
612
|
ac.abort()
|
|
445
613
|
server.stop()
|
|
446
614
|
})
|
|
@@ -450,7 +618,6 @@ server = serve((req, ctx) => new Response('Hello'), {
|
|
|
450
618
|
signal: ac.signal,
|
|
451
619
|
})
|
|
452
620
|
await server.ready
|
|
453
|
-
console.log(`listening on :${server.port}`)
|
|
454
621
|
```
|
|
455
622
|
|
|
456
623
|
### Using with WebSocket
|
|
@@ -489,10 +656,6 @@ Returns `{ stop, port, hostname, ready }`.
|
|
|
489
656
|
|
|
490
657
|
### `tsx(options)`
|
|
491
658
|
|
|
492
|
-
```ts
|
|
493
|
-
import { tsx } from 'weifuwu/tsx'
|
|
494
|
-
```
|
|
495
|
-
|
|
496
659
|
| Option | Default | Description |
|
|
497
660
|
|--------|---------|-------------|
|
|
498
661
|
| `dir` | — | Pages directory path |
|
|
@@ -506,33 +669,43 @@ Returns `Promise<Router>`.
|
|
|
506
669
|
| `get/post/put/delete/patch/head/options/all(path, ...mws, handler)` | Route registration |
|
|
507
670
|
| `use(mw)` / `use(path, mw)` / `use(path, subRouter)` | Middleware / sub-router |
|
|
508
671
|
| `ws(path, ...mws, handler)` | WebSocket route |
|
|
509
|
-
| `graphql(path, ...mws, options)` | GraphQL endpoint |
|
|
510
|
-
| `ai(path, ...mws, handler)` | AI streaming |
|
|
511
672
|
| `onError(handler)` | Global error handler |
|
|
512
673
|
| `handler()` | Returns `(req, ctx) => Response` for `serve()` |
|
|
513
674
|
| `websocketHandler()` | Returns upgrade handler for `serve({ websocket })` |
|
|
514
675
|
|
|
515
|
-
###
|
|
676
|
+
### Middleware modules
|
|
516
677
|
|
|
517
|
-
|
|
|
518
|
-
|
|
678
|
+
| Import | Description |
|
|
679
|
+
|--------|-------------|
|
|
519
680
|
| `auth(options)` | Bearer token / custom header / verify / proxy |
|
|
520
681
|
| `cors(options?)` | CORS with preflight, origin whitelist, credentials |
|
|
521
682
|
| `logger(options?)` | Request logging with duration |
|
|
522
683
|
| `rateLimit(options?)` | In-memory rate limiting with headers |
|
|
523
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) |
|
|
524
696
|
|
|
525
697
|
### Utilities
|
|
526
698
|
|
|
527
699
|
| Function | Description |
|
|
528
700
|
|----------|-------------|
|
|
529
701
|
| `serveStatic(root, options?)` | Static file serving handler |
|
|
530
|
-
| `validate(schemas)` | Zod validation middleware |
|
|
531
|
-
| `upload(options?)` | Multipart file upload middleware |
|
|
532
702
|
| `getCookies(req)` | Parse Cookie header → object |
|
|
533
703
|
| `setCookie(res, name, value, options?)` | Set cookie (returns new Response) |
|
|
534
704
|
| `deleteCookie(res, name)` | Delete cookie (returns new Response) |
|
|
535
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 |
|
|
536
709
|
|
|
537
710
|
Import `useTsx` and `TsxContext` from `'weifuwu'`.
|
|
538
711
|
|