weifuwu 0.19.10 → 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.
package/README.md CHANGED
@@ -25,6 +25,17 @@ serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
25
25
  npx weifuwu init my-app && cd my-app && npm run dev
26
26
  ```
27
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
+
28
39
  ---
29
40
 
30
41
  ## Core Concepts
@@ -42,11 +53,14 @@ await server.ready
42
53
  | `hostname` | `string` | `'0.0.0.0'` | Listen address |
43
54
  | `signal` | `AbortSignal` | — | Shutdown on abort |
44
55
  | `websocket` | `WsUpgradeHandler` | — | WebSocket upgrade handler |
45
- | `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) |
46
60
  | `shutdown` | `boolean` | `true` | Auto SIGTERM/SIGINT |
47
61
 
48
62
  ```ts
49
- 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> }
50
64
  const { server, url } = await createTestServer(handler)
51
65
  ```
52
66
 
@@ -58,9 +72,24 @@ app.get('/hello/:name', (req, ctx) => Response.json({ message: `Hello, ${ctx.par
58
72
  app.post('/data', async (req, ctx) => { const body = await req.json(); return Response.json(body, { status: 201 }) })
59
73
  app.use('/admin', authMW) // path-scoped middleware
60
74
  app.use('/admin', adminRouter) // sub-router (flattened into parent trie)
61
- 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
+ })
62
83
  app.onError((err, req, ctx) => Response.json({ error: err.message }, { status: 500 }))
63
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
+
64
93
  const handler = app.handler()
65
94
  const wsHandler = app.websocketHandler()
66
95
  serve(handler, { port: 3000, websocket: wsHandler })
@@ -126,6 +155,25 @@ The `ctx` object accumulates properties as it passes through the middleware chai
126
155
  | `compiledTailwindCss` | `ssr()` internal | `string` | Compiled CSS content (internal) |
127
156
  | `tailwindCssUrl` | `ssr()` internal | `string` | Compiled CSS route URL (internal) |
128
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.
176
+
129
177
  ---
130
178
 
131
179
  ## Module Patterns
@@ -165,6 +213,98 @@ app.use('/', a) // dashboard
165
213
 
166
214
  ---
167
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
+
168
308
  ## Module Reference
169
309
 
170
310
  ### agent [β]
@@ -398,6 +538,29 @@ Restart=always
398
538
 
399
539
 
400
540
 
541
+ ### graphql [β]
542
+
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))
552
+ ```
553
+
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) |
563
+
401
564
  ### health [β]
402
565
 
403
566
  ```ts
@@ -584,52 +747,70 @@ app.use(pg) // injects ctx.sql
584
747
  | `ssl` | `boolean\|object` | — | SSL options |
585
748
  | `idle_timeout` | `number` | `30` | Idle timeout (seconds) |
586
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 |
587
752
 
588
753
  ```ts
589
754
  // Raw SQL via tagged template
590
755
  await pg.sql`SELECT * FROM users WHERE email = ${email}`
591
756
 
592
- // Type-safe DDL
593
- 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'
594
759
 
595
- const users = pgTable('_users', {
760
+ const users = pg.table('_users', {
596
761
  id: serial('id').primaryKey(),
597
762
  name: text('name').notNull(),
598
763
  email: text('email').unique().notNull(),
599
764
  active: boolean('active').default(true),
600
765
  ...timestamps(),
601
766
  })
602
- await users.create(pg.sql)
603
- await users.createIndex(pg.sql, 'email')
604
-
605
- // BoundTable — sql bound once, no need to pass sql to every call
606
- const t = pg.table('_users', { id: serial('id'), name: text('name'), email: text('email'), ...timestamps() })
607
- await t.insert({ name: 'Alice' })
608
- // vs unbound Table sql passed as first argument each time
609
- // const t = pgTable('_users', { ... })
610
- // await t.insert(pg.sql, { name: 'Alice' })
611
- const { count, data } = await t.readMany({ role: 'admin' }, { orderBy: { name: 'asc' }, limit: 10 })
612
- await t.upsert({ email: 'alice@test.com' }, 'email')
613
-
614
- // 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
615
781
  await pg.transaction(async (sql) => {
616
- const users = pg.table('_users', {}).withSql(sql)
617
- return users.insert({ name: 'Bob' })
618
- })
782
+ const txUsers = users.withSql(sql)
783
+ return txUsers.insert({ name: 'Bob' })
784
+ }, { maxRetries: 3 })
619
785
 
620
786
  // Soft delete — automatic if deleted_at column exists
621
- await t.delete(1) // SET deleted_at = NOW()
622
- await t.hardDelete(1) // DELETE FROM
623
- 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)
624
790
 
625
791
  // JSONB queries
626
792
  const logs = pg.table('logs', { meta: jsonb<{ service: string }>('meta') })
627
793
  await logs.readMany(contains('meta', { service: 'auth' }))
628
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
+
629
803
  // Partitioned tables
630
804
  await logs.create({ partitionBy: partitionBy('range', 'created_at') })
631
805
  ```
632
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
+
633
814
  | Column builder | Type | Notes |
634
815
  |---------------|------|-------|
635
816
  | `serial(name)` | `number` | Auto-increment |
@@ -644,27 +825,27 @@ await logs.create({ partitionBy: partitionBy('range', 'created_at') })
644
825
 
645
826
  **Column modifiers:** `.primaryKey()`, `.notNull()`, `.nullable()`, `.default(val)`, `.unique()`, `.references(table, column?, onDelete?)`.
646
827
 
647
- **BoundTable CRUD methods:**
828
+ **CRUD methods:**
648
829
 
649
830
  | Method | Description |
650
831
  |--------|-------------|
651
832
  | `insert(data)` | INSERT + RETURNING \*, returns the inserted row |
652
833
  | `insertMany(data)` | Bulk INSERT + RETURNING \*, returns rows |
653
- | `read(id, opts?)` | SELECT by primary key, returns row or undefined |
834
+ | `read(id, opts?)` | SELECT by detected primary key + auto soft-delete filter |
654
835
  | `readMany(where?, opts?)` | Filtered query with `{ count, data }` — auto-filters soft-deleted |
655
- | `update(id, data)` | UPDATE by id + RETURNING \*, returns updated row |
836
+ | `update(id, data)` | UPDATE by primary key + RETURNING \*, returns updated row |
656
837
  | `updateMany(where, data)` | Bulk UPDATE, returns affected row count |
657
838
  | `delete(id)` | Soft delete if `deleted_at` exists, else hard delete |
658
839
  | `hardDelete(id)` | Always DELETE FROM |
659
840
  | `deleteMany(where)` | Soft bulk delete if `deleted_at` exists |
660
841
  | `hardDeleteMany(where)` | Always DELETE FROM |
661
842
  | `upsert(data, conflict)` | INSERT ON CONFLICT DO UPDATE, returns row |
662
- | `count(where?)` | SELECT COUNT(\*) |
843
+ | `count(where?)` | SELECT COUNT(\*) — auto-filters soft-deleted |
663
844
  | `create(opts?)` | CREATE TABLE IF NOT EXISTS |
664
845
  | `drop(opts?)` | DROP TABLE IF EXISTS |
665
846
  | `createIndex(columns, opts?)` | CREATE INDEX |
666
847
  | `createUniqueIndex(columns)` | CREATE UNIQUE INDEX |
667
- | `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) |
668
849
 
