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 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, layout, liveReload } from 'weifuwu'
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(layout('./layouts/root.tsx'))
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
- const config = defineConfig({
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
- api: { repo: 'git@github.com:user/api.git', entry: 'app.ts', port: 3001, subdomain: 'api' },
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
- const users = pgTable('_users', { id: serial('id').primaryKey(), name: text('name').notNull(), email: text('email').unique().notNull(), active: boolean('active').default(true), ...timestamps() })
457
- await users.create()
458
- await users.createIndex('email')
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 CRUD
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
- await pg.transaction(async (tx) => { const users = pg.table('_users', { ... }).withSql(tx); return users.insert({ name: 'Bob' }) })
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: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `isNull`, `isNotNull`, `like`, `contains`, `in_`, `and`, `or`, `not`.
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
- ### notFound(path) [β]
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.all('/*', notFound('./not-found.tsx'))
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
- ### tailwind(cssPath) [α]
671
-
672
- Compiles Tailwind CSS v4 via `@tailwindcss/postcss` and serves it at `/__wfw/style.css`. The file is compiled once and cached; on `app.css` changes in dev mode, the style is pushed to connected browsers via liveReload's WebSocket.
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'`. File watching + live reload via `liveReload()`:
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
- Tailwind v4 auto-compile via `tailwind()` middleware:
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
- ## Utility Functions
1121
+ ## Complete export index
1122
+
1123
+ Every public symbol can be imported from `'weifuwu'`:
953
1124
 
954
- ### Common
1125
+ ### Core
955
1126
 
956
- | Function | Description |
957
- |----------|-------------|
958
- | `loadEnv(path?)` | Load `.env` into `process.env` |
959
- | `serveStatic(root, opts?)` | Static file serving (20+ MIME, ETag, 304, path traversal protection) |
960
- | `getCookies(req)` | Parse cookies |
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
- ### AI re-exports
1133
+ ### Middleware modules
966
1134
 
967
1135
  ```ts
968
- streamText, generateText, streamObject, generateObject,
969
- tool, embed, embedMany, smoothStream,
970
- openai, createOpenAI
1136
+ auth, cors, csrf, compress, helmet, logger, rateLimit, requestId, validate, upload,
1137
+ preferences, serveStatic
971
1138
  ```
972
1139
 
973
- ### pgTable helpers
1140
+ ### Database
974
1141
 
975
1142
  ```ts
976
- pgTable, pg.table,
977
- serial, uuid, text, integer, boolean, timestamptz, jsonb, textArray, vector, timestamps,
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
- import { describe, it } from 'node:test'
987
- import assert from 'node:assert/strict'
988
- import { Router } from 'weifuwu'
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
- describe('hello', () => {
991
- it('returns 200', async () => {
992
- const r = new Router()
993
- r.get('/', () => new Response('ok'))
994
- const res = await r.handler()(new Request('http://localhost/'), {} as any)
995
- assert.equal(res.status, 200)
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
- import { createTestServer } from 'weifuwu'
1002
- const { server, url } = await createTestServer(handler)
1003
- const res = await fetch(`${url}/api/ping`)
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
  ---
@@ -1,11 +1,11 @@
1
1
  import { join } from 'node:path'
2
- import { Router, ssr, layout, tailwind, preferences } from '../../index.ts'
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(tailwind(_ui))
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}`) } })