weifuwu 0.19.9 → 0.21.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.
Files changed (143) hide show
  1. package/README.md +280 -98
  2. package/cli/template/app.ts +3 -8
  3. package/cli/template/index.ts +1 -3
  4. package/cli/template/ui/{page.tsx → app/page.tsx} +2 -2
  5. package/cli.ts +197 -40
  6. package/dist/agent/rest.d.ts +1 -0
  7. package/dist/agent/run.d.ts +1 -0
  8. package/dist/auth.d.ts +8 -0
  9. package/dist/cli.js +170 -30
  10. package/dist/client-router.d.ts +1 -1
  11. package/dist/cors.d.ts +10 -0
  12. package/dist/csrf.d.ts +4 -2
  13. package/dist/env.d.ts +6 -1
  14. package/dist/graphql.d.ts +4 -0
  15. package/dist/html-shell.d.ts +1 -0
  16. package/dist/index.d.ts +17 -17
  17. package/dist/index.js +1898 -2243
  18. package/dist/live.d.ts +4 -1
  19. package/dist/logger.d.ts +16 -0
  20. package/dist/messager/agent.d.ts +6 -1
  21. package/dist/postgres/client.d.ts +2 -0
  22. package/dist/postgres/index.d.ts +2 -2
  23. package/dist/postgres/module.d.ts +5 -3
  24. package/dist/postgres/schema/table.d.ts +8 -2
  25. package/dist/postgres/types.d.ts +31 -8
  26. package/dist/preferences.d.ts +6 -2
  27. package/dist/queue/types.d.ts +14 -3
  28. package/dist/rate-limit.d.ts +2 -2
  29. package/dist/react.d.ts +2 -0
  30. package/dist/react.js +57 -0
  31. package/dist/redis/types.d.ts +5 -3
  32. package/dist/request-id.d.ts +4 -2
  33. package/dist/router.d.ts +33 -19
  34. package/dist/serve.d.ts +13 -2
  35. package/dist/ssr.d.ts +20 -1
  36. package/dist/stream.d.ts +0 -1
  37. package/dist/tailwind.d.ts +3 -1
  38. package/dist/test-utils.d.ts +64 -0
  39. package/dist/trace.d.ts +15 -0
  40. package/dist/types.d.ts +3 -3
  41. package/dist/use-agent-stream.d.ts +27 -0
  42. package/dist/user/index.d.ts +1 -1
  43. package/dist/user/types.d.ts +5 -2
  44. package/package.json +3 -3
  45. package/cli/template/.weifuwu/ssr/96f5704e.js +0 -481
  46. package/cli/template/.weifuwu/ssr/fae6ecbe.js +0 -14
  47. package/dist/agent/migrate.d.ts +0 -6
  48. package/dist/cms/admin.d.ts +0 -3
  49. package/dist/cms/api.d.ts +0 -3
  50. package/dist/cms/client.d.ts +0 -2
  51. package/dist/cms/content.d.ts +0 -36
  52. package/dist/cms/index.d.ts +0 -2
  53. package/dist/cms/media.d.ts +0 -17
  54. package/dist/cms/types.d.ts +0 -93
  55. package/dist/dist/agent/client.d.ts +0 -2
  56. package/dist/dist/agent/index.d.ts +0 -2
  57. package/dist/dist/agent/migrate.d.ts +0 -6
  58. package/dist/dist/agent/rest.d.ts +0 -13
  59. package/dist/dist/agent/run.d.ts +0 -17
  60. package/dist/dist/agent/types.d.ts +0 -51
  61. package/dist/dist/ai/workflow.d.ts +0 -14
  62. package/dist/dist/analytics.d.ts +0 -15
  63. package/dist/dist/client-locale.d.ts +0 -5
  64. package/dist/dist/client-pref.d.ts +0 -3
  65. package/dist/dist/client-state.d.ts +0 -22
  66. package/dist/dist/client-theme.d.ts +0 -7
  67. package/dist/dist/compress.d.ts +0 -6
  68. package/dist/dist/cookie.d.ts +0 -12
  69. package/dist/dist/deploy/config.d.ts +0 -2
  70. package/dist/dist/deploy/gateway.d.ts +0 -2
  71. package/dist/dist/deploy/index.d.ts +0 -4
  72. package/dist/dist/deploy/manager.d.ts +0 -16
  73. package/dist/dist/deploy/process.d.ts +0 -14
  74. package/dist/dist/deploy/types.d.ts +0 -62
  75. package/dist/dist/head.d.ts +0 -6
  76. package/dist/dist/helmet.d.ts +0 -18
  77. package/dist/dist/iii/client.d.ts +0 -2
  78. package/dist/dist/iii/index.d.ts +0 -4
  79. package/dist/dist/iii/register-worker.d.ts +0 -10
  80. package/dist/dist/iii/rest.d.ts +0 -3
  81. package/dist/dist/iii/stream.d.ts +0 -82
  82. package/dist/dist/iii/types.d.ts +0 -133
  83. package/dist/dist/iii/worker.d.ts +0 -2
  84. package/dist/dist/iii/ws.d.ts +0 -29
  85. package/dist/dist/index.js +0 -8180
  86. package/dist/dist/messager/agent.d.ts +0 -6
  87. package/dist/dist/messager/client.d.ts +0 -2
  88. package/dist/dist/messager/index.d.ts +0 -2
  89. package/dist/dist/messager/migrate.d.ts +0 -2
  90. package/dist/dist/messager/rest.d.ts +0 -15
  91. package/dist/dist/messager/types.d.ts +0 -56
  92. package/dist/dist/messager/ws.d.ts +0 -14
  93. package/dist/dist/preferences.d.ts +0 -14
  94. package/dist/dist/react.d.ts +0 -12
  95. package/dist/dist/react.js +0 -637
  96. package/dist/dist/request-id.d.ts +0 -6
  97. package/dist/dist/seo.d.ts +0 -39
  98. package/dist/dist/ssr/compile.d.ts +0 -2
  99. package/dist/dist/ssr/error-boundary.d.ts +0 -2
  100. package/dist/dist/ssr/index.d.ts +0 -7
  101. package/dist/dist/ssr/index.js +0 -933
  102. package/dist/dist/ssr/layout.d.ts +0 -2
  103. package/dist/dist/ssr/live.d.ts +0 -6
  104. package/dist/dist/ssr/not-found.d.ts +0 -2
  105. package/dist/dist/ssr/ssr.d.ts +0 -2
  106. package/dist/dist/ssr/stream.d.ts +0 -14
  107. package/dist/dist/ssr/tailwind.d.ts +0 -2
  108. package/dist/dist/tenant/client.d.ts +0 -2
  109. package/dist/dist/tenant/graphql.d.ts +0 -3
  110. package/dist/dist/tenant/index.d.ts +0 -2
  111. package/dist/dist/tenant/migrate.d.ts +0 -6
  112. package/dist/dist/tenant/rest.d.ts +0 -3
  113. package/dist/dist/tenant/schema.d.ts +0 -5
  114. package/dist/dist/tenant/types.d.ts +0 -48
  115. package/dist/dist/tenant/utils.d.ts +0 -10
  116. package/dist/dist/types.d.ts +0 -19
  117. package/dist/dist/use-flash-message.d.ts +0 -1
  118. package/dist/i18n.d.ts +0 -6
  119. package/dist/logdb/migrate.d.ts +0 -5
  120. package/dist/messager/migrate.d.ts +0 -2
  121. package/dist/middleware.d.ts +0 -21
  122. package/dist/opencode/migrate.d.ts +0 -2
  123. package/dist/postgres/migrate.d.ts +0 -3
  124. package/dist/postgres/table.d.ts +0 -4
  125. package/dist/root-layout.d.ts +0 -4
  126. package/dist/tenant/migrate.d.ts +0 -6
  127. package/dist/tsx-instance.d.ts +0 -43
  128. package/dist/tsx.d.ts +0 -8
  129. package/dist/user/migrate.d.ts +0 -6
  130. package/dist/workflow/engine.d.ts +0 -7
  131. package/dist/workflow/index.d.ts +0 -8
  132. package/dist/workflow/llm.d.ts +0 -10
  133. package/dist/workflow/nodes.d.ts +0 -10
  134. package/dist/workflow/reference.d.ts +0 -3
  135. package/dist/workflow/route.d.ts +0 -11
  136. package/dist/workflow/sse.d.ts +0 -2
  137. package/dist/workflow/tool.d.ts +0 -8
  138. package/dist/workflow/types.d.ts +0 -86
  139. /package/cli/template/ui/{app.css → app/globals.css} +0 -0
  140. /package/cli/template/ui/{layout.tsx → app/layout.tsx} +0 -0
  141. /package/opencode/ui/{app.css → app/globals.css} +0 -0
  142. /package/opencode/ui/{layout.tsx → app/layout.tsx} +0 -0
  143. /package/opencode/ui/{page.tsx → app/page.tsx} +0 -0