669
850
  **Where helpers** — composable query conditions:
670
851
 
@@ -1120,7 +1301,8 @@ Every public symbol can be imported from `'weifuwu'`:
1120
1301
  ```ts
1121
1302
  serve, createTestServer, Router, ssr,
1122
1303
  Context, Handler, Middleware, ErrorHandler, ServeOptions, Server,
1123
- loadEnv
1304
+ loadEnv, testApp, TestApp, TestRequest, TestResponse,
1305
+ currentTraceId, currentTrace, runWithTrace, traceElapsed, TraceContext,
1124
1306
  ```
1125
1307
 
1126
1308
  ### Middleware modules
@@ -1136,6 +1318,7 @@ preferences, serveStatic
1136
1318
  postgres, PostgresOptions, PostgresClient,
1137
1319
  redis, RedisOptions, RedisClient,
1138
1320
  queue, QueueOptions, QueueJob, Queue,
1321
+ PostgresInjected, RedisInjected, QueueInjected,
1139
1322
  // Schema helpers — importable alongside postgres:
1140
1323
  pgTable, SQL, sql,
1141
1324
  ColumnBuilder, serial, uuid, text, integer, boolean, boolean_, timestamptz, jsonb, textArray, vector,
@@ -1151,8 +1334,10 @@ TsxContext, useLoaderData,
1151
1334
  useWebsocket, useAction, useFetch, useQueryState, createStore,
1152
1335
  Link, useNavigate, useNavigating, addInterceptor,
1153
1336
  useLocale, useTheme, applyTheme, useFlashMessage,
1337
+ useAgentStream,
1154
1338
  Head
1155
1339
  ```
1340
+ export type { UseAgentStreamOptions, UseAgentStreamReturn, AgentStreamState } from 'weifuwu/react'
1156
1341
 
1157
1342
  ### AI SDK (re-exported from `ai`)
1158
1343
 
@@ -1169,8 +1354,12 @@ preferences, health, analytics, seo, seoMiddleware, seoTags,
1169
1354
  user, mailer, graphql, aiStream, runWorkflow,
1170
1355
  logdb, messager, agent, iii, createWorker, registerWorker,
1171
1356
  opencode, deploy, defineConfig,
1357
+ testApp, TestApp, TestRequest, TestResponse,
1172
1358
  getCookies, setCookie, deleteCookie,
1173
- createSSEStream, formatSSE, formatSSEData
1359
+ createSSEStream, formatSSE, formatSSEData,
1360
+ currentTraceId, currentTrace, runWithTrace, traceElapsed,
1361
+ createHub, Hub, HubOptions,
1362
+ DEFAULT_MAX_BODY, MIGRATIONS_TABLE,
1174
1363
  ```
1175
1364
 
1176
1365
  ---
@@ -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() })