weifuwu 0.19.4 → 0.19.6
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 +351 -174
- package/cli/template/app.ts +2 -3
- package/cli/template/index.ts +1 -5
- package/cli.ts +1 -2
- package/dist/cli.js +1 -1
- package/dist/deploy/types.d.ts +5 -14
- package/dist/index.d.ts +1 -2
- package/dist/index.js +326 -366
- package/dist/live.d.ts +1 -3
- package/dist/root-layout.d.ts +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,12 +15,11 @@ serve((req, ctx) => new Response('Hello, World!'), { port: 3000 })
|
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
|
-
import { serve, Router, preferences, ssr,
|
|
18
|
+
import { serve, Router, preferences, ssr, rootLayout } from 'weifuwu'
|
|
19
19
|
const app = new Router()
|
|
20
20
|
app.use(preferences({ dir: './locales' }))
|
|
21
|
-
app.use(
|
|
22
|
-
app.get('/', ssr('./pages/home.tsx'))
|
|
23
|
-
app.use(liveReload({ dirs: ['./pages', './layouts'] }))
|
|
21
|
+
app.use(rootLayout('./ui'))
|
|
22
|
+
app.get('/', ssr('./ui/pages/home.tsx'))
|
|
24
23
|
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
25
24
|
```
|
|
26
25
|
|
|
@@ -77,6 +76,23 @@ serve(handler, { port: 3000, websocket: wsHandler })
|
|
|
77
76
|
|
|
78
77
|
Query params → `ctx.query`.
|
|
79
78
|
|
|
79
|
+
### Request lifecycle
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
Request → serve() → app.handler() → global middleware × N → path middleware × N → route handler → Response
|
|
83
|
+
↑
|
|
84
|
+
mountPath set by sub-router
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
1. `serve()` receives HTTP request
|
|
88
|
+
2. `app.handler()` creates `ctx = { params, query }` and routes to the matching trie node
|
|
89
|
+
3. **Global middleware** runs in `use()` order (e.g. `preferences()`, `postgres()`, `cors()`)
|
|
90
|
+
4. **Path‑scoped middleware** runs for matching paths (e.g. `app.use('/admin', authMW)`)
|
|
91
|
+
5. **Route‑level middleware** runs (e.g. `app.get('/admin', validate(...), handler)`)
|
|
92
|
+
6. **Route handler** returns `Response` — middleware chain unwinds
|
|
93
|
+
|
|
94
|
+
Sub-routers (`app.use('/admin', adminRouter)`) are **flattened** into the parent trie. The sub-router's global middleware merges with the parent's. `ctx.mountPath` is set when entering a sub-router, allowing each module to derive its own paths.
|
|
95
|
+
|
|
80
96
|
### Middleware
|
|
81
97
|
|
|
82
98
|
```ts
|
|
@@ -86,6 +102,32 @@ app.use('/admin', mw) // path-scoped
|
|
|
86
102
|
app.get('/admin', mw, handler) // route-level
|
|
87
103
|
```
|
|
88
104
|
|
|
105
|
+
### Context
|
|
106
|
+
|
|
107
|
+
The `ctx` object accumulates properties as it passes through the middleware chain. Below are all documented properties:
|
|
108
|
+
|
|
109
|
+
| Property | Set by | Type | Description |
|
|
110
|
+
|----------|--------|------|-------------|
|
|
111
|
+
| `params` | Router | `Record<string, string>` | URL path parameters |
|
|
112
|
+
| `query` | Router | `Record<string, string>` | URL query parameters |
|
|
113
|
+
| `mountPath` | Router | `string` | Current sub-router mount prefix |
|
|
114
|
+
| `env` | `loadEnv()` | `Record<string, string>` | Public env vars (`WEIFUWU_PUBLIC_*`) |
|
|
115
|
+
| `csrfToken` | `csrf()` | `string` | CSRF token |
|
|
116
|
+
| `requestId` | `requestId()` | `string` | Request ID |
|
|
117
|
+
| `sql` | `postgres()` | `Sql<{}>` | PostgreSQL tagged-template client |
|
|
118
|
+
| `redis` | `redis()` | `Redis` | Redis client |
|
|
119
|
+
| `queue` | `queue()` | `Queue` | Job queue |
|
|
120
|
+
| `prefs` | `preferences()` | `{ locale, theme }` | User preferences (locale, theme) |
|
|
121
|
+
| `deploy` | `deploy()` | `{ appName? }` | Deploy gateway info |
|
|
122
|
+
| `layoutStack` | `rootLayout()` / `layout()` | `LayoutEntry[]` | React layout component stack |
|
|
123
|
+
| `loaderData` | User middleware | `Record<string, unknown>` | SSR data passed to client |
|
|
124
|
+
| `user` | `auth()` / `user().middleware()` | `{ id?: string }` | Authenticated user |
|
|
125
|
+
| `parsed` | `validate()` / `upload()` | `{ body, query, params, headers, files }` | Validated/parsed request data |
|
|
126
|
+
| `t` | `preferences()` | `(key) => string` | Translation function |
|
|
127
|
+
| `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) |
|
|
130
|
+
|
|
89
131
|
---
|
|
90
132
|
|
|
91
133
|
## Module Patterns
|
|
@@ -116,11 +158,6 @@ app.use('/auth', user({ pg, jwtSecret })) // with .middlew
|
|
|
116
158
|
app.ws('/ws', messager({ pg }).wsHandler())
|
|
117
159
|
```
|
|
118
160
|
|
|
119
|
-
β modules can also be mounted **without a path** — internal routes (`/__xxx`) are inaccessible to the user:
|
|
120
|
-
```ts
|
|
121
|
-
app.use(liveReload({ dirs: ['./pages'] })) // no path, /__weifuwu/livereload
|
|
122
|
-
```
|
|
123
|
-
|
|
124
161
|
β modules that need **separate middleware** use `.middleware()`:
|
|
125
162
|
```ts
|
|
126
163
|
const a = analytics()
|
|
@@ -255,21 +292,130 @@ app.use(csrf())
|
|
|
255
292
|
| `key` | `'_csrf'` | Body field fallback |
|
|
256
293
|
| `excludeMethods` | `['GET','HEAD','OPTIONS']` | Skip validation |
|
|
257
294
|
|
|
258
|
-
### deploy
|
|
295
|
+
### deploy [β]
|
|
296
|
+
|
|
297
|
+
Multi-process manager with reverse proxy, health checks, auto-restart, and zero-downtime updates. Works identically locally and in production.
|
|
259
298
|
|
|
260
299
|
```ts
|
|
261
300
|
import { deploy, defineConfig } from 'weifuwu'
|
|
262
|
-
|
|
301
|
+
|
|
302
|
+
// Local
|
|
303
|
+
await deploy(defineConfig({
|
|
304
|
+
apps: { blog: {}, api: {} },
|
|
305
|
+
}))
|
|
306
|
+
|
|
307
|
+
// Production
|
|
308
|
+
await deploy(defineConfig({
|
|
263
309
|
domain: 'example.com',
|
|
310
|
+
deployToken: process.env.DEPLOY_TOKEN,
|
|
311
|
+
apps: { blog: {}, api: {} },
|
|
312
|
+
}))
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
**Auto-derived defaults** — each app key derives `dir`, `port`, `entry`, and `path`:
|
|
316
|
+
|
|
317
|
+
| Field | Default | Rule |
|
|
318
|
+
|-------|---------|------|
|
|
319
|
+
| `dir` | App key | `blog` → `'./blog'` |
|
|
320
|
+
| `entry` | `'index.ts'` | Default entry file |
|
|
321
|
+
| `port` | `3001+` | Auto-incremented from 3001 |
|
|
322
|
+
| `path` | `'/key'` | Only for localhost domain |
|
|
323
|
+
|
|
324
|
+
Override any field explicitly:
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
defineConfig({
|
|
264
328
|
apps: {
|
|
265
|
-
|
|
329
|
+
blog: { dir: '../packages/blog', entry: 'server.ts', port: 8080, path: '/blog' },
|
|
266
330
|
},
|
|
267
331
|
})
|
|
268
|
-
const server = await deploy(config)
|
|
269
|
-
// server.close(), server.ready, server.url
|
|
270
|
-
// server.apps.list(), server.apps.status(name), server.apps.deploy(name)
|
|
271
332
|
```
|
|
272
333
|
|
|
334
|
+
**Routing** — match priority: explicit path > app key > defaultApp.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
apps: {
|
|
338
|
+
api: { path: '/api' }, // example.com/api or localhost:3000/api
|
|
339
|
+
blog: {}, // blog.example.com or localhost:3000/blog
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
**Blue-green** — zero-downtime via `ports`:
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
apps: { blog: { ports: [3001, 3002] } }
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**WebSocket** — automatically bridged through the gateway.
|
|
350
|
+
|
|
351
|
+
**Process watchdog** — auto-restarts with exponential backoff on unexpected exit.
|
|
352
|
+
|
|
353
|
+
**Management API** — all endpoints require `Authorization: Bearer <deployToken>`:
|
|
354
|
+
|
|
355
|
+
| Endpoint | Method | Description |
|
|
356
|
+
|----------|--------|-------------|
|
|
357
|
+
| `/_deploy/apps` | GET | List apps |
|
|
358
|
+
| `/_deploy/apps/:name` | GET | App details |
|
|
359
|
+
| `/_deploy/apps/:name/deploy` | POST | Restart |
|
|
360
|
+
| `/_deploy/apps/:name/restart` | POST | Restart |
|
|
361
|
+
| `/_deploy/apps/:name/stop` | POST | Stop |
|
|
362
|
+
| `/_deploy/apps/:name/start` | POST | Start |
|
|
363
|
+
| `/_deploy/apps/:name/logs` | GET | SSE log stream |
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
curl -H "Authorization: Bearer my-token" http://localhost:3000/_deploy/apps
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
**Running** — use systemd for production:
|
|
370
|
+
|
|
371
|
+
```ini
|
|
372
|
+
[Service]
|
|
373
|
+
WorkingDirectory=/opt/deploy
|
|
374
|
+
ExecStart=/usr/bin/node /opt/deploy/deploy.ts
|
|
375
|
+
Restart=always
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**DeployConfig:**
|
|
379
|
+
|
|
380
|
+
| Option | Default | Description |
|
|
381
|
+
|--------|---------|-------------|
|
|
382
|
+
| `domain` | `'localhost'` | Root domain |
|
|
383
|
+
| `port` | `3000` | Gateway port |
|
|
384
|
+
| `deployToken` | — | Bearer token for management API |
|
|
385
|
+
| `defaultApp` | — | Fallback route |
|
|
386
|
+
| `apps` | — | `Record<string, AppConfig>` |
|
|
387
|
+
|
|
388
|
+
**AppConfig:**
|
|
389
|
+
|
|
390
|
+
| Field | Default | Description |
|
|
391
|
+
|-------|---------|-------------|
|
|
392
|
+
| `dir` | App key | Directory containing the app |
|
|
393
|
+
| `port` | Auto (3001+) | Internal port |
|
|
394
|
+
| `entry` | `'index.ts'` | Entry file |
|
|
395
|
+
| `path` | `'/key'` (local) | URL path prefix |
|
|
396
|
+
| `env` | — | Environment variables |
|
|
397
|
+
| `healthEndpoint` | `/` | Health check path |
|
|
398
|
+
| `buildCommand` | — | Build command |
|
|
399
|
+
| `ports` | — | `[port, port+1]` for blue-green |
|
|
400
|
+
|
|
401
|
+
### errorBoundary(path) [β]
|
|
402
|
+
|
|
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
|
+
|
|
409
|
+
The error component receives `{ error, reset }` as props (`reset` is a no-op on the server):
|
|
410
|
+
|
|
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
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Error boundaries nest — the nearest one up the middleware chain catches the error.
|
|
418
|
+
|
|
273
419
|
### health [β]
|
|
274
420
|
|
|
275
421
|
```ts
|
|
@@ -337,6 +483,17 @@ await engine.trigger({ function_id: 'orders::create', payload: { items: ['apple'
|
|
|
337
483
|
| `.migrate()` | DB setup |
|
|
338
484
|
| `.shutdown()` | Clean shutdown |
|
|
339
485
|
|
|
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
|
+
|
|
495
|
+
Layout components receive `{ children }` (the child page or nested layout). Multiple layouts wrap from outer to inner in `use()` order.
|
|
496
|
+
|
|
340
497
|
### logdb [β]
|
|
341
498
|
|
|
342
499
|
PostgreSQL structured event logging with monthly partitioning.
|
|
@@ -409,6 +566,18 @@ await msg.send(channelId, 'System message', { sender_type: 'system', sender_id:
|
|
|
409
566
|
| `.send(channel, content, opts?)` | Send message to channel |
|
|
410
567
|
| `.close()` | Cleanup |
|
|
411
568
|
|
|
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
|
+
|
|
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
|
+
|
|
412
581
|
### opencode [β]
|
|
413
582
|
|
|
414
583
|
AI programming assistant.
|
|
@@ -438,6 +607,8 @@ app.ws('/opencode', oc.wsHandler())
|
|
|
438
607
|
|
|
439
608
|
### postgres [α]
|
|
440
609
|
|
|
610
|
+
Type-safe PostgreSQL client with schema builder, CRUD, migrations, soft delete, and JSONB/vector support.
|
|
611
|
+
|
|
441
612
|
```ts
|
|
442
613
|
const pg = postgres() // reads DATABASE_URL
|
|
443
614
|
app.use(pg) // injects ctx.sql
|
|
@@ -452,20 +623,109 @@ app.use(pg) // injects ctx.sql
|
|
|
452
623
|
| `connect_timeout` | `number` | `30` | Connection timeout |
|
|
453
624
|
|
|
454
625
|
```ts
|
|
626
|
+
// Raw SQL via tagged template
|
|
627
|
+
await pg.sql`SELECT * FROM users WHERE email = ${email}`
|
|
628
|
+
|
|
455
629
|
// Type-safe DDL
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
630
|
+
import { pgTable, serial, text, boolean, timestamps } from 'weifuwu'
|
|
631
|
+
|
|
632
|
+
const users = pgTable('_users', {
|
|
633
|
+
id: serial('id').primaryKey(),
|
|
634
|
+
name: text('name').notNull(),
|
|
635
|
+
email: text('email').unique().notNull(),
|
|
636
|
+
active: boolean('active').default(true),
|
|
637
|
+
...timestamps(),
|
|
638
|
+
})
|
|
639
|
+
await users.create(pg.sql)
|
|
640
|
+
await users.createIndex(pg.sql, 'email')
|
|
459
641
|
|
|
460
|
-
// BoundTable
|
|
461
|
-
const t = pg.table('_users', { ... })
|
|
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() })
|
|
462
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' })
|
|
463
648
|
const { count, data } = await t.readMany({ role: 'admin' }, { orderBy: { name: 'asc' }, limit: 10 })
|
|
464
649
|
await t.upsert({ email: 'alice@test.com' }, 'email')
|
|
465
|
-
|
|
650
|
+
|
|
651
|
+
// Transactions
|
|
652
|
+
await pg.transaction(async (sql) => {
|
|
653
|
+
const users = pg.table('_users', {}).withSql(sql)
|
|
654
|
+
return users.insert({ name: 'Bob' })
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
// 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)
|
|
661
|
+
|
|
662
|
+
// JSONB queries
|
|
663
|
+
const logs = pg.table('logs', { meta: jsonb<{ service: string }>('meta') })
|
|
664
|
+
await logs.readMany(contains('meta', { service: 'auth' }))
|
|
665
|
+
|
|
666
|
+
// Partitioned tables
|
|
667
|
+
await logs.create({ partitionBy: partitionBy('range', 'created_at') })
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
| Column builder | Type | Notes |
|
|
671
|
+
|---------------|------|-------|
|
|
672
|
+
| `serial(name)` | `number` | Auto-increment |
|
|
673
|
+
| `uuid(name)` | `string` | — |
|
|
674
|
+
| `text(name)` | `string` | — |
|
|
675
|
+
| `integer(name)` | `number` | — |
|
|
676
|
+
| `boolean(name)` / `boolean_(name)` | `boolean` | `_` suffix for JS reserved word |
|
|
677
|
+
| `timestamptz(name)` | `string` | — |
|
|
678
|
+
| `jsonb<T>(name)` | `T` | Generic for typed JSONB access |
|
|
679
|
+
| `textArray(name)` | `string[]` | TEXT[] |
|
|
680
|
+
| `vector(name, dims)` | `number[]` | pgvector support |
|
|
681
|
+
|
|
682
|
+
**Column modifiers:** `.primaryKey()`, `.notNull()`, `.nullable()`, `.default(val)`, `.unique()`, `.references(table, column?, onDelete?)`.
|
|
683
|
+
|
|
684
|
+
**BoundTable CRUD methods:**
|
|
685
|
+
|
|
686
|
+
| Method | Description |
|
|
687
|
+
|--------|-------------|
|
|
688
|
+
| `insert(data)` | INSERT + RETURNING \*, returns the inserted row |
|
|
689
|
+
| `insertMany(data)` | Bulk INSERT + RETURNING \*, returns rows |
|
|
690
|
+
| `read(id, opts?)` | SELECT by primary key, returns row or undefined |
|
|
691
|
+
| `readMany(where?, opts?)` | Filtered query with `{ count, data }` — auto-filters soft-deleted |
|
|
692
|
+
| `update(id, data)` | UPDATE by id + RETURNING \*, returns updated row |
|
|
693
|
+
| `updateMany(where, data)` | Bulk UPDATE, returns affected row count |
|
|
694
|
+
| `delete(id)` | Soft delete if `deleted_at` exists, else hard delete |
|
|
695
|
+
| `hardDelete(id)` | Always DELETE FROM |
|
|
696
|
+
| `deleteMany(where)` | Soft bulk delete if `deleted_at` exists |
|
|
697
|
+
| `hardDeleteMany(where)` | Always DELETE FROM |
|
|
698
|
+
| `upsert(data, conflict)` | INSERT ON CONFLICT DO UPDATE, returns row |
|
|
699
|
+
| `count(where?)` | SELECT COUNT(\*) |
|
|
700
|
+
| `create(opts?)` | CREATE TABLE IF NOT EXISTS |
|
|
701
|
+
| `drop(opts?)` | DROP TABLE IF EXISTS |
|
|
702
|
+
| `createIndex(columns, opts?)` | CREATE INDEX |
|
|
703
|
+
| `createUniqueIndex(columns)` | CREATE UNIQUE INDEX |
|
|
704
|
+
| `withSql(sql)` | Returns a new BoundTable bound to a different sql (for transactions) |
|
|
705
|
+
|
|
706
|
+
**Where helpers** — composable query conditions:
|
|
707
|
+
|
|
708
|
+
| Helper | SQL |
|
|
709
|
+
|--------|-----|
|
|
710
|
+
| `eq(col, val)` | `"col" = val` |
|
|
711
|
+
| `ne(col, val)` | `"col" != val` |
|
|
712
|
+
| `gt` / `gte` / `lt` / `lte` | Comparison operators |
|
|
713
|
+
| `isNull(col)` / `isNotNull(col)` | `IS NULL` / `IS NOT NULL` |
|
|
714
|
+
| `like(col, pattern)` | `LIKE` |
|
|
715
|
+
| `contains(col, val)` | `@>` JSONB containment |
|
|
716
|
+
| `in_(col, vals)` | `= ANY(...)` |
|
|
717
|
+
| `and(...)` / `or(...)` / `not(...)` | Boolean composition |
|
|
718
|
+
|
|
719
|
+
**PgModule** — base class for modules that need DB access:
|
|
720
|
+
|
|
721
|
+
```ts
|
|
722
|
+
class MyModule extends PgModule {
|
|
723
|
+
async migrate() { /* run DDL */ }
|
|
724
|
+
async getUsers() { return this.table('users', {}).readMany() }
|
|
725
|
+
}
|
|
466
726
|
```
|
|
467
727
|
|
|
468
|
-
Where helpers
|
|
728
|
+
Where helpers + `and`/`or`/`not` can be imported from `'weifuwu'` alongside `postgres`. Full column builders and table helpers are in the same barrel.
|
|
469
729
|
|
|
470
730
|
### preferences [α]
|
|
471
731
|
|
|
@@ -565,117 +825,20 @@ app.use(requestId({ header: 'X-Request-Id', generator: () => crypto.randomUUID()
|
|
|
565
825
|
| `header` | `string` | `'X-Request-ID'` | Header name to read/write |
|
|
566
826
|
| `generator` | `() => string` | `crypto.randomUUID()` | ID generator |
|
|
567
827
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
## React SSR (weifuwu)
|
|
571
|
-
|
|
572
|
-
Import from `'weifuwu'` (no separate ssr entry):
|
|
573
|
-
|
|
574
|
-
```ts
|
|
575
|
-
import { ssr, layout, liveReload, errorBoundary, notFound, tailwind } from 'weifuwu'
|
|
576
|
-
```
|
|
577
|
-
|
|
578
|
-
### ssr(path) [β]
|
|
579
|
-
|
|
580
|
-
Compiles a `.tsx` file and returns a Router handler that renders the React component to HTML with streaming, client bundle injection, and context serialization.
|
|
581
|
-
|
|
582
|
-
```ts
|
|
583
|
-
app.get('/about', ssr('./pages/about.tsx'))
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
- Compiles via esbuild at runtime (no build step)
|
|
587
|
-
- Reads `ctx.layoutStack` (set by `layout()` middleware) and wraps the component from outer to inner
|
|
588
|
-
- Injects hydration script pointing to the auto-generated client bundle at `/__ssr/[hash].js`
|
|
589
|
-
- Serializes middleware-injected `ctx` data to `window.__WEIFUWU_CTX` for client-side hydration
|
|
590
|
-
- **Dev mode:** uses `createRoot` instead of `hydrateRoot` — all hooks (`useState`, `useEffect`) work correctly; SSR content is still streamed for fast first paint
|
|
591
|
-
- **Prod mode:** uses `hydrateRoot` for full SSR hydration
|
|
592
|
-
|
|
593
|
-
### layout(path) [β]
|
|
594
|
-
|
|
595
|
-
Compiles a `.tsx` file and returns middleware that pushes the layout component onto `ctx.layoutStack`. Pages rendered by `ssr()` consume this stack.
|
|
596
|
-
|
|
597
|
-
```ts
|
|
598
|
-
app.use(layout('./layouts/root.tsx')) // outermost
|
|
599
|
-
app.use('/blog', layout('./layouts/blog.tsx')) // inner
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
Layout components receive `{ children }` (the child page or nested layout). Multiple layouts wrap from outer to inner in `use()` order.
|
|
603
|
-
|
|
604
|
-
```tsx
|
|
605
|
-
// layouts/root.tsx
|
|
606
|
-
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
607
|
-
return <html><head/><body><main>{children}</main></body></html>
|
|
608
|
-
}
|
|
609
|
-
```
|
|
610
|
-
|
|
611
|
-
### liveReload(opts) [β]
|
|
612
|
-
|
|
613
|
-
Returns a `Router` that registers a WebSocket endpoint at `/__weifuwu/livereload` and starts a file watcher on the given directories. When a `.tsx` file changes, it compiles a hot-update bundle and broadcasts it to all connected browsers.
|
|
614
|
-
|
|
615
|
-
```ts
|
|
616
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
617
|
-
app.use(liveReload({ dirs: ['./pages', './layouts'] }))
|
|
618
|
-
}
|
|
619
|
-
```
|
|
620
|
-
|
|
621
|
-
**How HMR works:**
|
|
622
|
-
|
|
623
|
-
1. File change detected → compiles the entry `page.tsx` (bundles all dependencies) with esbuild
|
|
624
|
-
2. Browser receives WS message → `import()` the hot bundle
|
|
625
|
-
3. Hot bundle calls `window.__WFW_REFRESH(newComponent)` → updates a stable proxy component
|
|
626
|
-
4. Proxy reference unchanged → React reuses fiber tree → `useState` values preserved
|
|
627
|
-
5. Sets `ctx.tick` to trigger re-render without page navigation
|
|
628
|
-
|
|
629
|
-
**Key properties:**
|
|
630
|
-
|
|
631
|
-
- **State preservation** — input fields, scroll position, and other `useState`-driven state survive edits
|
|
632
|
-
- **Entry hash isolation** — each `ssr()` route has its own entry hash; WS messages carry the hash so only matching tabs update
|
|
633
|
-
- **Lazy hydration cache** — hydration bundle is only rebuilt on actual page load, not on every file change
|
|
634
|
-
- **Compilation fallback** — if esbuild encounters a syntax error, `location.reload()` is called to show the error
|
|
635
|
-
|
|
636
|
-
Mount without a path — the internal `/__weifuwu/livereload` route is invisible to the user. The `ssr()` function automatically injects the client-side WS script in dev mode.
|
|
637
|
-
|
|
638
|
-
| Option | Type | Default | Description |
|
|
639
|
-
|--------|------|---------|-------------|
|
|
640
|
-
| `dirs` | `string[]` | — | Directories to watch for `.tsx` changes |
|
|
641
|
-
|
|
642
|
-
Returns `Router & { close: () => void }` — call `.close()` to stop the watcher.
|
|
643
|
-
|
|
644
|
-
### errorBoundary(path) [β]
|
|
645
|
-
|
|
646
|
-
Wraps child routes in an error boundary. If a page or middleware throws, the error component is rendered instead.
|
|
647
|
-
|
|
648
|
-
```ts
|
|
649
|
-
app.use('/blog', errorBoundary('./blog-error.tsx'))
|
|
650
|
-
```
|
|
651
|
-
|
|
652
|
-
The error component receives `{ error, reset }` as props:
|
|
653
|
-
|
|
654
|
-
```tsx
|
|
655
|
-
export default function BlogError({ error, reset }: { error: Error; reset: () => void }) {
|
|
656
|
-
return <div><h2>Error</h2><p>{error.message}</p></div>
|
|
657
|
-
}
|
|
658
|
-
```
|
|
659
|
-
|
|
660
|
-
Error boundaries nest — the nearest one up the middleware chain catches the error.
|
|
828
|
+
### rootLayout(dir) [α]
|
|
661
829
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
Returns a catch-all handler for 404 pages. Typically registered last:
|
|
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.
|
|
665
831
|
|
|
666
832
|
```ts
|
|
667
|
-
app.
|
|
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
|
|
668
837
|
```
|
|
669
838
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
```ts
|
|
675
|
-
app.use(tailwind('./ui'))
|
|
676
|
-
```
|
|
677
|
-
|
|
678
|
-
The `dir` argument is the directory containing `app.css`. When `tailwind()` middleware is detected, `ssr()` automatically injects `<link rel="stylesheet" href="/__wfw/style.css" />` into the HTML `<head>`.
|
|
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
|
|
679
842
|
|
|
680
843
|
### seo [β] + seoMiddleware [α]
|
|
681
844
|
|
|
@@ -695,6 +858,22 @@ Also exports `seoTags(config)` for generating meta/og/twitter tags as an HTML st
|
|
|
695
858
|
| `sitemap` | `SitemapConfig` | — | Sitemap configuration (urls, resolve, cacheTTL) |
|
|
696
859
|
| `headers` | `SeoHeadersConfig` | — | Response headers (e.g. `X-Robots-Tag`) |
|
|
697
860
|
|
|
861
|
+
### ssr(path) [β]
|
|
862
|
+
|
|
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.
|
|
864
|
+
|
|
865
|
+
```ts
|
|
866
|
+
app.get('/about', ssr('./ui/pages/about.tsx'))
|
|
867
|
+
```
|
|
868
|
+
|
|
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
|
|
876
|
+
|
|
698
877
|
### tenant [β]
|
|
699
878
|
|
|
700
879
|
Multi-tenant BaaS with dynamic table API and GraphQL.
|
|
@@ -905,19 +1084,9 @@ function Toast() {
|
|
|
905
1084
|
|
|
906
1085
|
### Dev mode
|
|
907
1086
|
|
|
908
|
-
Auto-detected when `NODE_ENV !== 'production'`.
|
|
909
|
-
|
|
910
|
-
```ts
|
|
911
|
-
import { liveReload } from 'weifuwu'
|
|
912
|
-
|
|
913
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
914
|
-
app.use(liveReload({ dirs: ['./pages', './layouts'] }))
|
|
915
|
-
}
|
|
916
|
-
```
|
|
917
|
-
|
|
918
|
-
When a `.tsx` file changes, the browser hot-updates without refreshing — `useState` values are preserved. See `liveReload` section for details.
|
|
1087
|
+
Auto-detected when `NODE_ENV !== 'production'`. `rootLayout(dir)` automatically registers vendor bundle, HMR WebSocket, and file watcher. No explicit setup needed.
|
|
919
1088
|
|
|
920
|
-
|
|
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.
|
|
921
1090
|
|
|
922
1091
|
---
|
|
923
1092
|
|
|
@@ -949,58 +1118,66 @@ app.get('/stream', (req, ctx) => createSSEStream(events()))
|
|
|
949
1118
|
|
|
950
1119
|
---
|
|
951
1120
|
|
|
952
|
-
##
|
|
1121
|
+
## Complete export index
|
|
1122
|
+
|
|
1123
|
+
Every public symbol can be imported from `'weifuwu'`:
|
|
953
1124
|
|
|
954
|
-
###
|
|
1125
|
+
### Core
|
|
955
1126
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
| `setCookie(res, name, value, opts?)` | Set cookie |
|
|
962
|
-
| `deleteCookie(res, name, opts?)` | Delete cookie |
|
|
963
|
-
| `createTestServer(handler)` | `→ { server, url }` |
|
|
1127
|
+
```ts
|
|
1128
|
+
serve, createTestServer, Router,
|
|
1129
|
+
Context, Handler, Middleware, ErrorHandler, ServeOptions, Server,
|
|
1130
|
+
loadEnv
|
|
1131
|
+
```
|
|
964
1132
|
|
|
965
|
-
###
|
|
1133
|
+
### Middleware modules
|
|
966
1134
|
|
|
967
1135
|
```ts
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
openai, createOpenAI
|
|
1136
|
+
auth, cors, csrf, compress, helmet, logger, rateLimit, requestId, validate, upload,
|
|
1137
|
+
preferences, serveStatic
|
|
971
1138
|
```
|
|
972
1139
|
|
|
973
|
-
###
|
|
1140
|
+
### Database
|
|
974
1141
|
|
|
975
1142
|
```ts
|
|
976
|
-
|
|
977
|
-
|
|
1143
|
+
postgres, PostgresOptions, PostgresClient,
|
|
1144
|
+
redis, RedisOptions, RedisClient,
|
|
1145
|
+
queue, QueueOptions, QueueJob, Queue,
|
|
1146
|
+
// Schema helpers — importable alongside postgres:
|
|
1147
|
+
pgTable, SQL, sql,
|
|
1148
|
+
ColumnBuilder, serial, uuid, text, integer, boolean, boolean_, timestamptz, jsonb, textArray, vector,
|
|
1149
|
+
partitionBy, timestamps, toDDL, PartitionByDef,
|
|
1150
|
+
Table, BoundTable, IndexOptions, FindOptions, CreateOptions,
|
|
978
1151
|
eq, ne, gt, gte, lt, lte, isNull, isNotNull, like, contains, in_, and, or, not
|
|
979
1152
|
```
|
|
980
1153
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
## Testing
|
|
1154
|
+
### Client-side (from `'weifuwu/react'`)
|
|
984
1155
|
|
|
985
1156
|
```ts
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1157
|
+
TsxContext, useLoaderData,
|
|
1158
|
+
useWebsocket, useAction, useFetch, useQueryState, createStore,
|
|
1159
|
+
Link, useNavigate, useNavigating, addInterceptor,
|
|
1160
|
+
useLocale, useTheme, applyTheme, useFlashMessage,
|
|
1161
|
+
Head
|
|
1162
|
+
```
|
|
989
1163
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
})
|
|
997
|
-
})
|
|
1164
|
+
### AI SDK (re-exported from `ai`)
|
|
1165
|
+
|
|
1166
|
+
```ts
|
|
1167
|
+
streamText, generateText, streamObject, generateObject,
|
|
1168
|
+
tool, embed, embedMany, smoothStream,
|
|
1169
|
+
openai, createOpenAI
|
|
998
1170
|
```
|
|
999
1171
|
|
|
1172
|
+
### Other modules
|
|
1173
|
+
|
|
1000
1174
|
```ts
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1175
|
+
health, analytics, seo, seoMiddleware, seoTags,
|
|
1176
|
+
user, mailer, graphql, aiStream, runWorkflow,
|
|
1177
|
+
logdb, messager, agent, iii, createWorker, registerWorker,
|
|
1178
|
+
opencode, deploy, defineConfig,
|
|
1179
|
+
getCookies, setCookie, deleteCookie,
|
|
1180
|
+
createSSEStream, formatSSE, formatSSEData
|
|
1004
1181
|
```
|
|
1005
1182
|
|
|
1006
1183
|
---
|
package/cli/template/app.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
|
-
import { Router, ssr,
|
|
2
|
+
import { Router, ssr, rootLayout, preferences } from '../../index.ts'
|
|
3
3
|
|
|
4
4
|
const _ui = join(import.meta.dirname, 'ui')
|
|
5
5
|
const _loc = join(import.meta.dirname, 'locales')
|
|
6
6
|
|
|
7
7
|
export const app = new Router()
|
|
8
|
-
app.use(
|
|
8
|
+
app.use(rootLayout(_ui))
|
|
9
9
|
app.use(preferences({ dir: _loc, locale: { default: 'en' }, theme: { default: 'system' } }))
|
|
10
10
|
app.use(async (req, ctx, next) => {
|
|
11
11
|
ctx.loaderData = {
|
|
@@ -17,7 +17,6 @@ app.use(async (req, ctx, next) => {
|
|
|
17
17
|
}
|
|
18
18
|
return next(req, ctx)
|
|
19
19
|
})
|
|
20
|
-
app.use(layout(join(_ui, 'layout.tsx')))
|
|
21
20
|
app.get('/', ssr(join(_ui, 'page.tsx')))
|
|
22
21
|
app.get('/api/ping', () => Response.json({ pong: true, time: new Date().toISOString() }))
|
|
23
22
|
app.ws('/ws/echo', { message(ws, _ctx, data) { ws.send(`echo: ${data}`) } })
|