weifuwu 0.19.10 → 0.22.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 +468 -36
- package/cli/template/index.ts +1 -3
- package/cli.ts +212 -34
- package/dist/agent/rest.d.ts +1 -0
- package/dist/agent/run.d.ts +1 -0
- package/dist/cache.d.ts +74 -0
- package/dist/cli.js +184 -30
- package/dist/client-router.d.ts +1 -1
- package/dist/csrf.d.ts +4 -2
- package/dist/env.d.ts +6 -1
- package/dist/fts.d.ts +36 -0
- package/dist/graphql.d.ts +4 -0
- package/dist/index.d.ts +18 -6
- package/dist/index.js +2247 -500
- package/dist/logger.d.ts +14 -3
- package/dist/messager/agent.d.ts +6 -1
- package/dist/postgres/client.d.ts +2 -0
- package/dist/postgres/index.d.ts +2 -2
- package/dist/postgres/module.d.ts +5 -3
- package/dist/postgres/schema/table.d.ts +8 -2
- package/dist/postgres/types.d.ts +31 -8
- package/dist/preferences.d.ts +6 -2
- package/dist/queue/types.d.ts +28 -3
- package/dist/rate-limit.d.ts +7 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +57 -0
- package/dist/redis/types.d.ts +5 -3
- package/dist/request-id.d.ts +4 -2
- package/dist/router.d.ts +28 -16
- package/dist/serve.d.ts +13 -2
- package/dist/session.d.ts +83 -0
- package/dist/ssr.d.ts +15 -0
- package/dist/test-utils.d.ts +116 -0
- package/dist/trace.d.ts +15 -0
- package/dist/types.d.ts +3 -3
- package/dist/use-agent-stream.d.ts +27 -0
- package/dist/user/index.d.ts +1 -1
- package/dist/user/types.d.ts +5 -2
- package/dist/validate.d.ts +1 -1
- package/dist/webhook.d.ts +54 -0
- package/package.json +1 -1
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` |
|
|
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
|
|
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', {
|
|
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 })
|
|
@@ -112,6 +141,8 @@ The `ctx` object accumulates properties as it passes through the middleware chai
|
|
|
112
141
|
| `env` | `loadEnv()` | `Record<string, string>` | Public env vars (`WEIFUWU_PUBLIC_*`) |
|
|
113
142
|
| `csrfToken` | `csrf()` | `string` | CSRF token |
|
|
114
143
|
| `requestId` | `requestId()` | `string` | Request ID |
|
|
144
|
+
| `session` | `session()` | `Session` | Session data object |
|
|
145
|
+
| `sessionId` | `session()` | `string` | Session ID |
|
|
115
146
|
| `sql` | `postgres()` | `Sql<{}>` | PostgreSQL tagged-template client |
|
|
116
147
|
| `redis` | `redis()` | `Redis` | Redis client |
|
|
117
148
|
| `queue` | `queue()` | `Queue` | Job queue |
|
|
@@ -125,6 +156,27 @@ The `ctx` object accumulates properties as it passes through the middleware chai
|
|
|
125
156
|
| `setPref` | `preferences()` | `(key, val) => Response` | Set preference cookie + redirect |
|
|
126
157
|
| `compiledTailwindCss` | `ssr()` internal | `string` | Compiled CSS content (internal) |
|
|
127
158
|
| `tailwindCssUrl` | `ssr()` internal | `string` | Compiled CSS route URL (internal) |
|
|
159
|
+
| `session` | `session()` | `Session` | Session data object |
|
|
160
|
+
| `sessionId` | `session()` | `string` | Session ID |
|
|
161
|
+
|
|
162
|
+
### Type-Safe Context
|
|
163
|
+
|
|
164
|
+
Middleware-injected properties are **automatically typed** through chained `use()` calls:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const app = new Router()
|
|
168
|
+
.use(csrf()) // → Router<Context & { csrfToken: string }>
|
|
169
|
+
.use(requestId()) // → Router<Context & { csrfToken, requestId }>
|
|
170
|
+
.use(postgres()) // → Router<Context & { csrfToken, requestId, sql }>
|
|
171
|
+
|
|
172
|
+
app.get('/me', (_req, ctx) => {
|
|
173
|
+
ctx.csrfToken // ✅ string (IDE autocomplete)
|
|
174
|
+
ctx.requestId // ✅ string
|
|
175
|
+
ctx.sql`SELECT 1` // ✅ Sql<{}>
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
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.
|
|
128
180
|
|
|
129
181
|
---
|
|
130
182
|
|
|
@@ -165,6 +217,123 @@ app.use('/', a) // dashboard
|
|
|
165
217
|
|
|
166
218
|
---
|
|
167
219
|
|
|
220
|
+
## Request Tracing & Logging
|
|
221
|
+
|
|
222
|
+
Every request gets a **trace ID** via `AsyncLocalStorage`, injected into responses as `X-Trace-Id`. W3C `traceparent` headers are forwarded.
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
import { currentTraceId } from 'weifuwu'
|
|
226
|
+
|
|
227
|
+
app.get('/api', (req, ctx) => {
|
|
228
|
+
console.log('Handling request', currentTraceId()) // f240a3f3-60e2-...
|
|
229
|
+
})
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Structured logging** — `logger({ format: 'json' })` outputs JSON to stderr with `traceId`, `timestamp`, `elapsed_ms`:
|
|
233
|
+
|
|
234
|
+
```json
|
|
235
|
+
{"level":"info","message":"request","method":"GET","path":"/api/users","status":200,"elapsed_ms":42,"traceId":"f240a3f3-...","timestamp":"2025-01-15T10:30:00.000Z"}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Default format is `'short'` (human-readable). `'combined'` includes query strings.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## AI Observability
|
|
243
|
+
|
|
244
|
+
Agent runs are **automatically logged** to `_agent_runs`. Dashboard endpoints provide analytics:
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
GET /agents/:id/runs?days=7 → [{ input, output, tokens_in, tokens_out, elapsed_ms, status, trace_id, ... }]
|
|
248
|
+
GET /agents/:id/runs/summary?days=7 → { total, success, error, success_rate, tokens_in, tokens_out, avg_elapsed_ms, p95_elapsed_ms }
|
|
249
|
+
GET /opencode/sessions/:id/usage → { message_count, tokens_in, tokens_out, tokens_total }
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Non-streaming runs log full token data; streaming runs log `status: 'stream'`.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Agent ↔ Messager Streaming
|
|
257
|
+
|
|
258
|
+
Agent replies in messager channels now stream **token-by-token** via WebSocket:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
// Backend — automatic when agents are attached to messager
|
|
262
|
+
const msg = messager({ pg, agents: agent({ pg, model }) })
|
|
263
|
+
app.ws('/ws', msg.wsHandler())
|
|
264
|
+
// Agent replies stream to: hub.broadcast({ type: 'agent_stream', data: { token, full } })
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
// Frontend — React hook
|
|
269
|
+
import { useAgentStream } from 'weifuwu/react'
|
|
270
|
+
|
|
271
|
+
const { getAgentText, isAgentStreaming, stream } = useAgentStream({
|
|
272
|
+
wsPath: '/ws',
|
|
273
|
+
channelId: 1,
|
|
274
|
+
})
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Multi-round conversation context: the last 10 channel messages are automatically injected into agent calls.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Test Utilities
|
|
282
|
+
|
|
283
|
+
Chainable test helper for HTTP-level testing without starting a server:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { testApp } from 'weifuwu'
|
|
287
|
+
|
|
288
|
+
const app = testApp()
|
|
289
|
+
app.use(postgres({ connection: TEST_DB }))
|
|
290
|
+
app.get('/users/:id', (req, ctx) => Response.json({ id: ctx.params.id, user: ctx.user }))
|
|
291
|
+
|
|
292
|
+
const res = await app
|
|
293
|
+
.getReq('/users/42?name=Alice')
|
|
294
|
+
.withUser({ id: 1 })
|
|
295
|
+
.header('X-Custom', 'val')
|
|
296
|
+
.body({ data: 'test' })
|
|
297
|
+
.send()
|
|
298
|
+
|
|
299
|
+
assert.equal(res.status, 200)
|
|
300
|
+
assert.deepEqual(await res.json(), { id: '42', user: { id: 1 } })
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
| Method | Description |
|
|
304
|
+
|--------|-------------|
|
|
305
|
+
| `app.getReq(path)` `postReq` `putReq` `patchReq` `deleteReq` | Start building a request |
|
|
306
|
+
| `.withUser(u)` `.withTenant(t)` `.with(ctx)` | Simulate middleware injection |
|
|
307
|
+
| `.header(k,v)` `.body(data)` `.rawBody(str)` | Set request properties |
|
|
308
|
+
| `.send()` → `TestResponse` | Execute and get `{ status, headers, json(), text() }` |
|
|
309
|
+
|
|
310
|
+
### Database test isolation
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
import { createTestDb, withTestDb } from 'weifuwu'
|
|
314
|
+
|
|
315
|
+
// Isolated schema — each test gets its own schema, destroyed after
|
|
316
|
+
const db = await createTestDb()
|
|
317
|
+
await db.sql`CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)`
|
|
318
|
+
await db.sql`INSERT INTO users (name) VALUES ('Alice')`
|
|
319
|
+
await db.destroy() // DROP SCHEMA ... CASCADE
|
|
320
|
+
|
|
321
|
+
// Transaction rollback — all changes are rolled back after callback
|
|
322
|
+
await withTestDb(async (sql) => {
|
|
323
|
+
await sql`INSERT INTO users ...`
|
|
324
|
+
// Automatically rolled back
|
|
325
|
+
})
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
| Function | Description |
|
|
329
|
+
|----------|-------------|
|
|
330
|
+
| `createTestDb(opts?)` | Create isolated schema, returns `{ sql, url, schema, destroy }` |
|
|
331
|
+
| `withTestDb(url?, fn)` | Run callback in a transaction, auto-rollback |
|
|
332
|
+
|
|
333
|
+
Uses `TEST_DATABASE_URL` or `DATABASE_URL`. Automatically skipped in CI if unset.
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
168
337
|
## Module Reference
|
|
169
338
|
|
|
170
339
|
### agent [β]
|
|
@@ -274,6 +443,46 @@ app.use(cors({ credentials: true, maxAge: 3600 }))
|
|
|
274
443
|
| `credentials` | `boolean` | `false` | Allow cookies/credentials |
|
|
275
444
|
| `maxAge` | `number` | — | Preflight cache duration (seconds) |
|
|
276
445
|
|
|
446
|
+
### cache [α]
|
|
447
|
+
|
|
448
|
+
Response caching middleware with memory and Redis stores. Caches GET/HEAD responses, with tag-based invalidation.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
app.use(cache()) // in-memory, 5min TTL
|
|
452
|
+
app.use(cache({ ttl: 60_000, store: 'redis', redis: ctx.redis })) // Redis store
|
|
453
|
+
app.use(cache({
|
|
454
|
+
ttl: 30_000,
|
|
455
|
+
tag: (req, ctx) => ctx.user ? `user:${ctx.user.id}` : undefined, // per-user invalidation
|
|
456
|
+
}))
|
|
457
|
+
|
|
458
|
+
// Programmatic invalidation
|
|
459
|
+
const c = cache({ store: 'redis', redis: ctx.redis })
|
|
460
|
+
app.use(c)
|
|
461
|
+
await c.invalidate('users') // invalidate all entries tagged with 'users'
|
|
462
|
+
await c.flush() // clear entire cache
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
| Option | Type | Default | Description |
|
|
466
|
+
|--------|------|---------|-------------|
|
|
467
|
+
| `ttl` | `number` | `300000` (5min) | Cache TTL in ms |
|
|
468
|
+
| `store` | `'memory' \| 'redis' \| CacheStore` | `'memory'` | Cache store backend |
|
|
469
|
+
| `redis` | `Redis` | — | Redis client (required when `store: 'redis'`) |
|
|
470
|
+
| `key` | `(req) => string` | SHA256(method+URL) | Custom cache key |
|
|
471
|
+
| `tag` | `(req, ctx) => string \| string[]` | — | Tag for grouped invalidation |
|
|
472
|
+
| `cacheCookies` | `boolean` | `false` | Cache responses with Set-Cookie |
|
|
473
|
+
| `cacheStatus` | `number[]` | `[200]` | Status codes to cache |
|
|
474
|
+
| `maxBodySize` | `number` | `1048576` (1MB) | Max body bytes to cache |
|
|
475
|
+
|
|
476
|
+
Cached responses include `X-Cache: HIT` and `Age` headers. Requests with `Authorization` or `Cookie` headers are never cached. Binary content types (image, audio, video) are skipped.
|
|
477
|
+
|
|
478
|
+
```ts
|
|
479
|
+
import { MemoryCache, RedisCache } from 'weifuwu'
|
|
480
|
+
|
|
481
|
+
const mem = new MemoryCache()
|
|
482
|
+
await mem.set('key', { status: 200, statusText: 'OK', headers: {}, body: '...', createdAt: Date.now(), tags: [] }, 300_000)
|
|
483
|
+
mem.close()
|
|
484
|
+
```
|
|
485
|
+
|
|
277
486
|
### csrf [α]
|
|
278
487
|
|
|
279
488
|
```ts
|
|
@@ -398,6 +607,29 @@ Restart=always
|
|
|
398
607
|
|
|
399
608
|
|
|
400
609
|
|
|
610
|
+
### graphql [β]
|
|
611
|
+
|
|
612
|
+
```ts
|
|
613
|
+
const handler: GraphQLHandler = () => ({
|
|
614
|
+
schema: `type Query { hello: String }`,
|
|
615
|
+
resolvers: { Query: { hello: () => 'world' } },
|
|
616
|
+
graphiql: true, // GET / returns GraphiQL IDE
|
|
617
|
+
maxDepth: 10, // max query nesting (default 10, 0 = disable)
|
|
618
|
+
timeout: 30_000, // execution timeout in ms
|
|
619
|
+
})
|
|
620
|
+
app.use('/graphql', graphql(handler))
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
| Option | Type | Default | Description |
|
|
624
|
+
|--------|------|---------|-------------|
|
|
625
|
+
| `schema` | `string \| GraphQLSchema` | — | SDL string or pre-built schema |
|
|
626
|
+
| `resolvers` | `object` | — | Resolver map |
|
|
627
|
+
| `rootValue` | `any` | — | Root value for queries |
|
|
628
|
+
| `context` | `(req, ctx) => object` | — | Per-request context factory |
|
|
629
|
+
| `graphiql` | `boolean` | `false` | Serve GraphiQL IDE at GET / |
|
|
630
|
+
| `maxDepth` | `number` | `10` | Max query nesting depth |
|
|
631
|
+
| `timeout` | `number` | `30_000` | Execution timeout (ms) |
|
|
632
|
+
|
|
401
633
|
### health [β]
|
|
402
634
|
|
|
403
635
|
```ts
|
|
@@ -584,52 +816,70 @@ app.use(pg) // injects ctx.sql
|
|
|
584
816
|
| `ssl` | `boolean\|object` | — | SSL options |
|
|
585
817
|
| `idle_timeout` | `number` | `30` | Idle timeout (seconds) |
|
|
586
818
|
| `connect_timeout` | `number` | `30` | Connection timeout |
|
|
819
|
+
| `statementTimeout` | `number` | `30_000` | Per-statement timeout (ms, 0 = disable) |
|
|
820
|
+
| `onQuery` | `(query, ms, rows) => void` | — | Query logging callback |
|
|
587
821
|
|
|
588
822
|
```ts
|
|
589
823
|
// Raw SQL via tagged template
|
|
590
824
|
await pg.sql`SELECT * FROM users WHERE email = ${email}`
|
|
591
825
|
|
|
592
|
-
//
|
|
593
|
-
import {
|
|
826
|
+
// Define a table — one API, sql pre-bound
|
|
827
|
+
import { serial, text, boolean, timestamps } from 'weifuwu'
|
|
594
828
|
|
|
595
|
-
const users =
|
|
829
|
+
const users = pg.table('_users', {
|
|
596
830
|
id: serial('id').primaryKey(),
|
|
597
831
|
name: text('name').notNull(),
|
|
598
832
|
email: text('email').unique().notNull(),
|
|
599
833
|
active: boolean('active').default(true),
|
|
600
834
|
...timestamps(),
|
|
601
835
|
})
|
|
602
|
-
await users.create(
|
|
603
|
-
await users.createIndex(
|
|
604
|
-
|
|
605
|
-
//
|
|
606
|
-
|
|
607
|
-
await
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
836
|
+
await users.create() // DDL — no need to pass sql
|
|
837
|
+
await users.createIndex('email')
|
|
838
|
+
|
|
839
|
+
// CRUD — sql already bound
|
|
840
|
+
await users.insert({ name: 'Alice' })
|
|
841
|
+
const { count, data } = await users.readMany({ role: 'admin' }, { orderBy: { name: 'asc' }, limit: 10 })
|
|
842
|
+
await users.upsert({ email: 'alice@test.com' }, 'email')
|
|
843
|
+
|
|
844
|
+
// Reuse schema without redefining fields
|
|
845
|
+
import { pgTable } from 'weifuwu'
|
|
846
|
+
const usersSchema = pgTable('_users', { id: serial('id'), name: text('name') }) // define once
|
|
847
|
+
const users = pg.table(usersSchema) // bind — no field duplication
|
|
848
|
+
|
|
849
|
+
// Transactions — with auto-retry on deadlock/serialization failure
|
|
615
850
|
await pg.transaction(async (sql) => {
|
|
616
|
-
const
|
|
617
|
-
return
|
|
618
|
-
})
|
|
851
|
+
const txUsers = users.withSql(sql)
|
|
852
|
+
return txUsers.insert({ name: 'Bob' })
|
|
853
|
+
}, { maxRetries: 3 })
|
|
619
854
|
|
|
620
855
|
// Soft delete — automatic if deleted_at column exists
|
|
621
|
-
await
|
|
622
|
-
await
|
|
623
|
-
await
|
|
856
|
+
await users.delete(1) // SET deleted_at = NOW()
|
|
857
|
+
await users.hardDelete(1) // DELETE FROM
|
|
858
|
+
await users.read(1) // auto-filters deleted_at IS NULL (use withDeleted: true to include)
|
|
624
859
|
|
|
625
860
|
// JSONB queries
|
|
626
861
|
const logs = pg.table('logs', { meta: jsonb<{ service: string }>('meta') })
|
|
627
862
|
await logs.readMany(contains('meta', { service: 'auth' }))
|
|
628
863
|
|
|
864
|
+
// Connection pool visibility
|
|
865
|
+
console.log(pg.poolStats()) // { active: 3, idle: 7, waiting: 0, max: 10 }
|
|
866
|
+
|
|
867
|
+
// Migration tracking
|
|
868
|
+
await pg.migrate() // creates _weifuwu_migrations
|
|
869
|
+
await pg.markMigrated('myModule') // idempotent
|
|
870
|
+
const done = await pg.isMigrated('myModule')
|
|
871
|
+
|
|
629
872
|
// Partitioned tables
|
|
630
873
|
await logs.create({ partitionBy: partitionBy('range', 'created_at') })
|
|
631
874
|
```
|
|
632
875
|
|
|
876
|
+
**When to use pgTable vs pg.table:**
|
|
877
|
+
| API | Use when |
|
|
878
|
+
|-----|---------|
|
|
879
|
+
| `pg.table('t', cols)` | You have `pg` available (factory, handler, migrate) |
|
|
880
|
+
| `pg.table(schema)` | Reusing a schema without duplicating field definitions |
|
|
881
|
+
| `pgTable('t', cols)` | No `pg` reference (utility modules, standalone schema files) |
|
|
882
|
+
|
|
633
883
|
| Column builder | Type | Notes |
|
|
634
884
|
|---------------|------|-------|
|
|
635
885
|
| `serial(name)` | `number` | Auto-increment |
|
|
@@ -644,27 +894,27 @@ await logs.create({ partitionBy: partitionBy('range', 'created_at') })
|
|
|
644
894
|
|
|
645
895
|
**Column modifiers:** `.primaryKey()`, `.notNull()`, `.nullable()`, `.default(val)`, `.unique()`, `.references(table, column?, onDelete?)`.
|
|
646
896
|
|
|
647
|
-
**
|
|
897
|
+
**CRUD methods:**
|
|
648
898
|
|
|
649
899
|
| Method | Description |
|
|
650
900
|
|--------|-------------|
|
|
651
901
|
| `insert(data)` | INSERT + RETURNING \*, returns the inserted row |
|
|
652
902
|
| `insertMany(data)` | Bulk INSERT + RETURNING \*, returns rows |
|
|
653
|
-
| `read(id, opts?)` | SELECT by primary key
|
|
903
|
+
| `read(id, opts?)` | SELECT by detected primary key + auto soft-delete filter |
|
|
654
904
|
| `readMany(where?, opts?)` | Filtered query with `{ count, data }` — auto-filters soft-deleted |
|
|
655
|
-
| `update(id, data)` | UPDATE by
|
|
905
|
+
| `update(id, data)` | UPDATE by primary key + RETURNING \*, returns updated row |
|
|
656
906
|
| `updateMany(where, data)` | Bulk UPDATE, returns affected row count |
|
|
657
907
|
| `delete(id)` | Soft delete if `deleted_at` exists, else hard delete |
|
|
658
908
|
| `hardDelete(id)` | Always DELETE FROM |
|
|
659
909
|
| `deleteMany(where)` | Soft bulk delete if `deleted_at` exists |
|
|
660
910
|
| `hardDeleteMany(where)` | Always DELETE FROM |
|
|
661
911
|
| `upsert(data, conflict)` | INSERT ON CONFLICT DO UPDATE, returns row |
|
|
662
|
-
| `count(where?)` | SELECT COUNT(\*) |
|
|
912
|
+
| `count(where?)` | SELECT COUNT(\*) — auto-filters soft-deleted |
|
|
663
913
|
| `create(opts?)` | CREATE TABLE IF NOT EXISTS |
|
|
664
914
|
| `drop(opts?)` | DROP TABLE IF EXISTS |
|
|
665
915
|
| `createIndex(columns, opts?)` | CREATE INDEX |
|
|
666
916
|
| `createUniqueIndex(columns)` | CREATE UNIQUE INDEX |
|
|
667
|
-
| `withSql(sql)` | Returns
|
|
917
|
+
| `withSql(sql)` | Returns copy bound to a different sql (for transactions) |
|
|
668
918
|
|
|
669
919
|
**Where helpers** — composable query conditions:
|
|
670
920
|
|
|
@@ -690,6 +940,42 @@ class MyModule extends PgModule {
|
|
|
690
940
|
|
|
691
941
|
Where helpers + `and`/`or`/`not` can be imported from `'weifuwu'` alongside `postgres`. Full column builders and table helpers are in the same barrel.
|
|
692
942
|
|
|
943
|
+
### fts — Full-Text Search (PostgreSQL)
|
|
944
|
+
|
|
945
|
+
Utilities for PostgreSQL full-text search: create GIN indexes, search with ranking, and generate highlighted snippets.
|
|
946
|
+
|
|
947
|
+
```ts
|
|
948
|
+
import { fts } from 'weifuwu'
|
|
949
|
+
|
|
950
|
+
const articles = pg.table('articles', {
|
|
951
|
+
id: serial('id').primaryKey(),
|
|
952
|
+
title: text('title'),
|
|
953
|
+
body: text('body'),
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
// Create search index
|
|
957
|
+
await fts.createIndex(pg.sql, articles, ['title', 'body'], { language: 'english' })
|
|
958
|
+
|
|
959
|
+
// Search with ranking
|
|
960
|
+
const results = await fts.search(pg.sql, articles, 'node.js framework', {
|
|
961
|
+
fields: ['title', 'body'],
|
|
962
|
+
limit: 20,
|
|
963
|
+
headline: true, // highlighted snippets via ts_headline
|
|
964
|
+
})
|
|
965
|
+
// → [{ id, rank: 0.8, row: { title, body, ... }, headline: '...<b>Node.js</b> framework...' }]
|
|
966
|
+
|
|
967
|
+
// Drop index
|
|
968
|
+
await fts.dropIndex(pg.sql, articles)
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
| Function | Description |
|
|
972
|
+
|----------|-------------|
|
|
973
|
+
| `createIndex(sql, table, fields, opts?)` | Create GIN/GiST tsvector index |
|
|
974
|
+
| `search(sql, table, query, opts?)` | Search with ts_rank ordering |
|
|
975
|
+
| `dropIndex(sql, table, opts?)` | Drop the index |
|
|
976
|
+
|
|
977
|
+
Search options: `fields`, `limit` (20), `offset` (0), `headline` (false), `language` ('english'), `minRank`.
|
|
978
|
+
|
|
693
979
|
### preferences [α]
|
|
694
980
|
|
|
695
981
|
Locale detection + theme + translations. `/__lang/:locale` and `/__theme/:theme` auto-routed.
|
|
@@ -742,16 +1028,34 @@ await q.add('send-email', { to: 'user@test.com' }, { cron: '0 8 * * *' })
|
|
|
742
1028
|
| `.process(handler)` | Register job processor |
|
|
743
1029
|
| `.run()` | Start processing |
|
|
744
1030
|
| `.stop()` | Stop processing |
|
|
1031
|
+
| `.jobs(limit?)` | List pending jobs |
|
|
1032
|
+
| `.failedJobs(limit?)` | List failed jobs with error messages |
|
|
1033
|
+
| `.retryFailed(jobId)` | Retry a specific failed job |
|
|
1034
|
+
| `.retryAllFailed(type?)` | Retry all failed jobs (optionally by type) |
|
|
1035
|
+
| `.dashboard()` | Returns a Router with management endpoints |
|
|
745
1036
|
| `.close()` | Cleanup |
|
|
746
1037
|
|
|
1038
|
+
**Dashboard endpoints** (mount via `app.use('/__queue', q.dashboard())`):
|
|
1039
|
+
|
|
1040
|
+
| Method | Path | Description |
|
|
1041
|
+
|--------|------|-------------|
|
|
1042
|
+
| GET | `/` | Queue stats + pending/failed counts by type |
|
|
1043
|
+
| GET | `/:type/failed` | List failed jobs for a type |
|
|
1044
|
+
| POST | `/:type/retry` | Retry all failed jobs of a type |
|
|
1045
|
+
| POST | `/retry/:id` | Retry a specific failed job by ID |
|
|
1046
|
+
|
|
747
1047
|
### rateLimit [α]
|
|
748
1048
|
|
|
749
1049
|
```ts
|
|
750
|
-
app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min
|
|
1050
|
+
app.use(rateLimit({ max: 100, window: 60_000 })) // 100 req/min, in-memory
|
|
751
1051
|
app.get('/api', rateLimit({ max: 10 }), handler) // per-route
|
|
752
1052
|
app.use(rateLimit({ key: (req) => req.headers.get('x-api-key') ?? 'anonymous' }))
|
|
1053
|
+
|
|
1054
|
+
// Multi-process: Redis-backed rate limiting
|
|
1055
|
+
app.use(rateLimit({ max: 100, store: 'redis', redis: ctx.redis }))
|
|
1056
|
+
|
|
753
1057
|
// Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After headers
|
|
754
|
-
// m.stop() — clear interval
|
|
1058
|
+
// m.stop() — clear interval (memory) or Redis cleanup
|
|
755
1059
|
```
|
|
756
1060
|
|
|
757
1061
|
| Option | Type | Default | Description |
|
|
@@ -760,6 +1064,11 @@ app.use(rateLimit({ key: (req) => req.headers.get('x-api-key') ?? 'anonymous' })
|
|
|
760
1064
|
| `window` | `number` | `60_000` | Window duration (ms) |
|
|
761
1065
|
| `key` | `(req) => string` | IP-based | Key function |
|
|
762
1066
|
| `message` | `string` | `'Too Many Requests'` | 429 response body |
|
|
1067
|
+
| `store` | `'memory' \| 'redis'` | `'memory'` | Backend store |
|
|
1068
|
+
| `redis` | `Redis` | — | Redis client (required when `store: 'redis'`) |
|
|
1069
|
+
| `prefix` | `string` | `'ratelimit:'` | Redis key prefix |
|
|
1070
|
+
|
|
1071
|
+
Redis mode uses `INCR` + `EXPIRE` for atomic counting, enabling accurate rate limiting across multiple server processes. Memory mode is ideal for single-process deployments.
|
|
763
1072
|
|
|
764
1073
|
### redis [α]
|
|
765
1074
|
|
|
@@ -808,6 +1117,58 @@ Also exports `seoTags(config)` for generating meta/og/twitter tags as an HTML st
|
|
|
808
1117
|
| `sitemap` | `SitemapConfig` | — | Sitemap configuration (urls, resolve, cacheTTL) |
|
|
809
1118
|
| `headers` | `SeoHeadersConfig` | — | Response headers (e.g. `X-Robots-Tag`) |
|
|
810
1119
|
|
|
1120
|
+
### session [α]
|
|
1121
|
+
|
|
1122
|
+
Cookie-based server-side session management with memory and Redis stores.
|
|
1123
|
+
|
|
1124
|
+
```ts
|
|
1125
|
+
app.use(session()) // in-memory store (default)
|
|
1126
|
+
app.use(session({ store: 'redis', redis: ctx.redis })) // Redis store
|
|
1127
|
+
app.use(session({ store: 'redis', redis, ttl: 30 * 60_000, cookieName: 'sid' }))
|
|
1128
|
+
|
|
1129
|
+
app.get('/login', async (req, ctx) => {
|
|
1130
|
+
ctx.session.userId = 42
|
|
1131
|
+
ctx.session.role = 'admin'
|
|
1132
|
+
// Auto-saved on response — cookie set automatically
|
|
1133
|
+
return Response.json({ ok: true })
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
app.get('/logout', async (req, ctx) => {
|
|
1137
|
+
ctx.session.destroy() // or ctx.session = null
|
|
1138
|
+
return Response.json({ ok: true })
|
|
1139
|
+
})
|
|
1140
|
+
|
|
1141
|
+
// ctx.session.id — readonly session ID
|
|
1142
|
+
// ctx.session.save() — explicit dirty mark (for deep mutations)
|
|
1143
|
+
// ctx.session.destroy() — clear session + remove cookie
|
|
1144
|
+
// Session mutations are auto-detected on property set/delete
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
| Option | Type | Default | Description |
|
|
1148
|
+
|--------|------|---------|-------------|
|
|
1149
|
+
| `store` | `'memory' \| 'redis' \| SessionStore` | `'memory'` | Session store backend |
|
|
1150
|
+
| `redis` | `Redis` | — | Redis client (required when `store: 'redis'`) |
|
|
1151
|
+
| `ttl` | `number` | `86400000` (24h) | Session TTL in ms |
|
|
1152
|
+
| `cookieName` | `string` | `'__session'` | Cookie name |
|
|
1153
|
+
| `cookie.httpOnly` | `boolean` | `true` | Cookie httpOnly flag |
|
|
1154
|
+
| `cookie.secure` | `boolean` | `auto` | Cookie Secure flag (true in production) |
|
|
1155
|
+
| `cookie.sameSite` | `string` | `'lax'` | SameSite policy |
|
|
1156
|
+
| `cookie.path` | `string` | `'/'` | Cookie path |
|
|
1157
|
+
| `cookie.domain` | `string` | — | Cookie domain |
|
|
1158
|
+
|
|
1159
|
+
**Stores** are also exported for standalone use:
|
|
1160
|
+
|
|
1161
|
+
```ts
|
|
1162
|
+
import { MemoryStore, RedisStore } from 'weifuwu'
|
|
1163
|
+
|
|
1164
|
+
const mem = new MemoryStore() // auto-cleanup every 60s
|
|
1165
|
+
await mem.set('sid', { userId: 1 }, 86400000)
|
|
1166
|
+
mem.close()
|
|
1167
|
+
|
|
1168
|
+
const redis = new RedisStore(redisClient, 'myapp:session:')
|
|
1169
|
+
await redis.destroy('sid')
|
|
1170
|
+
```
|
|
1171
|
+
|
|
811
1172
|
### ssr({ dir }) [β]
|
|
812
1173
|
|
|
813
1174
|
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.
|
|
@@ -938,7 +1299,67 @@ app.post('/users', validate({ body: CreateUser, query: z.object({ ref: z.string(
|
|
|
938
1299
|
// ctx.parsed.headers — typed & validated
|
|
939
1300
|
})
|
|
940
1301
|
// Validation failure: returns 400 with { error: 'Validation failed', issues: [...] }
|
|
1302
|
+
|
|
1303
|
+
**Form body auto-parsing** — `application/x-www-form-urlencoded` bodies are automatically parsed into `Record<string, string>` via `URLSearchParams`, even without a Zod schema:
|
|
1304
|
+
|
|
1305
|
+
```ts
|
|
1306
|
+
// No schema needed — just parse the form
|
|
1307
|
+
app.post('/contact', validate(), (req, ctx) => {
|
|
1308
|
+
const email = ctx.parsed.body.email // string
|
|
1309
|
+
const msg = ctx.parsed.body.message // string
|
|
1310
|
+
return Response.json({ received: true })
|
|
1311
|
+
})
|
|
1312
|
+
|
|
1313
|
+
// Or validate with Zod
|
|
1314
|
+
app.post('/contact', validate({ body: z.object({ email: z.string().email() }) }), handler)
|
|
941
1315
|
```
|
|
1316
|
+
|
|
1317
|
+
| Option | Type | Default | Description |
|
|
1318
|
+
|--------|------|---------|-------------|
|
|
1319
|
+
| `body` | `ZodSchema` | — | Body validation schema (omit to skip) |
|
|
1320
|
+
| `query` | `ZodSchema` | — | Query validation schema |
|
|
1321
|
+
| `params` | `ZodSchema` | — | URL params validation schema |
|
|
1322
|
+
| `headers` | `ZodSchema` | — | Header validation schema |
|
|
1323
|
+
|
|
1324
|
+
### webhook [β]
|
|
1325
|
+
|
|
1326
|
+
Webhook receiver with built-in signature verification for Stripe, GitHub, and Slack. Event-based dispatch with replay protection.
|
|
1327
|
+
|
|
1328
|
+
```ts
|
|
1329
|
+
import { webhook } from 'weifuwu'
|
|
1330
|
+
|
|
1331
|
+
const wh = webhook({
|
|
1332
|
+
stripe: { secret: process.env.STRIPE_WEBHOOK_SECRET! },
|
|
1333
|
+
github: { secret: process.env.GITHUB_WEBHOOK_SECRET! },
|
|
1334
|
+
slack: { secret: process.env.SLACK_WEBHOOK_SECRET! },
|
|
1335
|
+
})
|
|
1336
|
+
|
|
1337
|
+
app.use('/webhooks', wh)
|
|
1338
|
+
|
|
1339
|
+
wh.on('checkout.session.completed', async (event, ctx) => {
|
|
1340
|
+
await fulfillOrder(event.payload.data.object)
|
|
1341
|
+
})
|
|
1342
|
+
|
|
1343
|
+
wh.on('push', async (event, ctx) => {
|
|
1344
|
+
await triggerCI(event.payload)
|
|
1345
|
+
})
|
|
1346
|
+
|
|
1347
|
+
wh.on('*', (event) => {
|
|
1348
|
+
console.log(`Received ${event.provider}.${event.event}`)
|
|
1349
|
+
})
|
|
1350
|
+
```
|
|
1351
|
+
|
|
1352
|
+
| Option | Type | Default | Description |
|
|
1353
|
+
|--------|------|---------|-------------|
|
|
1354
|
+
| `stripe` | `PlatformConfig` | — | Stripe webhook config with `secret` |
|
|
1355
|
+
| `github` | `PlatformConfig` | — | GitHub webhook config |
|
|
1356
|
+
| `slack` | `PlatformConfig` | — | Slack webhook config |
|
|
1357
|
+
| `custom` | `CustomVerifierConfig[]` | — | Custom signature verifiers |
|
|
1358
|
+
| `replayProtection` | `boolean` | `true` | Deduplicate by event ID |
|
|
1359
|
+
| `idempotencyTTL` | `number` | `3600000` | Dedup TTL (ms) |
|
|
1360
|
+
|
|
1361
|
+
Built-in verifiers handle HMAC-SHA256, timestamp validation (Slack's 5-min window), and Stripe's `t=` / `v1=` signature format. Slack URL verification challenges are auto-responded.
|
|
1362
|
+
|
|
942
1363
|
### Client-side navigation
|
|
943
1364
|
|
|
944
1365
|
```tsx
|
|
@@ -1120,14 +1541,16 @@ Every public symbol can be imported from `'weifuwu'`:
|
|
|
1120
1541
|
```ts
|
|
1121
1542
|
serve, createTestServer, Router, ssr,
|
|
1122
1543
|
Context, Handler, Middleware, ErrorHandler, ServeOptions, Server,
|
|
1123
|
-
loadEnv
|
|
1544
|
+
loadEnv, testApp, TestApp, TestRequest, TestResponse,
|
|
1545
|
+
currentTraceId, currentTrace, runWithTrace, traceElapsed, TraceContext,
|
|
1124
1546
|
```
|
|
1125
1547
|
|
|
1126
1548
|
### Middleware modules
|
|
1127
1549
|
|
|
1128
1550
|
```ts
|
|
1129
1551
|
auth, cors, csrf, compress, helmet, logger, rateLimit, requestId, validate, upload,
|
|
1130
|
-
preferences, serveStatic
|
|
1552
|
+
preferences, serveStatic, session, MemoryStore, RedisStore, SessionStore,
|
|
1553
|
+
cache, MemoryCache, RedisCache, CacheStore
|
|
1131
1554
|
```
|
|
1132
1555
|
|
|
1133
1556
|
### Database
|
|
@@ -1136,6 +1559,7 @@ preferences, serveStatic
|
|
|
1136
1559
|
postgres, PostgresOptions, PostgresClient,
|
|
1137
1560
|
redis, RedisOptions, RedisClient,
|
|
1138
1561
|
queue, QueueOptions, QueueJob, Queue,
|
|
1562
|
+
PostgresInjected, RedisInjected, QueueInjected,
|
|
1139
1563
|
// Schema helpers — importable alongside postgres:
|
|
1140
1564
|
pgTable, SQL, sql,
|
|
1141
1565
|
ColumnBuilder, serial, uuid, text, integer, boolean, boolean_, timestamptz, jsonb, textArray, vector,
|
|
@@ -1151,8 +1575,10 @@ TsxContext, useLoaderData,
|
|
|
1151
1575
|
useWebsocket, useAction, useFetch, useQueryState, createStore,
|
|
1152
1576
|
Link, useNavigate, useNavigating, addInterceptor,
|
|
1153
1577
|
useLocale, useTheme, applyTheme, useFlashMessage,
|
|
1578
|
+
useAgentStream,
|
|
1154
1579
|
Head
|
|
1155
1580
|
```
|
|
1581
|
+
export type { UseAgentStreamOptions, UseAgentStreamReturn, AgentStreamState } from 'weifuwu/react'
|
|
1156
1582
|
|
|
1157
1583
|
### AI SDK (re-exported from `ai`)
|
|
1158
1584
|
|
|
@@ -1168,9 +1594,15 @@ openai, createOpenAI
|
|
|
1168
1594
|
preferences, health, analytics, seo, seoMiddleware, seoTags,
|
|
1169
1595
|
user, mailer, graphql, aiStream, runWorkflow,
|
|
1170
1596
|
logdb, messager, agent, iii, createWorker, registerWorker,
|
|
1171
|
-
opencode, deploy, defineConfig,
|
|
1597
|
+
opencode, deploy, defineConfig, webhook,
|
|
1598
|
+
testApp, TestApp, TestRequest, TestResponse,
|
|
1599
|
+
createTestDb, withTestDb,
|
|
1172
1600
|
getCookies, setCookie, deleteCookie,
|
|
1173
|
-
createSSEStream, formatSSE, formatSSEData
|
|
1601
|
+
createSSEStream, formatSSE, formatSSEData,
|
|
1602
|
+
currentTraceId, currentTrace, runWithTrace, traceElapsed,
|
|
1603
|
+
createHub, Hub, HubOptions,
|
|
1604
|
+
DEFAULT_MAX_BODY, MIGRATIONS_TABLE,
|
|
1605
|
+
fts,
|
|
1174
1606
|
```
|
|
1175
1607
|
|
|
1176
1608
|
---
|