weifuwu 0.12.0 → 0.13.1
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 +95 -1427
- package/cli.ts +88 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +75 -0
- package/dist/env.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +86 -57
- package/docs/agent.md +44 -0
- package/docs/ai.md +93 -0
- package/docs/extra.md +67 -0
- package/docs/graphql.md +61 -0
- package/docs/messager.md +48 -0
- package/docs/middleware.md +131 -0
- package/docs/opencode.md +252 -0
- package/docs/postgres.md +162 -0
- package/docs/router.md +80 -0
- package/docs/tenant.md +174 -0
- package/docs/tsx.md +199 -0
- package/docs/user.md +167 -0
- package/package.json +7 -2
package/docs/postgres.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# PostgreSQL
|
|
2
|
+
|
|
3
|
+
> [Home](../README.md) → PostgreSQL
|
|
4
|
+
|
|
5
|
+
## PostgreSQL
|
|
6
|
+
|
|
7
|
+
Built-in PostgreSQL client — connection management, type-safe DDL, transactions, and module lifecycle.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { serve, Router, postgres } from 'weifuwu'
|
|
11
|
+
|
|
12
|
+
const app = new Router()
|
|
13
|
+
const pg = postgres() // reads DATABASE_URL
|
|
14
|
+
app.use(pg) // injects ctx.sql into handlers
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Type-safe DDL with schema builder
|
|
18
|
+
|
|
19
|
+
Define tables declaratively with type inference — no raw SQL for common operations, no Zod needed:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { pgTable, serial, uuid, text, integer, boolean, timestamptz, jsonb, sql } from 'weifuwu'
|
|
23
|
+
|
|
24
|
+
const users = pgTable('_users', {
|
|
25
|
+
id: serial('id').primaryKey(),
|
|
26
|
+
name: text('name').notNull(),
|
|
27
|
+
email: text('email').unique().notNull(),
|
|
28
|
+
age: integer('age'),
|
|
29
|
+
active: boolean('active').default(true),
|
|
30
|
+
createdAt: timestamptz('created_at').default(sql`NOW()`),
|
|
31
|
+
metadata: jsonb<{ role: string }>('metadata'),
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Supports 10 column types:
|
|
36
|
+
| Builder | DDL | TS Type |
|
|
37
|
+
|---------|-----|---------|
|
|
38
|
+
| `serial()` | `SERIAL` | `number` |
|
|
39
|
+
| `uuid()` | `UUID` | `string` |
|
|
40
|
+
| `text()` | `TEXT` | `string` |
|
|
41
|
+
| `integer()` | `INTEGER` | `number` |
|
|
42
|
+
| `boolean()` | `BOOLEAN` | `boolean` |
|
|
43
|
+
| `timestamptz()` | `TIMESTAMPTZ` | `string` |
|
|
44
|
+
| `jsonb<T>()` | `JSONB` | `T` |
|
|
45
|
+
| `textArray()` | `TEXT[]` | `string[]` |
|
|
46
|
+
| `vector(name, dims)` | `vector(N)` | `number[]` |
|
|
47
|
+
|
|
48
|
+
Column constraints chainable: `.primaryKey()`, `.notNull()`, `.nullable()`, `.default(value | sql\`...\`)`, `.unique()`, `.references(table, column?, onDelete?)`.
|
|
49
|
+
|
|
50
|
+
### DDL execution
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
await users.create() // CREATE TABLE IF NOT EXISTS
|
|
54
|
+
await users.createIndex('email') // CREATE INDEX
|
|
55
|
+
await users.createUniqueIndex('slug') // CREATE UNIQUE INDEX
|
|
56
|
+
await users.createIndex('created_at', { desc: true })
|
|
57
|
+
await users.createIndex(['a', 'b']) // multi-column
|
|
58
|
+
await users.createIndex('embedding', { // pgvector HNSW
|
|
59
|
+
type: 'hnsw', operator: 'vector_cosine_ops',
|
|
60
|
+
})
|
|
61
|
+
await users.drop({ cascade: true })
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Type-safe CRUD with BoundTable
|
|
65
|
+
|
|
66
|
+
Two usage paths — use `pg.table()` when you have a `pg` handle, or `pgTable()` with explicit `sql`:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// pg.table() — auto-binds sql, no need to pass it
|
|
70
|
+
const users = pg.table('_users', {
|
|
71
|
+
id: serial('id').primaryKey(),
|
|
72
|
+
name: text('name').notNull(),
|
|
73
|
+
email: text('email').unique(),
|
|
74
|
+
active: boolean('active').default(true),
|
|
75
|
+
createdAt: timestamptz('created_at').default(sql`NOW()`),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// INSERT ... RETURNING * — auto-strips serial id
|
|
79
|
+
const user = await users.insert({ name: 'Alice', email: 'alice@test.com' })
|
|
80
|
+
// → { id: 1, name: 'Alice', email: 'alice@test.com', active: true, ... }
|
|
81
|
+
|
|
82
|
+
// SELECT ... WHERE id = ? LIMIT 1
|
|
83
|
+
const found = await users.findById(1)
|
|
84
|
+
|
|
85
|
+
// SELECT ... WHERE ... [ORDER BY ...] [LIMIT ...] [OFFSET ...]
|
|
86
|
+
const admins = await users.find({ role: 'admin' })
|
|
87
|
+
const sorted = await users.find({ active: true }, { orderBy: { name: 'asc' } })
|
|
88
|
+
const page = await users.find(undefined, { limit: 10, offset: 0 })
|
|
89
|
+
const filtered = await users.find({ role: 'admin' }, { orderBy: { name: 'desc' }, limit: 5 })
|
|
90
|
+
|
|
91
|
+
// UPDATE ... SET ... WHERE ... RETURNING *
|
|
92
|
+
const updated = await users.update({ id: 1 }, { name: 'Bob' })
|
|
93
|
+
// With SQL expressions:
|
|
94
|
+
await users.update({ id: 1 }, { name: 'Bob', updated_at: sql`NOW()` })
|
|
95
|
+
|
|
96
|
+
// DELETE ... WHERE ... RETURNING 1
|
|
97
|
+
const ok = await users.delete({ id: 1 })
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
When using `pgTable()` directly (without `pg`), pass `sql` as the first argument:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const t = pgTable('_users', { ... })
|
|
104
|
+
await t.insert(ctx.sql, { name: 'Alice' })
|
|
105
|
+
await t.find(ctx.sql, { role: 'admin' }, { orderBy: { name: 'asc' } })
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Complex queries use raw SQL
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
app.get('/users/stats', async (req, ctx) => {
|
|
112
|
+
const rows = await ctx.sql`
|
|
113
|
+
SELECT u.*, count(p.id) as posts
|
|
114
|
+
FROM ${users} u LEFT JOIN posts p ON p.user_id = u.id
|
|
115
|
+
GROUP BY u.id
|
|
116
|
+
`
|
|
117
|
+
return Response.json(rows)
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Transactions
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const result = await pg.transaction(async (tx) => {
|
|
125
|
+
const [user] = await tx`INSERT INTO "_users" (...) VALUES (...) RETURNING *`
|
|
126
|
+
const [wallet] = await tx`INSERT INTO "_wallets" ("user_id") VALUES (${user.id}) RETURNING *`
|
|
127
|
+
return { user, wallet }
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Connection lifecycle
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const pg = postgres() // reads DATABASE_URL
|
|
135
|
+
const pg = postgres('postgres://...') // explicit connection
|
|
136
|
+
const pg = postgres({
|
|
137
|
+
connection: 'postgres://...',
|
|
138
|
+
max: 10, // pool size
|
|
139
|
+
ssl: { rejectUnauthorized: false }, // SSL options
|
|
140
|
+
idle_timeout: 30, // idle timeout (s)
|
|
141
|
+
connect_timeout: 10, // connection timeout (s)
|
|
142
|
+
closeTimeout: 5, // close grace period (s)
|
|
143
|
+
signal: ac.signal, // abort → sql.end()
|
|
144
|
+
})
|
|
145
|
+
await pg.close()
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Module base class
|
|
149
|
+
|
|
150
|
+
Every database module (`opencode`, `messager`, `tenant`, `agent`, `user`) extends `PgModule`:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { PgModule } from 'weifuwu'
|
|
154
|
+
|
|
155
|
+
class MyModule extends PgModule {
|
|
156
|
+
constructor(pg: PostgresClient) {
|
|
157
|
+
super(pg) // sets this.sql = pg.sql
|
|
158
|
+
}
|
|
159
|
+
async migrate() { /* override */ }
|
|
160
|
+
// close() inherited — calls pg.close() automatically
|
|
161
|
+
}
|
|
162
|
+
```
|
package/docs/router.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Router
|
|
2
|
+
|
|
3
|
+
> [Home](../README.md) → Router
|
|
4
|
+
|
|
5
|
+
## Router
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { serve, Router } from 'weifuwu'
|
|
9
|
+
|
|
10
|
+
const app = new Router()
|
|
11
|
+
.use((req, ctx, next) => {
|
|
12
|
+
console.log(`${req.method} ${new URL(req.url).pathname}`)
|
|
13
|
+
return next(req, ctx)
|
|
14
|
+
})
|
|
15
|
+
.get('/hello/:name', (req, ctx) =>
|
|
16
|
+
Response.json({ message: `Hello, ${ctx.params.name}!` }),
|
|
17
|
+
)
|
|
18
|
+
.post('/data', async (req, ctx) => {
|
|
19
|
+
const body = await req.json()
|
|
20
|
+
return Response.json(body, { status: 201 })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
serve(app.handler(), { port: 3000 })
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## WebSocket
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{ "type": "message", "channel_id": 1, "content": "Hi" }
|
|
30
|
+
{ "type": "typing", "channel_id": 1, "is_typing": true }
|
|
31
|
+
{ "type": "read", "channel_id": 1, "last_message_id": 42 }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Programmatic send
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
await msg.send(channelId, 'System message', { sender_type: 'system' })
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Error handling
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const app = new Router()
|
|
44
|
+
.onError((err, req, ctx) =>
|
|
45
|
+
Response.json({ error: err.message }, { status: 500 }),
|
|
46
|
+
)
|
|
47
|
+
.get('/crash', () => { throw new Error('boom') })
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Graceful shutdown
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { serve } from 'weifuwu'
|
|
54
|
+
import type { Server } from 'weifuwu'
|
|
55
|
+
|
|
56
|
+
const ac = new AbortController()
|
|
57
|
+
let server: Server
|
|
58
|
+
|
|
59
|
+
process.on('SIGTERM', () => {
|
|
60
|
+
ac.abort()
|
|
61
|
+
server.stop()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
server = serve((req, ctx) => new Response('Hello'), {
|
|
65
|
+
port: 3000,
|
|
66
|
+
signal: ac.signal,
|
|
67
|
+
})
|
|
68
|
+
await server.ready
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Using with WebSocket
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const app = new Router().ws('/chat', { … })
|
|
75
|
+
const server = serve(app.handler(), {
|
|
76
|
+
port: 3000,
|
|
77
|
+
signal: ac.signal,
|
|
78
|
+
websocket: app.websocketHandler(),
|
|
79
|
+
})
|
|
80
|
+
```
|
package/docs/tenant.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Tenant BaaS
|
|
2
|
+
|
|
3
|
+
> [Home](../README.md) → Tenant BaaS
|
|
4
|
+
|
|
5
|
+
## Tenant BaaS
|
|
6
|
+
|
|
7
|
+
Built-in multi-tenant backend-as-a-service — define tables at runtime via API, get RESTful CRUD + GraphQL automatically, with row-level tenant isolation.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { serve, Router, postgres, user, tenant } from 'weifuwu'
|
|
11
|
+
|
|
12
|
+
const pg = postgres()
|
|
13
|
+
const u = user({ pg, jwtSecret: process.env.JWT_SECRET! })
|
|
14
|
+
const t = tenant({ pg, usersTable: '_users' })
|
|
15
|
+
|
|
16
|
+
await pg.migrate()
|
|
17
|
+
await u.migrate()
|
|
18
|
+
await t.migrate() // creates _tenants, _tenant_members, _user_tables
|
|
19
|
+
|
|
20
|
+
const app = new Router()
|
|
21
|
+
app.use('/auth', u.router())
|
|
22
|
+
app.use('/api', u.middleware()) // → ctx.user
|
|
23
|
+
app.use('/api', t.middleware()) // → ctx.tenant
|
|
24
|
+
app.use('/api', t.router()) // → management + data CRUD
|
|
25
|
+
app.use('/graphql', t.graphql()) // → dynamic GraphQL
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### System tables
|
|
29
|
+
|
|
30
|
+
| Table | Purpose |
|
|
31
|
+
|-------|---------|
|
|
32
|
+
| `_tenants` | Tenant records (`id TEXT PK DEFAULT gen_random_uuid()`, `name`, `created_at`) |
|
|
33
|
+
| `_tenant_members` | User-tenant membership (`tenant_id`, `user_id`, `role`) |
|
|
34
|
+
| `_user_tables` | Dynamic table definitions (`tenant_id`, `slug`, `fields JSONB`) |
|
|
35
|
+
|
|
36
|
+
### Dynamic table API
|
|
37
|
+
|
|
38
|
+
Create a table at runtime:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
POST /api/tables
|
|
42
|
+
{
|
|
43
|
+
"slug": "articles",
|
|
44
|
+
"fields": [
|
|
45
|
+
{ "name": "title", "type": "string", "required": true },
|
|
46
|
+
{ "name": "content", "type": "text" },
|
|
47
|
+
{ "name": "status", "type": "enum", "options": ["draft", "published"], "default": "draft" },
|
|
48
|
+
{ "name": "views", "type": "integer", "default": 0 },
|
|
49
|
+
{ "name": "embedding", "type": "vector", "dimensions": 1536, "index": "hnsw" }
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
→ Creates a PostgreSQL table with `id SERIAL PK`, `tenant_id TEXT NOT NULL`, and the specified columns, plus indexes. The table name is internally scoped to the tenant.
|
|
55
|
+
|
|
56
|
+
### Field types
|
|
57
|
+
|
|
58
|
+
| type | PostgreSQL | Index support |
|
|
59
|
+
|------|-----------|---------------|
|
|
60
|
+
| `string` | `TEXT` | `true`, `unique` |
|
|
61
|
+
| `integer` | `INTEGER` | `true`, `desc`, `unique` |
|
|
62
|
+
| `float` | `DOUBLE PRECISION` | `true`, `desc` |
|
|
63
|
+
| `boolean` | `BOOLEAN` | `true` |
|
|
64
|
+
| `text` | `TEXT` | `true` |
|
|
65
|
+
| `datetime` | `TIMESTAMPTZ` | `true`, `desc` |
|
|
66
|
+
| `date` | `DATE` | `true`, `desc` |
|
|
67
|
+
| `enum` | `TEXT` (with validation) | `true` |
|
|
68
|
+
| `json` | `JSONB` | `gin` |
|
|
69
|
+
| `vector` | `vector(n)` (pgvector) | `hnsw` (HNSW, vector_cosine_ops) |
|
|
70
|
+
|
|
71
|
+
### Relationships
|
|
72
|
+
|
|
73
|
+
Declare a foreign key via the `relation` field:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{ "name": "article_id", "type": "integer", "relation": { "table": "articles", "onDelete": "cascade" } }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Supported relationship patterns:
|
|
80
|
+
|
|
81
|
+
| Pattern | Detection | REST | GraphQL |
|
|
82
|
+
|---------|-----------|------|---------|
|
|
83
|
+
| **belongs_to** | Field with `relation` | — | `comment.article` resolver |
|
|
84
|
+
| **has_many** | Another table has a relation pointing here | `GET /api/articles/:id/comments` | `article.comments` resolver |
|
|
85
|
+
| **M2M** | Junction table with exactly two relation fields | `GET /api/articles/:id/tags` (bypasses junction) | `article.tags` / `tag.articles` resolver |
|
|
86
|
+
| **Self-ref** | Relation field pointing to same table | — | With depth control |
|
|
87
|
+
|
|
88
|
+
### RESTful API
|
|
89
|
+
|
|
90
|
+
All routes require `ctx.tenant` (set by `t.middleware()`). All queries automatically filter by `tenant_id`.
|
|
91
|
+
|
|
92
|
+
| Route | Method | Description |
|
|
93
|
+
|-------|--------|-------------|
|
|
94
|
+
| `/sys/tenants` | POST | Create tenant, caller becomes admin |
|
|
95
|
+
| `/sys/tenants` | GET | List user's tenants |
|
|
96
|
+
| `/sys/tenants/invite` | POST | Invite user by email (admin) |
|
|
97
|
+
| `/sys/tenants/members/:userId` | DELETE | Remove member (admin) |
|
|
98
|
+
| `/sys/tables` | POST/GET | Create / list dynamic tables |
|
|
99
|
+
| `/sys/tables/:slug` | GET/PATCH/DELETE | Get schema / add fields / drop table |
|
|
100
|
+
| `/:slug` | GET | List rows (limit, offset, sort) |
|
|
101
|
+
| `/:slug` | POST | Create row |
|
|
102
|
+
| `/:slug/:id` | GET/PATCH/DELETE | Get / update / delete row |
|
|
103
|
+
| `/:slug/:id/:_nested` | GET | List related rows (has_many / M2M) |
|
|
104
|
+
| `/:slug/:id/:_nested` | POST | Create related row (auto-fills relation field) |
|
|
105
|
+
|
|
106
|
+
### Vector search
|
|
107
|
+
|
|
108
|
+
```http
|
|
109
|
+
GET /api/articles?search_vector=[0.1,0.2,...]&search_field=embedding&search_limit=10
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Returns rows ordered by cosine distance (`<=>`), includes `_distance` field. Supports `l2` (`<->`) and `ip` (`<#>`):
|
|
113
|
+
|
|
114
|
+
```http
|
|
115
|
+
GET /api/articles?search_vector=[...]&search_field=embedding&search_distance=l2
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### GraphQL
|
|
119
|
+
|
|
120
|
+
Dynamic GraphQL schema generated per-request based on the authenticated tenant's tables:
|
|
121
|
+
|
|
122
|
+
```graphql
|
|
123
|
+
type Article {
|
|
124
|
+
id: ID!
|
|
125
|
+
title: String!
|
|
126
|
+
content: String
|
|
127
|
+
status: String
|
|
128
|
+
comments(limit: Int, offset: Int): [Comment!]!
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type Query {
|
|
132
|
+
articles(limit: Int, offset: Int): [Article!]!
|
|
133
|
+
getArticle(id: ID!): Article
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type Mutation {
|
|
137
|
+
createArticle(data: CreateArticleInput!): Article!
|
|
138
|
+
updateArticle(id: ID!, data: PatchArticleInput!): Article!
|
|
139
|
+
deleteArticle(id: ID!): Boolean!
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Built with `graphql-js` native constructors (`GraphQLObjectType`), no SDL generation, no `makeExecutableSchema`.
|
|
144
|
+
|
|
145
|
+
### Middleware
|
|
146
|
+
|
|
147
|
+
`t.middleware()` extracts the tenant context:
|
|
148
|
+
|
|
149
|
+
1. Requires `ctx.user` (from `u.middleware()`)
|
|
150
|
+
2. Looks up user's tenant memberships
|
|
151
|
+
3. Single tenant → automatically set `ctx.tenant`
|
|
152
|
+
4. Multiple tenants → require `X-Tenant-ID` header, return 300 with tenant list if missing
|
|
153
|
+
5. No tenants → 403
|
|
154
|
+
|
|
155
|
+
### Tenant lifecycle
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const t = tenant({ pg, usersTable: '_users' })
|
|
159
|
+
|
|
160
|
+
// Create a tenant — the caller becomes admin
|
|
161
|
+
const tenant = await (await fetch('http://localhost/api/sys/tenants', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer <jwt>' },
|
|
164
|
+
body: JSON.stringify({ name: 'Acme Corp' }),
|
|
165
|
+
})).json()
|
|
166
|
+
// → { id: "uuid", name: "Acme Corp", created_at: "..." }
|
|
167
|
+
|
|
168
|
+
// Invite a member
|
|
169
|
+
await fetch('http://localhost/api/sys/tenants/invite', {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer <jwt>' },
|
|
172
|
+
body: JSON.stringify({ email: 'colleague@acme.com', role: 'member' }),
|
|
173
|
+
})
|
|
174
|
+
```
|
package/docs/tsx.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# React SSR with tsx()
|
|
2
|
+
|
|
3
|
+
> [Home](../README.md) → tsx()
|
|
4
|
+
|
|
5
|
+
## React pages with tsx()
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { serve, Router } from 'weifuwu'
|
|
9
|
+
import { tsx } from 'weifuwu/tsx'
|
|
10
|
+
|
|
11
|
+
const app = new Router()
|
|
12
|
+
app.use('/', await tsx({ dir: './ui/' }))
|
|
13
|
+
|
|
14
|
+
serve(app.handler(), { port: 3000, websocket: app.websocketHandler() })
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Directory structure
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
ui/
|
|
21
|
+
├── pages/ ← 页面文件
|
|
22
|
+
│ ├── page.tsx → GET / (React component, default export)
|
|
23
|
+
│ ├── layout.tsx → root layout (HTML shell, receives req/ctx, NOT hydrated)
|
|
24
|
+
│ ├── not-found.tsx → 404 error page (rendered for unmatched routes, wrapped in layout)
|
|
25
|
+
│ ├── about/page.tsx → GET /about
|
|
26
|
+
│ ├── blog/[slug]/
|
|
27
|
+
│ │ ├── page.tsx → GET /blog/:slug
|
|
28
|
+
│ │ ├── load.ts → data fetching (server-only, default export)
|
|
29
|
+
│ │ └── route.ts → POST /blog/:slug (API, named exports POST/PUT/DELETE/...)
|
|
30
|
+
│ ├── blog/layout.tsx → /blog/* layout (UI structure, receives children, hydrated)
|
|
31
|
+
│ └── api/search/
|
|
32
|
+
│ └── route.ts → GET /api/search (standalone API, no page.tsx needed)
|
|
33
|
+
└── components/ ← 组件文件(会被热更自动感知)
|
|
34
|
+
└── button.tsx
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Development mode
|
|
38
|
+
|
|
39
|
+
tsx() runs in development mode automatically when `NODE_ENV !== 'production'`:
|
|
40
|
+
|
|
41
|
+
- **File watching** — chokidar watches the `dir` directory for `.tsx`/`.ts` changes
|
|
42
|
+
- Page files in `pages/` → single-file recompilation + registry update
|
|
43
|
+
- Component files in `components/` → full rebuild of all pages
|
|
44
|
+
- New files are detected automatically
|
|
45
|
+
- **Live reload** — Compiled via esbuild `write: false` + `vm.Script.runInContext` (no disk writes, no `node --watch` conflict)
|
|
46
|
+
- **WebSocket auto-refresh** — `/__weifuwu/livereload` endpoint pushes reload signals; browser refreshes automatically
|
|
47
|
+
- **`node --watch` compatible** — External files (`app.ts`, `middleware/`) handled by `--watch` restart; `ui/` changes handled by tsx() without conflict
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
node app.ts # development (auto-reload + live refresh)
|
|
51
|
+
NODE_ENV=production node app.ts # production
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Tailwind CSS
|
|
55
|
+
|
|
56
|
+
tsx() includes built-in Tailwind CSS v4 support. If an `app.css` file exists in the `dir` directory, it is compiled automatically through PostCSS + `@tailwindcss/postcss`. If no `app.css` is found, one is created automatically:
|
|
57
|
+
|
|
58
|
+
```css
|
|
59
|
+
@import "tailwindcss";
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Write `className` directly in your components — no CLI, no configuration:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
export default function Home() {
|
|
66
|
+
return <h1 className="text-3xl font-bold text-blue-600">Hello</h1>
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
In development mode, Tailwind is reprocessed whenever a `.tsx` file changes (new class names are picked up automatically).
|
|
71
|
+
|
|
72
|
+
### `@` alias
|
|
73
|
+
|
|
74
|
+
If your project has a `tsconfig.json` or `jsconfig.json` with `compilerOptions.paths`, tsx() reads it automatically and passes aliases to all esbuild builds (SSR compilation, hydration bundles, and hot reload):
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"compilerOptions": {
|
|
79
|
+
"paths": {
|
|
80
|
+
"@/*": ["./ui/*"]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This enables imports like `@/components/button` or `@/lib/utils` in both server-rendered and client-hydrated code.
|
|
87
|
+
|
|
88
|
+
### shadcn/ui
|
|
89
|
+
|
|
90
|
+
tsx() works with [shadcn/ui](https://ui.shadcn.com) out of the box. The `@` alias and Tailwind CSS are handled automatically.
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# 1. Install shadcn CLI and init (select "other" framework)
|
|
94
|
+
npx shadcn@latest init
|
|
95
|
+
|
|
96
|
+
# 2. When prompted, configure:
|
|
97
|
+
# - Style: your preference
|
|
98
|
+
# - Base color: your preference
|
|
99
|
+
# - CSS file path: ui/app.css
|
|
100
|
+
# - Import alias: @/ → ./ui/
|
|
101
|
+
# - React hooks: yes
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
// tsconfig.json (generated by shadcn init)
|
|
106
|
+
{
|
|
107
|
+
"compilerOptions": {
|
|
108
|
+
"paths": {
|
|
109
|
+
"@/*": ["./ui/*"]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Add components:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npx shadcn@latest add button card dialog
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Use them in your pages:
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
// ui/pages/page.tsx
|
|
125
|
+
import { Button } from '@/components/ui/button'
|
|
126
|
+
|
|
127
|
+
export default function Home() {
|
|
128
|
+
return <Button variant="outline">Click me</Button>
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
node app.ts
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Backward compatibility
|
|
137
|
+
|
|
138
|
+
`tsx({ dir: './pages/' })` still works. When there is no `pages/` subdirectory under `dir`, the `dir` itself is used as the pages directory.
|
|
139
|
+
|
|
140
|
+
### page.tsx — page component
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
export default function Page({ params, query }: {
|
|
144
|
+
params: { slug: string }
|
|
145
|
+
query: Record<string, string>
|
|
146
|
+
}) {
|
|
147
|
+
return <article><h1>{params.slug}</h1></article>
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### load.ts — data fetching (server-only)
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
export default async function load({ params, query }: {
|
|
155
|
+
params: Record<string, string>
|
|
156
|
+
query: Record<string, string>
|
|
157
|
+
}) {
|
|
158
|
+
const data = await db.query(params.slug)
|
|
159
|
+
return { data } // merged into props passed to page.tsx
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### layout.tsx
|
|
164
|
+
|
|
165
|
+
**Root layout** (`pages/layout.tsx`) — receives `{ children, req, ctx }`:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
export default function RootLayout({ children, req, ctx }: {
|
|
169
|
+
children: React.ReactNode
|
|
170
|
+
req: Request
|
|
171
|
+
ctx: Context
|
|
172
|
+
}) {
|
|
173
|
+
return (
|
|
174
|
+
<html>
|
|
175
|
+
<head><title>App</title></head>
|
|
176
|
+
<body><div id="__weifuwu_root">{children}</div></body>
|
|
177
|
+
</html>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Nested layouts** (`pages/blog/layout.tsx`) — receives only `{ children }`.
|
|
183
|
+
|
|
184
|
+
### route.ts — API (co-located with page)
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
export const POST: Handler = async (req, ctx) => {
|
|
188
|
+
const body = await req.json()
|
|
189
|
+
return Response.json({ ...body, slug: ctx.params.slug })
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### not-found.tsx — 404 page
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
export default function NotFound() {
|
|
197
|
+
return <h1 class="text-4xl">404 – Not Found</h1>
|
|
198
|
+
}
|
|
199
|
+
```
|