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.
@@ -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
+ ```