package/README.md CHANGED
@@ -15,11 +15,9 @@ serve((req, ctx) => new Response('Hello, World!'), { port: 3000 })
15
15
  ```
16
16
 
17
17
  ```ts
18
- import { serve, Router, preferences, ssr, rootLayout } from 'weifuwu'
18
+ import { serve, Router, ssr } from 'weifuwu'
19
19
  const app = new Router()
20
- app.use(preferences({ dir: './locales' }))
21
- app.use(rootLayout('./ui'))
22
- app.get('/', ssr('./ui/pages/home.tsx'))
20
+ app.use('/', ssr({ dir: './ui' }))
23
21
  serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
24
22
  ```
25
23
 
@@ -27,6 +25,17 @@ serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
27
25
  npx weifuwu init my-app && cd my-app && npm run dev
28
26
  ```
29
27
 
28
+ ## CLI
29
+
30
+ ```bash
31
+ npx weifuwu init my-app # Full project (SSR + i18n + theme + WS demo)
32
+ npx weifuwu init my-api --minimal # Minimal HTTP project (2 files)
33
+ npx weifuwu init my-api --skip-install # Skip npm install
34
+ npx weifuwu dev # Start dev server (auto-detect index.ts)
35
+ npx weifuwu generate module my-mod # Scaffold middleware module + test
36
+ npx weifuwu version # Print version
37
+ ```
38
+
30
39
  ---
31
40
 
32
41
  ## Core Concepts
@@ -44,11 +53,14 @@ await server.ready
44
53
  | `hostname` | `string` | `'0.0.0.0'` | Listen address |
45
54
  | `signal` | `AbortSignal` | — | Shutdown on abort |
46
55
  | `websocket` | `WsUpgradeHandler` | — | WebSocket upgrade handler |
47
- | `maxBodySize` | `number` | | Max body bytes |
56
+ | `maxBodySize` | `number` | `10MB` | Max body bytes (0 = unlimited) |
57
+ | `timeout` | `number` | `30_000` | Socket inactivity timeout (ms) |
58
+ | `keepAliveTimeout` | `number` | `5_000` | Keep-Alive idle timeout (ms) |
59
+ | `headersTimeout` | `number` | `6_000` | Headers read timeout (ms) |
48
60
  | `shutdown` | `boolean` | `true` | Auto SIGTERM/SIGINT |
49
61
 
50
62
  ```ts
51
- interface Server { stop: () => void; readonly port: number; readonly hostname: string; ready: Promise<void> }
63
+ interface Server { stop: () => Promise<void>; readonly port: number; readonly hostname: string; ready: Promise<void> }
52
64
  const { server, url } = await createTestServer(handler)
53
65
  ```
54
66
 
@@ -60,9 +72,24 @@ app.get('/hello/:name', (req, ctx) => Response.json({ message: `Hello, ${ctx.par
60
72
  app.post('/data', async (req, ctx) => { const body = await req.json(); return Response.json(body, { status: 201 }) })
61
73
  app.use('/admin', authMW) // path-scoped middleware
62
74
  app.use('/admin', adminRouter) // sub-router (flattened into parent trie)
63
- app.ws('/echo', { open(ws) { ws.send('connected') }, message(ws, _ctx, data) { ws.send(`echo: ${data}`) } })
75
+ app.ws('/echo', {
76
+ open(ws, ctx) { ctx.ws.json({ type: 'connected' }) },
77
+ message(ws, ctx, data) { ctx.ws.json({ echo: data.toString() }) },
78
+ })
79
+ app.ws('/chat', {
80
+ open(ws, ctx) { ctx.ws.join('room') },
81
+ message(ws, ctx, data) { ctx.ws.sendRoom('room', JSON.parse(data.toString())) },
82
+ })
64
83
  app.onError((err, req, ctx) => Response.json({ error: err.message }, { status: 500 }))
65
84
 
85
+ // Debug: list all registered routes
86
+ console.log(app.routes())
87
+ // [ 'GET /hello/:name', 'POST /data', 'WS /echo', 'WS /chat' ]
88
+
89
+ // Cross-process WebSocket broadcast (Redis)
90
+ import { createHub } from 'weifuwu'
91
+ app.wsHub(createHub({ redis: redis() }))
92
+
66
93
  const handler = app.handler()
67
94
  const wsHandler = app.websocketHandler()
68
95
  serve(handler, { port: 3000, websocket: wsHandler })
@@ -119,14 +146,33 @@ The `ctx` object accumulates properties as it passes through the middleware chai
119
146
  | `queue` | `queue()` | `Queue` | Job queue |
120
147
  | `prefs` | `preferences()` | `{ locale, theme }` | User preferences (locale, theme) |
121
148
  | `deploy` | `deploy()` | `{ appName? }` | Deploy gateway info |
122
- | `layoutStack` | `rootLayout()` / `layout()` | `LayoutEntry[]` | React layout component stack |
149
+ | `layoutStack` | `ssr()` internal | `LayoutEntry[]` | React layout component stack |
123
150
  | `loaderData` | User middleware | `Record<string, unknown>` | SSR data passed to client |
124
151
  | `user` | `auth()` / `user().middleware()` | `{ id?: string }` | Authenticated user |
125
152
  | `parsed` | `validate()` / `upload()` | `{ body, query, params, headers, files }` | Validated/parsed request data |
126
153
  | `t` | `preferences()` | `(key) => string` | Translation function |
127
154
  | `setPref` | `preferences()` | `(key, val) => Response` | Set preference cookie + redirect |
128
- | `compiledTailwindCss` | `tailwind()` | `string` | Compiled CSS content (internal) |
129
- | `tailwindCssUrl` | `tailwind()` | `string` | Compiled CSS route URL (internal) |
155
+ | `compiledTailwindCss` | `ssr()` internal | `string` | Compiled CSS content (internal) |
156
+ | `tailwindCssUrl` | `ssr()` internal | `string` | Compiled CSS route URL (internal) |
157
+
158
+ ### Type-Safe Context
159
+
160
+ Middleware-injected properties are **automatically typed** through chained `use()` calls:
161
+
162
+ ```ts
163
+ const app = new Router()
164
+ .use(csrf()) // → Router<Context & { csrfToken: string }>
165
+ .use(requestId()) // → Router<Context & { csrfToken, requestId }>
166
+ .use(postgres()) // → Router<Context & { csrfToken, requestId, sql }>
167
+
168
+ app.get('/me', (_req, ctx) => {
169
+ ctx.csrfToken // ✅ string (IDE autocomplete)
170
+ ctx.requestId // ✅ string
171
+ ctx.sql`SELECT 1` // ✅ Sql<{}>
172
+ })
173
+ ```
174
+
175
+ Each module exports an `XxxInjected` type (e.g. `PostgresInjected`, `UserInjected`) for composing custom context types. `Context` is an interface — modules augment it via `declare module` for ambient compatibility.
130
176
 
131
177
  ---
132
178
 
@@ -137,7 +183,7 @@ All modules follow one of **2 patterns** — learn these and you know every modu
137
183
  | Pattern | How to mount | Example |
138
184
  |---------|-------------|---------|
139
185
  | `[α]` | `app.use(mod())` | `compress()`, `preferences()`, `postgres()` |
140
- | `[β]` | `app.use('/path', mod())` | `health()`, `graphql(handler)`, `user()` |
186
+ | `[β]` | `app.use('/path', mod())` | `health()`, `ssr({dir})`, `graphql(handler)`, `user()` |
141
187
 
142
188
  ### Pattern α — Middleware
143
189
 
@@ -167,6 +213,98 @@ app.use('/', a) // dashboard
167
213
 
168
214
  ---
169
215
 
216
+ ## Request Tracing & Logging
217
+
218
+ Every request gets a **trace ID** via `AsyncLocalStorage`, injected into responses as `X-Trace-Id`. W3C `traceparent` headers are forwarded.
219
+
220
+ ```ts
221
+ import { currentTraceId } from 'weifuwu'
222
+
223
+ app.get('/api', (req, ctx) => {
224
+ console.log('Handling request', currentTraceId()) // f240a3f3-60e2-...
225
+ })
226
+ ```
227
+
228
+ **Structured logging** — `logger({ format: 'json' })` outputs JSON to stderr with `traceId`, `timestamp`, `elapsed_ms`:
229
+
230
+ ```json
231
+ {"level":"info","message":"request","method":"GET","path":"/api/users","status":200,"elapsed_ms":42,"traceId":"f240a3f3-...","timestamp":"2025-01-15T10:30:00.000Z"}
232
+ ```
233
+
234
+ Default format is `'short'` (human-readable). `'combined'` includes query strings.
235
+
236
+ ---
237
+
238
+ ## AI Observability
239
+
240
+ Agent runs are **automatically logged** to `_agent_runs`. Dashboard endpoints provide analytics:
241
+
242
+ ```
243
+ GET /agents/:id/runs?days=7 → [{ input, output, tokens_in, tokens_out, elapsed_ms, status, trace_id, ... }]
244
+ GET /agents/:id/runs/summary?days=7 → { total, success, error, success_rate, tokens_in, tokens_out, avg_elapsed_ms, p95_elapsed_ms }
245
+ GET /opencode/sessions/:id/usage → { message_count, tokens_in, tokens_out, tokens_total }
246
+ ```
247
+
248
+ Non-streaming runs log full token data; streaming runs log `status: 'stream'`.
249
+
250
+ ---
251
+
252
+ ## Agent ↔ Messager Streaming
253
+
254
+ Agent replies in messager channels now stream **token-by-token** via WebSocket:
255
+
256
+ ```ts
257
+ // Backend — automatic when agents are attached to messager
258
+ const msg = messager({ pg, agents: agent({ pg, model }) })
259
+ app.ws('/ws', msg.wsHandler())
260
+ // Agent replies stream to: hub.broadcast({ type: 'agent_stream', data: { token, full } })
261
+ ```
262
+
263
+ ```tsx
264
+ // Frontend — React hook
265
+ import { useAgentStream } from 'weifuwu/react'
266
+
267
+ const { getAgentText, isAgentStreaming, stream } = useAgentStream({
268
+ wsPath: '/ws',
269
+ channelId: 1,
270
+ })
271
+ ```
272
+
273
+ Multi-round conversation context: the last 10 channel messages are automatically injected into agent calls.
274
+
275
+ ---
276
+
277
+ ## Test Utilities
278
+
279
+ Chainable test helper for HTTP-level testing without starting a server:
280
+
281
+ ```ts
282
+ import { testApp } from 'weifuwu'
283
+
284
+ const app = testApp()
285
+ app.use(postgres({ connection: TEST_DB }))
286
+ app.get('/users/:id', (req, ctx) => Response.json({ id: ctx.params.id, user: ctx.user }))
287
+
288
+ const res = await app
289
+ .getReq('/users/42?name=Alice')
290
+ .withUser({ id: 1 })
291
+ .header('X-Custom', 'val')
292
+ .body({ data: 'test' })
293
+ .send()
294
+
295
+ assert.equal(res.status, 200)
296
+ assert.deepEqual(await res.json(), { id: '42', user: { id: 1 } })
297
+ ```
298
+
299
+ | Method | Description |
300
+ |--------|-------------|
301
+ | `app.getReq(path)` `postReq` `putReq` `patchReq` `deleteReq` | Start building a request |
302
+ | `.withUser(u)` `.withTenant(t)` `.with(ctx)` | Simulate middleware injection |
303
+ | `.header(k,v)` `.body(data)` `.rawBody(str)` | Set request properties |
304
+ | `.send()` → `TestResponse` | Execute and get `{ status, headers, json(), text() }` |
305
+
306
+ ---
307
+
170
308
  ## Module Reference
171
309
 
172
310
  ### agent [β]
@@ -398,23 +536,30 @@ Restart=always
398
536
  | `buildCommand` | — | Build command |
399
537
  | `ports` | — | `[port, port+1]` for blue-green |
400
538
 
401
- ### errorBoundary(path) [β]
402
539
 
403
- Wraps child routes in an error boundary. If a page or middleware throws, the error component is rendered via SSR with head injection (CSS, theme, context) and layout wrapping.
404
-
405
- ```ts
406
- app.use('/blog', errorBoundary('./blog-error.tsx'))
407
- ```
408
540
 
409
- The error component receives `{ error, reset }` as props (`reset` is a no-op on the server):
541
+ ### graphql [β]
410
542
 
411
- ```tsx
412
- export default function BlogError({ error, reset }: { error: Error; reset: () => void }) {
413
- return <div><h2>Error</h2><p>{error.message}</p></div>
414
- }
543
+ ```ts
544
+ const handler: GraphQLHandler = () => ({
545
+ schema: `type Query { hello: String }`,
546
+ resolvers: { Query: { hello: () => 'world' } },
547
+ graphiql: true, // GET / returns GraphiQL IDE
548
+ maxDepth: 10, // max query nesting (default 10, 0 = disable)
549
+ timeout: 30_000, // execution timeout in ms
550
+ })
551
+ app.use('/graphql', graphql(handler))
415
552
  ```
416
553
 
417
- Error boundaries nest the nearest one up the middleware chain catches the error.
554
+ | Option | Type | Default | Description |
555
+ |--------|------|---------|-------------|
556
+ | `schema` | `string \| GraphQLSchema` | — | SDL string or pre-built schema |
557
+ | `resolvers` | `object` | — | Resolver map |
558
+ | `rootValue` | `any` | — | Root value for queries |
559
+ | `context` | `(req, ctx) => object` | — | Per-request context factory |
560
+ | `graphiql` | `boolean` | `false` | Serve GraphiQL IDE at GET / |
561
+ | `maxDepth` | `number` | `10` | Max query nesting depth |
562
+ | `timeout` | `number` | `30_000` | Execution timeout (ms) |
418
563
 
419
564
  ### health [β]
420
565
 
@@ -483,16 +628,7 @@ await engine.trigger({ function_id: 'orders::create', payload: { items: ['apple'
483
628
  | `.migrate()` | DB setup |
484
629
  | `.shutdown()` | Clean shutdown |
485
630
 
486
- ### layout(path) [β]
487
-
488
- Compiles a `.tsx` file and returns middleware that pushes the layout component onto `ctx.layoutStack`. Pages rendered by `ssr()` consume this stack. Use after `rootLayout()` to extend a shared structure.
489
-
490
- ```ts
491
- app.use(rootLayout('./ui'))
492
- app.use(layout('./extra.tsx')) // appended after rootLayout
493
- ```
494
631
 
495
- Layout components receive `{ children }` (the child page or nested layout). Multiple layouts wrap from outer to inner in `use()` order.
496
632
 
497
633
  ### logdb [β]
498
634
 
@@ -566,17 +702,7 @@ await msg.send(channelId, 'System message', { sender_type: 'system', sender_id:
566
702
  | `.send(channel, content, opts?)` | Send message to channel |
567
703
  | `.close()` | Cleanup |
568
704
 
569
- ### notFound(path) [β]
570
-
571
- Returns a catch-all handler for 404 pages. When a path is given, the component is rendered via SSR with layout support. Falls back to plain text if compilation fails or no path given.
572
705
 
573
- ```ts
574
- // No path — plain text
575
- app.all('/*', notFound())
576
-
577
- // Path to a .tsx component — renders via SSR
578
- app.all('/*', notFound('./not-found.tsx'))
579
- ```
580
706
 
581
707
  ### opencode [β]
582
708
 
@@ -621,52 +747,70 @@ app.use(pg) // injects ctx.sql
621
747
  | `ssl` | `boolean\|object` | — | SSL options |
622
748
  | `idle_timeout` | `number` | `30` | Idle timeout (seconds) |
623
749
  | `connect_timeout` | `number` | `30` | Connection timeout |
750
+ | `statementTimeout` | `number` | `30_000` | Per-statement timeout (ms, 0 = disable) |
751
+ | `onQuery` | `(query, ms, rows) => void` | — | Query logging callback |
624
752
 
625
753
  ```ts
626
754
  // Raw SQL via tagged template
627
755
  await pg.sql`SELECT * FROM users WHERE email = ${email}`
628
756
 
629
- // Type-safe DDL
630
- import { pgTable, serial, text, boolean, timestamps } from 'weifuwu'
757
+ // Define a table — one API, sql pre-bound
758
+ import { serial, text, boolean, timestamps } from 'weifuwu'
631
759
 
632
- const users = pgTable('_users', {
760
+ const users = pg.table('_users', {
633
761
  id: serial('id').primaryKey(),
634
762
  name: text('name').notNull(),
635
763
  email: text('email').unique().notNull(),
636
764
  active: boolean('active').default(true),
637
765
  ...timestamps(),
638
766
  })
639
- await users.create(pg.sql)
640
- await users.createIndex(pg.sql, 'email')
641
-
642
- // BoundTable — sql bound once, no need to pass sql to every call
643
- const t = pg.table('_users', { id: serial('id'), name: text('name'), email: text('email'), ...timestamps() })
644
- await t.insert({ name: 'Alice' })
645
- // vs unbound Table sql passed as first argument each time
646
- // const t = pgTable('_users', { ... })
647
- // await t.insert(pg.sql, { name: 'Alice' })
648
- const { count, data } = await t.readMany({ role: 'admin' }, { orderBy: { name: 'asc' }, limit: 10 })
649
- await t.upsert({ email: 'alice@test.com' }, 'email')
650
-
651
- // Transactions
767
+ await users.create() // DDL — no need to pass sql
768
+ await users.createIndex('email')
769
+
770
+ // CRUD — sql already bound
771
+ await users.insert({ name: 'Alice' })
772
+ const { count, data } = await users.readMany({ role: 'admin' }, { orderBy: { name: 'asc' }, limit: 10 })
773
+ await users.upsert({ email: 'alice@test.com' }, 'email')
774
+
775
+ // Reuse schema without redefining fields
776
+ import { pgTable } from 'weifuwu'
777
+ const usersSchema = pgTable('_users', { id: serial('id'), name: text('name') }) // define once
778
+ const users = pg.table(usersSchema) // bind — no field duplication
779
+
780
+ // Transactions — with auto-retry on deadlock/serialization failure
652
781
  await pg.transaction(async (sql) => {
653
- const users = pg.table('_users', {}).withSql(sql)
654
- return users.insert({ name: 'Bob' })
655
- })
782
+ const txUsers = users.withSql(sql)
783
+ return txUsers.insert({ name: 'Bob' })
784
+ }, { maxRetries: 3 })
656
785
 
657
786
  // Soft delete — automatic if deleted_at column exists
658
- await t.delete(1) // SET deleted_at = NOW()
659
- await t.hardDelete(1) // DELETE FROM
660
- await t.readMany() // WHERE deleted_at IS NULL (use withDeleted: true to include)
787
+ await users.delete(1) // SET deleted_at = NOW()
788
+ await users.hardDelete(1) // DELETE FROM
789
+ await users.read(1) // auto-filters deleted_at IS NULL (use withDeleted: true to include)
661
790
 
662
791
  // JSONB queries
663
792
  const logs = pg.table('logs', { meta: jsonb<{ service: string }>('meta') })
664
793
  await logs.readMany(contains('meta', { service: 'auth' }))
665
794
 
795
+ // Connection pool visibility
796
+ console.log(pg.poolStats()) // { active: 3, idle: 7, waiting: 0, max: 10 }
797
+
798
+ // Migration tracking
799
+ await pg.migrate() // creates _weifuwu_migrations
800
+ await pg.markMigrated('myModule') // idempotent
801
+ const done = await pg.isMigrated('myModule')
802
+
666
803
  // Partitioned tables
667
804
  await logs.create({ partitionBy: partitionBy('range', 'created_at') })
668
805
  ```
669
806
 
807
+ **When to use pgTable vs pg.table:**
808
+ | API | Use when |
809
+ |-----|---------|
810
+ | `pg.table('t', cols)` | You have `pg` available (factory, handler, migrate) |
811
+ | `pg.table(schema)` | Reusing a schema without duplicating field definitions |
812
+ | `pgTable('t', cols)` | No `pg` reference (utility modules, standalone schema files) |
813
+
670
814
  | Column builder | Type | Notes |
671
815
  |---------------|------|-------|
672
816
  | `serial(name)` | `number` | Auto-increment |
@@ -681,27 +825,27 @@ await logs.create({ partitionBy: partitionBy('range', 'created_at') })
681
825
 
682
826
  **Column modifiers:** `.primaryKey()`, `.notNull()`, `.nullable()`, `.default(val)`, `.unique()`, `.references(table, column?, onDelete?)`.
683
827
 
684
- **BoundTable CRUD methods:**
828
+ **CRUD methods:**
685
829
 
686
830
  | Method | Description |
687
831
  |--------|-------------|
688
832
  | `insert(data)` | INSERT + RETURNING \*, returns the inserted row |
689
833
  | `insertMany(data)` | Bulk INSERT + RETURNING \*, returns rows |
690
- | `read(id, opts?)` | SELECT by primary key, returns row or undefined |
834
+ | `read(id, opts?)` | SELECT by detected primary key + auto soft-delete filter |
691
835
  | `readMany(where?, opts?)` | Filtered query with `{ count, data }` — auto-filters soft-deleted |
692
- | `update(id, data)` | UPDATE by id + RETURNING \*, returns updated row |
836
+ | `update(id, data)` | UPDATE by primary key + RETURNING \*, returns updated row |
693
837
  | `updateMany(where, data)` | Bulk UPDATE, returns affected row count |
694
838
  | `delete(id)` | Soft delete if `deleted_at` exists, else hard delete |
695
839
  | `hardDelete(id)` | Always DELETE FROM |
696
840
  | `deleteMany(where)` | Soft bulk delete if `deleted_at` exists |
697
841
  | `hardDeleteMany(where)` | Always DELETE FROM |
698
842
  | `upsert(data, conflict)` | INSERT ON CONFLICT DO UPDATE, returns row |
699
- | `count(where?)` | SELECT COUNT(\*) |
843
+ | `count(where?)` | SELECT COUNT(\*) — auto-filters soft-deleted |
700
844
  | `create(opts?)` | CREATE TABLE IF NOT EXISTS |
701
845
  | `drop(opts?)` | DROP TABLE IF EXISTS |
702
846
  | `createIndex(columns, opts?)` | CREATE INDEX |
703
847
  | `createUniqueIndex(columns)` | CREATE UNIQUE INDEX |
704
- | `withSql(sql)` | Returns a new BoundTable bound to a different sql (for transactions) |
848
+ | `withSql(sql)` | Returns copy bound to a different sql (for transactions) |
705
849
 
706
850
  **Where helpers** — composable query conditions:
707
851
 
@@ -825,20 +969,7 @@ app.use(requestId({ header: 'X-Request-Id', generator: () => crypto.randomUUID()
825
969
  | `header` | `string` | `'X-Request-ID'` | Header name to read/write |
826
970
  | `generator` | `() => string` | `crypto.randomUUID()` | ID generator |
827
971
 
828
- ### rootLayout(dir) [α]
829
-
830
- One-stop middleware for a page structure. Compiles `layout.tsx` and `app.css` from the given directory, and in dev mode registers vendor bundle, HMR WebSocket, and file watcher.
831
-
832
- ```ts
833
- app.use(rootLayout('./ui'))
834
- // Scans ./ui/layout.tsx → layout component
835
- // Scans ./ui/app.css → tailwind CSS at /{mountPath}/__wfw/style/{hash}.css
836
- // Dev: vendor bundle + HMR WS + file watcher
837
- ```
838
972
 
839
- - Sets `ctx.layoutStack` (consumed by `ssr()`)
840
- - Registers CSS route at `/__wfw/style/:hash.css` — Tailwind CSS compiled via `@tailwindcss/postcss` if `app.css` exists in the dir. Hash is content-based, ensuring cache invalidation on changes.
841
- - Each `rootLayout` instance is independent — sub-routes can define their own
842
973
 
843
974
  ### seo [β] + seoMiddleware [α]
844
975
 
@@ -858,21 +989,64 @@ Also exports `seoTags(config)` for generating meta/og/twitter tags as an HTML st
858
989
  | `sitemap` | `SitemapConfig` | — | Sitemap configuration (urls, resolve, cacheTTL) |
859
990
  | `headers` | `SeoHeadersConfig` | — | Response headers (e.g. `X-Robots-Tag`) |
860
991
 
861
- ### ssr(path) [β]
992
+ ### ssr({ dir }) [β]
993
+
994
+ One-stop Server-Side Rendering. Accepts a directory and returns a Router that handles all SSR routes, tailwind CSS, hydration bundles, and livereload — using Next.js-style file conventions.
862
995
 
863
- Compiles a `.tsx` file and returns a Router handler that renders the React component to HTML with streaming, client bundle injection, and context serialization.
996
+ ```ts
997
+ import { Router, ssr } from 'weifuwu'
998
+ const app = new Router()
999
+ app.use('/', ssr({ dir: './ui' }))
1000
+ ```
1001
+
1002
+ **Directory conventions (Next.js-style):**
1003
+
1004
+ ```
1005
+ ./ui/
1006
+ ├── app/ ← only this directory affects routing
1007
+ │ ├── globals.css ← tailwind CSS + CSS variables (optional)
1008
+ │ ├── layout.tsx → root layout (wraps all pages)
1009
+ │ ├── page.tsx → GET /
1010
+ │ ├── not-found.tsx → 404 page (optional)
1011
+ │ ├── error.tsx → error boundary (optional)
1012
+ │ ├── about/
1013
+ │ │ ├── page.tsx → GET /about
1014
+ │ │ └── layout.tsx → group layout
1015
+ │ └── posts/
1016
+ │ ├── page.tsx → GET /posts
1017
+ │ └── [id]/
1018
+ │ └── page.tsx → GET /posts/:id
1019
+ ├── components/ ← shared components (does not affect routing)
1020
+ └── lib/ ← utilities (does not affect routing)
1021
+ ```
1022
+
1023
+ | Location | Route |
1024
+ |----------|-------|
1025
+ | `app/page.tsx` | `GET /` |
1026
+ | `app/[param]/page.tsx` | `GET /:param` |
1027
+ | `app/layout.tsx` | Root layout (wraps all pages in its subtree) |
1028
+ | `app/not-found.tsx` | 404 fallback for that subtree |
1029
+ | `app/error.tsx` | Error boundary for that subtree |
1030
+ | `app/globals.css` | Tailwind CSS entry (compiled via `@tailwindcss/postcss`) |
1031
+
1032
+ **How it works:**
1033
+
1034
+ - Each page is lazy-resolved on first request — only the `page.tsx` and its layout chain are compiled
1035
+ - Hydration bundle generated per-page at `/__ssr/{hash}.js`
1036
+ - Tailwind CSS served at `/__wfw/style/{hash}.css` (cached, content-hashed)
1037
+ - Dev mode: vendor bundle, HMR WebSocket, file watcher — all automatic
1038
+ - Page components and layouts are compiled via esbuild at runtime — no build step needed
864
1039
 
865
1040
  ```ts
866
- app.get('/about', ssr('./ui/pages/about.tsx'))
1041
+ // Multiple independent SSR directories
1042
+ app.use('/', ssr({ dir: './www' }))
1043
+ app.use('/admin', ssr({ dir: './admin' }))
1044
+
1045
+ // API routes coexist normally
1046
+ app.get('/api/ping', () => Response.json({ pong: true }))
867
1047
  ```
868
1048
 
869
- - Compiles via esbuild at runtime (no build step)
870
- - Reads `ctx.layoutStack` (set by `rootLayout()` or `layout()`) and wraps the component from outer to inner
871
- - Injects hydration script pointing to the auto-generated client bundle at `/__ssr/{entryId}.js`
872
- - Injects `<link>` for tailwind CSS from `ctx.tailwindCssUrl` (set by `rootLayout()`)
873
- - Serializes middleware-injected `ctx` data to `window.__WEIFUWU_CTX` for client-side hydration
874
- - **Dev mode:** uses `createRoot` instead of `hydrateRoot` — all hooks (`useState`, `useEffect`) work correctly; SSR content is still streamed for fast first paint
875
- - **Prod mode:** uses `hydrateRoot` for full SSR hydration
1049
+ Layout components receive `{ children }` and wrap from outer to inner:
876
1050
 
877
1051
  ### tenant [β]
878
1052
 
@@ -1084,9 +1258,9 @@ function Toast() {
1084
1258
 
1085
1259
  ### Dev mode
1086
1260
 
1087
- Auto-detected when `NODE_ENV !== 'production'`. `rootLayout(dir)` automatically registers vendor bundle, HMR WebSocket, and file watcher. No explicit setup needed.
1261
+ Auto-detected when `NODE_ENV !== 'production'`. `ssr({dir})` automatically registers vendor bundle, HMR WebSocket, and file watcher. No explicit setup needed.
1088
1262
 
1089
- When a `.tsx` or `.css` file changes under the `rootLayout` dir, the browser hot-updates without refreshing — `useState` values are preserved. Layout changes trigger a full page reload.
1263
+ When a `.tsx` or `.css` file changes under the `ssr` dir, the browser hot-updates without refreshing — `useState` values are preserved. Layout changes trigger a full page reload.
1090
1264
 
1091
1265
  ---
1092
1266
 
@@ -1125,9 +1299,10 @@ Every public symbol can be imported from `'weifuwu'`:
1125
1299
  ### Core
1126
1300
 
1127
1301
  ```ts
1128
- serve, createTestServer, Router,
1302
+ serve, createTestServer, Router, ssr,
1129
1303
  Context, Handler, Middleware, ErrorHandler, ServeOptions, Server,
1130
- loadEnv
1304
+ loadEnv, testApp, TestApp, TestRequest, TestResponse,
1305
+ currentTraceId, currentTrace, runWithTrace, traceElapsed, TraceContext,
1131
1306
  ```
1132
1307
 
1133
1308
  ### Middleware modules
@@ -1143,6 +1318,7 @@ preferences, serveStatic
1143
1318
  postgres, PostgresOptions, PostgresClient,
1144
1319
  redis, RedisOptions, RedisClient,
1145
1320
  queue, QueueOptions, QueueJob, Queue,
1321
+ PostgresInjected, RedisInjected, QueueInjected,
1146
1322
  // Schema helpers — importable alongside postgres:
1147
1323
  pgTable, SQL, sql,
1148
1324
  ColumnBuilder, serial, uuid, text, integer, boolean, boolean_, timestamptz, jsonb, textArray, vector,
@@ -1158,8 +1334,10 @@ TsxContext, useLoaderData,
1158
1334
  useWebsocket, useAction, useFetch, useQueryState, createStore,
1159
1335
  Link, useNavigate, useNavigating, addInterceptor,
1160
1336
  useLocale, useTheme, applyTheme, useFlashMessage,
1337
+ useAgentStream,
1161
1338
  Head
1162
1339
  ```
1340
+ export type { UseAgentStreamOptions, UseAgentStreamReturn, AgentStreamState } from 'weifuwu/react'
1163
1341
 
1164
1342
  ### AI SDK (re-exported from `ai`)
1165
1343
 
@@ -1172,12 +1350,16 @@ openai, createOpenAI
1172
1350
  ### Other modules
1173
1351
 
1174
1352
  ```ts
1175
- health, analytics, seo, seoMiddleware, seoTags,
1353
+ preferences, health, analytics, seo, seoMiddleware, seoTags,
1176
1354
  user, mailer, graphql, aiStream, runWorkflow,
1177
1355
  logdb, messager, agent, iii, createWorker, registerWorker,
1178
1356
  opencode, deploy, defineConfig,
1357
+ testApp, TestApp, TestRequest, TestResponse,
1179
1358
  getCookies, setCookie, deleteCookie,
1180
- createSSEStream, formatSSE, formatSSEData
1359
+ createSSEStream, formatSSE, formatSSEData,
1360
+ currentTraceId, currentTrace, runWithTrace, traceElapsed,
1361
+ createHub, Hub, HubOptions,
1362
+ DEFAULT_MAX_BODY, MIGRATIONS_TABLE,
1181
1363
  ```
1182
1364
 
1183
1365
  ---
@@ -1,12 +1,8 @@
1
- import { join } from 'node:path'
2
- import { Router, ssr, rootLayout, preferences } from '../../index.ts'
3
-
4
- const _ui = join(import.meta.dirname, 'ui')
5
- const _loc = join(import.meta.dirname, 'locales')
1
+ import { Router, ssr, preferences } from '../../index.ts'
6
2
 
7
3
  export const app = new Router()
8
- app.use(rootLayout(_ui))
9
- app.use(preferences({ dir: _loc, locale: { default: 'en' }, theme: { default: 'system' } }))
4
+ app.use('/', ssr({ dir: './ui' }))
5
+ app.use(preferences({ dir: './locales', locale: { default: 'en' }, theme: { default: 'system' } }))
10
6
  app.use(async (req, ctx, next) => {
11
7
  ctx.loaderData = {
12
8
  features: [
@@ -17,6 +13,5 @@ app.use(async (req, ctx, next) => {
17
13
  }
18
14
  return next(req, ctx)
19
15
  })
20
- app.get('/', ssr(join(_ui, 'page.tsx')))
21
16
  app.get('/api/ping', () => Response.json({ pong: true, time: new Date().toISOString() }))
22
17
  app.ws('/ws/echo', { message(ws, _ctx, data) { ws.send(`echo: ${data}`) } })
@@ -3,6 +3,4 @@ import { app } from './app.ts'
3
3
 
4
4
  loadEnv()
5
5
  const port = Number(process.env.PORT) || 3000
6
- const server = serve(app.handler(), { port, websocket: app.websocketHandler() })
7
- await server.ready
8
- console.log(`Listening on http://localhost:${server.port}`)
6
+ serve(app.handler(), { port, websocket: app.websocketHandler() })
@@ -1,6 +1,6 @@
1
1
  import { useState } from 'react'
2
- import { useWebsocket, useLoaderData, useLocale, useTheme } from '../../../react.ts'
3
- import Greeting from './components/Greeting.tsx'
2
+ import { useWebsocket, useLoaderData, useLocale, useTheme } from '../../../../react.ts'
3
+ import Greeting from '../components/Greeting.tsx'
4
4
 
5
5
  export default function Home() {
6
6
  const [input, setInput] = useState('')