weifuwu 0.1.0 → 0.2.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/AGENTS.md CHANGED
@@ -9,7 +9,7 @@ This is the weifuwu HTTP framework — pure Node.js, no build step.
9
9
  ## TypeScript rules
10
10
 
11
11
  - All imports must use explicit `.ts` extensions (e.g. `import { x } from './foo.ts'`)
12
- - Node.js v26+ supports TypeScript natively with `--experimental-strip-types`
12
+ - Node.js v24+ supports TypeScript natively (no `--experimental-strip-types` needed)
13
13
  - No `tsc` compiler needed for runtime (native TS via Node.js)
14
14
 
15
15
  ## Code conventions
@@ -28,9 +28,67 @@ This is the weifuwu HTTP framework — pure Node.js, no build step.
28
28
  - `graphql` + `@graphql-tools/schema` for GraphQL
29
29
  - `ai` (Vercel AI SDK) for AI streaming
30
30
  - `zod` for request validation
31
+ - `react` + `react-dom` for `.tsx()` SSR + hydration
32
+ - `esbuild` for hydration bundle compilation
31
33
  - Node.js built-in `WebSocket` for WebSocket clients
32
34
  - Node.js built-in `zlib` for response compression
33
35
 
36
+ ## tsx() — React SSR + Auto Hydration
37
+
38
+ `tsx({ dir })` — creates a Router from a React pages directory:
39
+
40
+ ```ts
41
+ type TsxRoute = {
42
+ component: React.ComponentType<any>
43
+ props?: Record<string, any> // custom props (merged with params + query)
44
+ source?: string // component source path → enables hydration
45
+ }
46
+
47
+ type TsxHandler = (
48
+ req: Request,
49
+ ctx: Context,
50
+ ) => TsxRoute | Promise<TsxRoute>
51
+ ```
52
+
53
+ - SSR via `react-dom/server` `renderToReadableStream`
54
+ - Props are serialized as `window.__WEIFUWU_PROPS` in HTML
55
+ - Hydration: esbuild lazily compiles source → client bundle served at `/_wfw/client/`
56
+ - Props passed to component: `{ ...props, params, query }` (never `req`/`ctx`)
57
+
58
+ ### File conventions
59
+
60
+ ```
61
+ pages/
62
+ page.tsx → GET / (React component, default export)
63
+ layout.tsx → root layout (wraps all pages)
64
+ about/page.tsx → GET /about
65
+ blog/[slug]/
66
+ page.tsx → GET /blog/:slug
67
+ load.ts → data fetching (server-only, default export)
68
+ route.ts → POST /blog/:slug (API, named exports GET/POST/...)
69
+ blog/layout.tsx → /blog/* layout (auto-wraps blog pages)
70
+ ```
71
+
72
+ - `page.tsx` — default export = React component, receives `{ params, query }` + load data
73
+ - `load.ts` — default export = async function `({ params, query }) => props`, server-only
74
+ - `layout.tsx` — default export = React component with `{ children }`, auto-nested by directory level
75
+ - `route.ts` — named exports `GET`/`POST`/`PUT`/`DELETE`/`PATCH`, standard Handler signature
76
+
77
+ ### Usage
78
+
79
+ ```ts
80
+ import { serve, Router } from 'weifuwu'
81
+ import { tsx } from 'weifuwu/tsx'
82
+
83
+ const r = new Router()
84
+ r.use('/', await tsx({ dir: './pages/' }))
85
+
86
+ // Other features coexist
87
+ r.ws('/chat', { message(ws, _, data) { ws.send(data) } })
88
+
89
+ serve(r.handler())
90
+ ```
91
+
34
92
  ## Testing
35
93
 
36
94
  ```ts#test/example.test.ts
package/compress.ts CHANGED
@@ -60,7 +60,7 @@ export function compress(options?: CompressOptions): Middleware {
60
60
  headers.delete('Content-Range')
61
61
  headers.set('Vary', 'Accept-Encoding')
62
62
 
63
- return new Response(compressed, {
63
+ return new Response(compressed as BodyInit, {
64
64
  status: res.status,
65
65
  statusText: res.statusText,
66
66
  headers,
package/index.ts CHANGED
@@ -3,6 +3,8 @@ export { serve } from './serve.ts'
3
3
  export type { ServeOptions, Server } from './serve.ts'
4
4
  export { Router } from './router.ts'
5
5
  export type { WebSocketHandler, GraphQLOptions, AIHandler } from './router.ts'
6
+ export { tsx } from './tsx.ts'
7
+ export type { TsxOptions } from './tsx.ts'
6
8
  export { auth, cors, logger } from './middleware.ts'
7
9
  export type { AuthOptions, CORSOptions, LoggerOptions } from './middleware.ts'
8
10
  export { serveStatic } from './static.ts'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weifuwu",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Web-standard HTTP framework for Node.js — (req, ctx) => Response",
5
5
  "main": "index.ts",
6
6
  "scripts": {
@@ -9,13 +9,19 @@
9
9
  "dependencies": {
10
10
  "@graphql-tools/schema": "^10",
11
11
  "ai": "^6",
12
+ "esbuild": "^0.28.0",
12
13
  "graphql": "^16",
14
+ "react": "^19",
15
+ "react-dom": "^19",
13
16
  "ws": "^8",
14
17
  "zod": "^4.4.3"
15
18
  },
16
19
  "type": "module",
17
20
  "license": "MIT",
18
21
  "devDependencies": {
19
- "@types/ws": "^8.18.1"
22
+ "@types/react": "^19",
23
+ "@types/react-dom": "^19",
24
+ "@types/ws": "^8.18.1",
25
+ "typescript": "^6.0.3"
20
26
  }
21
27
  }
package/serve.ts CHANGED
@@ -39,7 +39,7 @@ export function createRequest(req: IncomingMessage, body: Buffer): [Request, Rec
39
39
  method: req.method?.toUpperCase() ?? 'GET',
40
40
  headers,
41
41
  body: (req.method !== 'GET' && req.method !== 'HEAD' && body.length > 0)
42
- ? body
42
+ ? body as BodyInit
43
43
  : null,
44
44
  })
45
45
 
package/static.ts CHANGED
@@ -73,7 +73,7 @@ export function serveStatic(root: string, options?: ServeStaticOptions): Handler
73
73
  }
74
74
 
75
75
  const stream = fileHandle.readableWebStream()
76
- return new Response(stream, { headers })
76
+ return new Response(stream as unknown as BodyInit, { headers })
77
77
  } catch (err) {
78
78
  if (fileHandle) await fileHandle.close().catch(() => {})
79
79
  if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
@@ -0,0 +1,3 @@
1
+ export default function About() {
2
+ return <h1>About</h1>
3
+ }
@@ -0,0 +1,3 @@
1
+ export default async function load({ params }: { params: { slug: string } }) {
2
+ return { post: { title: `Post: ${params.slug}` } }
3
+ }
@@ -0,0 +1,3 @@
1
+ export default function Post({ post, params }: { post?: { title: string }; params: { slug: string } }) {
2
+ return <article><h1>{post?.title ?? params.slug}</h1></article>
3
+ }
@@ -0,0 +1,7 @@
1
+ export async function GET(req: Request, ctx: { params: { slug: string } }) {
2
+ return Response.json({ method: 'GET', slug: ctx.params.slug })
3
+ }
4
+
5
+ export async function POST(req: Request, ctx: { params: { slug: string } }) {
6
+ return Response.json({ method: 'POST', slug: ctx.params.slug })
7
+ }
@@ -0,0 +1,3 @@
1
+ export default function BlogLayout({ children }: { children: any }) {
2
+ return <div className="blog-layout">{children}</div>
3
+ }
@@ -0,0 +1,12 @@
1
+ export default function RootLayout({ children }: { children: any }) {
2
+ return (
3
+ <html>
4
+ <head>
5
+ <title>App</title>
6
+ </head>
7
+ <body>
8
+ <div id="__weifuwu_root">{children}</div>
9
+ </body>
10
+ </html>
11
+ )
12
+ }
@@ -0,0 +1,3 @@
1
+ export default function Home() {
2
+ return <h1>Home</h1>
3
+ }
@@ -0,0 +1,285 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { tsx } from '../tsx.ts'
4
+ import { serve, Router } from '../index.ts'
5
+ import type { Server } from '../serve.ts'
6
+
7
+ const fixtures = './test/fixtures/pages'
8
+
9
+ describe('tsx()', () => {
10
+ describe('SSR rendering', () => {
11
+ it('renders root page', async () => {
12
+ const r = await tsx({ dir: fixtures })
13
+ const res = await r.handler()(
14
+ new Request('http://localhost/'),
15
+ { params: {}, query: {} },
16
+ )
17
+ assert.equal(res.status, 200)
18
+
19
+ const html = await res.text()
20
+ assert.match(html, /<!DOCTYPE html>/)
21
+ assert.match(html, /<h1>Home<\/h1>/)
22
+ })
23
+
24
+ it('renders nested static page', async () => {
25
+ const r = await tsx({ dir: fixtures })
26
+ const res = await r.handler()(
27
+ new Request('http://localhost/about'),
28
+ { params: {}, query: {} },
29
+ )
30
+ assert.equal(res.status, 200)
31
+
32
+ const html = await res.text()
33
+ assert.match(html, /<h1>About<\/h1>/)
34
+ })
35
+
36
+ it('renders dynamic route with params', async () => {
37
+ const r = await tsx({ dir: fixtures })
38
+ const res = await r.handler()(
39
+ new Request('http://localhost/blog/hello-world'),
40
+ { params: { slug: 'hello-world' }, query: {} },
41
+ )
42
+ assert.equal(res.status, 200)
43
+
44
+ const html = await res.text()
45
+ assert.match(html, /hello-world/)
46
+ })
47
+
48
+ it('passes query params', async () => {
49
+ const r = await tsx({ dir: fixtures })
50
+ const res = await r.handler()(
51
+ new Request('http://localhost/?foo=bar'),
52
+ { params: {}, query: { foo: 'bar' } },
53
+ )
54
+ assert.equal(res.status, 200)
55
+ assert.equal((await res.text()).includes('Home'), true)
56
+ })
57
+
58
+ it('returns 404 for non-existent route', async () => {
59
+ const r = await tsx({ dir: fixtures })
60
+ const res = await r.handler()(
61
+ new Request('http://localhost/nonexistent'),
62
+ { params: {}, query: {} },
63
+ )
64
+ assert.equal(res.status, 404)
65
+ })
66
+ })
67
+
68
+ describe('data loading (load.ts)', () => {
69
+ it('calls load() and passes data as props', async () => {
70
+ const r = await tsx({ dir: fixtures })
71
+ const res = await r.handler()(
72
+ new Request('http://localhost/blog/my-post'),
73
+ { params: { slug: 'my-post' }, query: {} },
74
+ )
75
+ assert.equal(res.status, 200)
76
+
77
+ const html = await res.text()
78
+ assert.match(html, /Post: my-post/)
79
+ })
80
+
81
+ it('serializes props to __WEIFUWU_PROPS', async () => {
82
+ const r = await tsx({ dir: fixtures })
83
+ const res = await r.handler()(
84
+ new Request('http://localhost/blog/my-post'),
85
+ { params: { slug: 'my-post' }, query: {} },
86
+ )
87
+ const html = await res.text()
88
+ assert.match(html, /__WEIFUWU_PROPS/)
89
+ assert.match(html, /Post: my-post/)
90
+ })
91
+ })
92
+
93
+ describe('layout chain', () => {
94
+ it('wraps page with root layout', async () => {
95
+ const r = await tsx({ dir: fixtures })
96
+ const res = await r.handler()(
97
+ new Request('http://localhost/'),
98
+ { params: {}, query: {} },
99
+ )
100
+
101
+ const html = await res.text()
102
+ assert.match(html, /<html>/)
103
+ assert.match(html, /<title>App<\/title>/)
104
+ assert.match(html, /__weifuwu_root/)
105
+ })
106
+
107
+ it('wraps blog pages with nested layouts', async () => {
108
+ const r = await tsx({ dir: fixtures })
109
+ const res = await r.handler()(
110
+ new Request('http://localhost/blog/some-post'),
111
+ { params: { slug: 'some-post' }, query: {} },
112
+ )
113
+
114
+ const html = await res.text()
115
+ assert.match(html, /blog-layout/)
116
+ })
117
+ })
118
+
119
+ describe('route.ts API', () => {
120
+ it('GET returns SSR page, not route.ts', async () => {
121
+ const r = await tsx({ dir: fixtures })
122
+ const res = await r.handler()(
123
+ new Request('http://localhost/blog/my-route'),
124
+ { params: { slug: 'my-route' }, query: {} },
125
+ )
126
+ // GET is handled by page.tsx SSR, not route.ts
127
+ const html = await res.text()
128
+ assert.match(html, /<!DOCTYPE html>/)
129
+ assert.match(html, /my-route/)
130
+ })
131
+
132
+ it('handles POST from route.ts', async () => {
133
+ const r = await tsx({ dir: fixtures })
134
+ const res = await r.handler()(
135
+ new Request('http://localhost/blog/my-route', { method: 'POST' }),
136
+ { params: { slug: 'my-route' }, query: {} },
137
+ )
138
+
139
+ const data = await res.json() as any
140
+ assert.equal(data.method, 'POST')
141
+ assert.equal(data.slug, 'my-route')
142
+ })
143
+ })
144
+
145
+ describe('hydration', () => {
146
+ it('injects hydration scripts', async () => {
147
+ const r = await tsx({ dir: fixtures })
148
+ const res = await r.handler()(
149
+ new Request('http://localhost/'),
150
+ { params: {}, query: {} },
151
+ )
152
+
153
+ const html = await res.text()
154
+ assert.match(html, /<script type="module"/)
155
+ assert.match(html, /__wfw\/client\//)
156
+ })
157
+
158
+ it('serves hydration bundle', async () => {
159
+ const r = await tsx({ dir: fixtures })
160
+ const res1 = await r.handler()(
161
+ new Request('http://localhost/'),
162
+ { params: {}, query: {} },
163
+ )
164
+ const html = await res1.text()
165
+ const match = html.match(/src="(\/__wfw\/client\/[^"]+)"/)
166
+ assert.ok(match)
167
+
168
+ const res2 = await r.handler()(
169
+ new Request(`http://localhost${match[1]}`),
170
+ { params: {}, query: {} },
171
+ )
172
+ assert.equal(res2.status, 200)
173
+ const js = await res2.text()
174
+ assert.match(js, /hydrateRoot/)
175
+ })
176
+ })
177
+
178
+ describe('mounting via router.use()', () => {
179
+ it('works as sub-router', async () => {
180
+ const pages = await tsx({ dir: fixtures })
181
+ const main = new Router()
182
+ main.use('/app', pages)
183
+
184
+ const res = await main.handler()(
185
+ new Request('http://localhost/app/about'),
186
+ { params: {}, query: {} },
187
+ )
188
+ assert.equal(res.status, 200)
189
+ const html = await res.text()
190
+ assert.match(html, /<h1>About<\/h1>/)
191
+ })
192
+
193
+ it('coexists with other Router features', async () => {
194
+ const pages = await tsx({ dir: fixtures })
195
+ const main = new Router()
196
+ main.use('/app', pages)
197
+ main.get('/api/ping', () => Response.json({ ok: true }))
198
+
199
+ const res1 = await main.handler()(
200
+ new Request('http://localhost/app/about'),
201
+ { params: {}, query: {} },
202
+ )
203
+ assert.equal(res1.status, 200)
204
+ const html = await res1.text()
205
+ assert.match(html, /<h1>About<\/h1>/)
206
+
207
+ const res2 = await main.handler()(
208
+ new Request('http://localhost/api/ping'),
209
+ { params: {}, query: {} },
210
+ )
211
+ const data = await res2.json() as any
212
+ assert.equal(data.ok, true)
213
+ })
214
+ })
215
+
216
+ describe('end-to-end via serve()', () => {
217
+ let server: Server
218
+ let url: string
219
+
220
+ before(async () => {
221
+ const pages = await tsx({ dir: fixtures })
222
+ server = serve(pages.handler(), { port: 0 })
223
+ await server.ready
224
+ url = `http://localhost:${server.port}`
225
+ })
226
+
227
+ after(() => server.stop())
228
+
229
+ it('serves page via HTTP', async () => {
230
+ const res = await fetch(`${url}/about`)
231
+ assert.equal(res.status, 200)
232
+ const html = await res.text()
233
+ assert.match(html, /<h1>About<\/h1>/)
234
+ })
235
+
236
+ it('serves hydration bundle via HTTP', async () => {
237
+ const res = await fetch(`${url}/`)
238
+ const html = await res.text()
239
+ const match = html.match(/src="(\/__wfw\/client\/[^"]+)"/)
240
+ assert.ok(match)
241
+
242
+ const bundleRes = await fetch(`${url}${match[1]}`)
243
+ assert.equal(bundleRes.status, 200)
244
+ const js = await bundleRes.text()
245
+ assert.match(js, /hydrateRoot/)
246
+ })
247
+
248
+ it('serves dynamic route and API', async () => {
249
+ const res = await fetch(`${url}/blog/test-article`)
250
+ assert.equal(res.status, 200)
251
+ const html = await res.text()
252
+ assert.match(html, /Post: test-article/)
253
+ })
254
+ })
255
+
256
+ describe('edge cases', () => {
257
+ it('non-existent directory returns empty router', async () => {
258
+ const r = await tsx({ dir: './test/fixtures/nonexistent' })
259
+ const res = await r.handler()(
260
+ new Request('http://localhost/'),
261
+ { params: {}, query: {} },
262
+ )
263
+ assert.equal(res.status, 404)
264
+ })
265
+
266
+ it('empty directory returns empty router', async () => {
267
+ const r = await tsx({ dir: './test/fixtures/empty' })
268
+ const res = await r.handler()(
269
+ new Request('http://localhost/'),
270
+ { params: {}, query: {} },
271
+ )
272
+ assert.equal(res.status, 404)
273
+ })
274
+
275
+ it('handles page.ts without tsx extension', async () => {
276
+ // about/ has page.tsx only, but scanPages also checks page.ts
277
+ const r = await tsx({ dir: fixtures })
278
+ const res = await r.handler()(
279
+ new Request('http://localhost/about'),
280
+ { params: {}, query: {} },
281
+ )
282
+ assert.equal(res.status, 200)
283
+ })
284
+ })
285
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "jsx": "react-jsx",
9
+ "skipLibCheck": true,
10
+ "allowImportingTsExtensions": true
11
+ },
12
+ "include": ["*.ts", "test/**/*.ts"]
13
+ }
package/tsx.ts ADDED
@@ -0,0 +1,354 @@
1
+ import { createElement } from 'react'
2
+ import { renderToReadableStream } from 'react-dom/server'
3
+ import * as esbuild from 'esbuild'
4
+ import { readdirSync, statSync, existsSync, mkdirSync } from 'node:fs'
5
+ import { join, relative, resolve, sep, dirname } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+ import { createHash } from 'node:crypto'
8
+ import { Router } from './router.ts'
9
+ import type { Handler } from './types.ts'
10
+
11
+ export interface TsxOptions {
12
+ dir: string
13
+ }
14
+
15
+ type PageEntry = {
16
+ route: string
17
+ entryPath: string
18
+ loadPath?: string
19
+ layouts: string[]
20
+ routePath?: string
21
+ }
22
+
23
+ // ── helpers ────────────────────────────────────────────────────────────────
24
+
25
+ function id(s: string): string {
26
+ return createHash('md5').update(s).digest('hex').slice(0, 8)
27
+ }
28
+
29
+ function concatUint8(chunks: Uint8Array[]): Uint8Array {
30
+ const len = chunks.reduce((a, c) => a + c.length, 0)
31
+ const out = new Uint8Array(len)
32
+ let off = 0
33
+ for (const c of chunks) {
34
+ out.set(c, off)
35
+ off += c.length
36
+ }
37
+ return out
38
+ }
39
+
40
+ async function readStream(stream: ReadableStream): Promise<string> {
41
+ const chunks: Uint8Array[] = []
42
+ const reader = stream.getReader()
43
+ while (true) {
44
+ const { done, value } = await reader.read()
45
+ if (done) break
46
+ chunks.push(value)
47
+ }
48
+ return new TextDecoder().decode(concatUint8(chunks))
49
+ }
50
+
51
+ // ── file scanning ──────────────────────────────────────────────────────────
52
+
53
+ function scanPages(dir: string): PageEntry[] {
54
+ const pages: PageEntry[] = []
55
+
56
+ function walk(current: string) {
57
+ let entries: string[]
58
+ try {
59
+ entries = readdirSync(current)
60
+ } catch {
61
+ return
62
+ }
63
+
64
+ const dirs: string[] = []
65
+ for (const name of entries) {
66
+ const full = join(current, name)
67
+ const st = statSync(full)
68
+ if (st.isDirectory()) {
69
+ if (!name.startsWith('.')) dirs.push(full)
70
+ }
71
+ }
72
+
73
+ // Check for page.tsx in this directory
74
+ const pagePath = join(current, 'page.tsx')
75
+ const tsPagePath = join(current, 'page.ts')
76
+ let entryPath = ''
77
+ if (existsSync(pagePath)) {
78
+ entryPath = pagePath
79
+ } else if (existsSync(tsPagePath)) {
80
+ entryPath = tsPagePath
81
+ }
82
+
83
+ if (entryPath) {
84
+ let relPath = relative(dir, entryPath).replace(sep, '/')
85
+ // Remove page.tsx / page.ts suffix
86
+ relPath = relPath.replace(/\/page\.tsx?$/, '')
87
+ relPath = relPath.replace(/^page\.tsx?$/, '')
88
+
89
+ const route = filePathToRoute(relPath)
90
+ const layouts = resolveLayouts(current, dir)
91
+ const loadPath = existsSync(join(current, 'load.ts'))
92
+ ? join(current, 'load.ts') : undefined
93
+ const rPath = existsSync(join(current, 'route.ts'))
94
+ ? join(current, 'route.ts') : undefined
95
+
96
+ pages.push({
97
+ route,
98
+ entryPath,
99
+ loadPath,
100
+ layouts,
101
+ routePath: rPath,
102
+ })
103
+ }
104
+
105
+ for (const d of dirs) walk(d)
106
+ }
107
+
108
+ walk(dir)
109
+ return pages
110
+ }
111
+
112
+ function filePathToRoute(relPath: string): string {
113
+ let route = relPath.replace(/\\/g, '/')
114
+ // Remove page.tsx suffix => already done in scanPages
115
+ // [...rest] → *
116
+ route = route.replace(/\[\.\.\.(\w+)\]/g, '*')
117
+ // [slug] → :slug
118
+ route = route.replace(/\[(\w+)\]/g, ':$1')
119
+ return route.startsWith('/') ? route : '/' + route
120
+ }
121
+
122
+ function resolveLayouts(dir: string, pagesDir: string): string[] {
123
+ const layouts: string[] = []
124
+ let current = dir
125
+
126
+ while (current.startsWith(pagesDir)) {
127
+ const p = join(current, 'layout.tsx')
128
+ if (existsSync(p)) {
129
+ layouts.push(p)
130
+ }
131
+ const parent = dirname(current)
132
+ if (parent === current) break
133
+ current = parent
134
+ }
135
+
136
+ // Return outermost first
137
+ return layouts.reverse()
138
+ }
139
+
140
+ // ── compilation ────────────────────────────────────────────────────────────
141
+
142
+ const esbId = '__weifuwu_tsx_build'
143
+
144
+ async function compileAll(
145
+ files: string[],
146
+ outDir: string,
147
+ platform: 'node' | 'browser',
148
+ ): Promise<void> {
149
+ const entryPoints: Record<string, string> = {}
150
+ for (const f of files) {
151
+ entryPoints[id(f)] = f
152
+ }
153
+
154
+ await esbuild.build({
155
+ entryPoints,
156
+ outdir: outDir,
157
+ format: 'esm',
158
+ platform: platform === 'node' ? 'node' : 'browser',
159
+ jsx: 'automatic',
160
+ jsxImportSource: 'react',
161
+ bundle: platform === 'browser',
162
+ write: true,
163
+ allowOverwrite: true,
164
+ })
165
+ }
166
+
167
+ function compiledUrl(filePath: string, outDir: string): string {
168
+ const hash = id(join(outDir, id(filePath)))
169
+ const p = join(outDir, id(filePath) + '.js')
170
+ return pathToFileURL(p).href
171
+ }
172
+
173
+ // ── client bundle (lazy) ───────────────────────────────────────────────────
174
+
175
+ const clientBundleCache = new Map<string, Uint8Array>()
176
+ const clientRouteLog = new WeakMap<object, Set<string>>()
177
+
178
+ async function getOrBuildClientBundle(
179
+ entryPath: string,
180
+ layoutPaths: string[],
181
+ pagesDir: string,
182
+ router: Router,
183
+ ): Promise<{ url: string } | null> {
184
+ const key = id(entryPath)
185
+ const url = `/__wfw/client/${key}.js`
186
+
187
+ if (!clientRouteLog.get(router)?.has(url)) {
188
+ let buf = clientBundleCache.get(key)
189
+
190
+ if (!buf) {
191
+ try {
192
+ const layoutsImport = layoutPaths.map((p, i) =>
193
+ `import L${i} from${JSON.stringify(p)};`,
194
+ ).join('')
195
+ const layoutsWrap = layoutPaths.map((_, i) => {
196
+ const idx = layoutPaths.length - 1 - i
197
+ return `el=createElement(L${idx},null,el);`
198
+ }).join('')
199
+
200
+ const code = [
201
+ `import{hydrateRoot}from'react-dom/client';`,
202
+ `import{createElement}from'react';`,
203
+ `import P from${JSON.stringify(entryPath)};`,
204
+ layoutsImport,
205
+ `const p=window.__WEIFUWU_PROPS;`,
206
+ `let el=createElement(P,p);`,
207
+ layoutsWrap,
208
+ `hydrateRoot(document.getElementById('__weifuwu_root'),el);`,
209
+ ].join('')
210
+
211
+ const result = await esbuild.build({
212
+ stdin: { contents: code, loader: 'tsx', resolveDir: pagesDir },
213
+ bundle: true,
214
+ format: 'esm',
215
+ jsx: 'automatic',
216
+ jsxImportSource: 'react',
217
+ write: false,
218
+ minify: true,
219
+ })
220
+
221
+ buf = result.outputFiles[0].contents
222
+ clientBundleCache.set(key, buf)
223
+ } catch (err) {
224
+ console.error('hydration bundle failed:', err)
225
+ return null
226
+ }
227
+ }
228
+
229
+ router.get(url, () => new Response(buf! as BodyInit, {
230
+ headers: { 'content-type': 'application/javascript; charset=utf-8' },
231
+ }))
232
+
233
+ const set = clientRouteLog.get(router) ?? new Set()
234
+ set.add(url)
235
+ clientRouteLog.set(router, set)
236
+ }
237
+
238
+ return { url }
239
+ }
240
+
241
+ // ── SSR handler ────────────────────────────────────────────────────────────
242
+
243
+ function makeSsrHandler(
244
+ Component: any,
245
+ loadFn: any | undefined,
246
+ layouts: any[],
247
+ entryPath: string,
248
+ layoutPaths: string[],
249
+ pagesDir: string,
250
+ router: Router,
251
+ ): Handler {
252
+ return async (req, ctx) => {
253
+ const loadProps = loadFn ? await loadFn({ params: ctx.params, query: ctx.query }) : {}
254
+ const allProps = { ...loadProps, params: ctx.params, query: ctx.query }
255
+
256
+ let element = createElement(Component, allProps)
257
+ for (let i = layouts.length - 1; i >= 0; i--) {
258
+ element = createElement(layouts[i], null, element)
259
+ }
260
+
261
+ const stream = await renderToReadableStream(element)
262
+ const body = await readStream(stream)
263
+
264
+ const scripts: string[] = []
265
+ scripts.push(`<script>window.__WEIFUWU_PROPS=${JSON.stringify(allProps)}</script>`)
266
+
267
+ const bundle = await getOrBuildClientBundle(entryPath, layoutPaths, pagesDir, router)
268
+ if (bundle) {
269
+ scripts.push(`<script type="module" src="${bundle.url}"></script>`)
270
+ }
271
+
272
+ const html = `<!DOCTYPE html>\n${body}\n${scripts.join('\n')}`
273
+
274
+ return new Response(html, {
275
+ headers: { 'content-type': 'text/html; charset=utf-8' },
276
+ })
277
+ }
278
+ }
279
+
280
+ // ── main export ────────────────────────────────────────────────────────────
281
+
282
+ export async function tsx(options: TsxOptions): Promise<Router> {
283
+ const pagesDir = resolve(options.dir)
284
+ const outDir = join(pagesDir, '..', '.weifuwu', 'ssr')
285
+ const clientDir = join(pagesDir, '..', '.weifuwu', 'client')
286
+
287
+ // 1. Scan
288
+ const pages = scanPages(pagesDir)
289
+ if (pages.length === 0) return new Router()
290
+
291
+ // 2. Collect all files to compile
292
+ const allFiles = new Set<string>()
293
+ const loadMap = new Map<string, string>()
294
+ const layoutMap = new Map<string, string[]>()
295
+
296
+ for (const p of pages) {
297
+ allFiles.add(p.entryPath)
298
+ if (p.loadPath) {
299
+ allFiles.add(p.loadPath)
300
+ loadMap.set(p.entryPath, p.loadPath)
301
+ }
302
+ for (const lp of p.layouts) allFiles.add(lp)
303
+ layoutMap.set(p.entryPath, [...p.layouts])
304
+
305
+ if (p.routePath) allFiles.add(p.routePath)
306
+ }
307
+
308
+ // 3. Compile for SSR
309
+ mkdirSync(outDir, { recursive: true })
310
+ await compileAll([...allFiles], outDir, 'node')
311
+
312
+ // 4. Import and register routes
313
+ const router = new Router()
314
+
315
+ for (const p of pages) {
316
+ const url = compiledUrl(p.entryPath, outDir)
317
+ const mod = await import(url)
318
+ const Component = mod.default
319
+
320
+ let loadFn: any
321
+ if (p.loadPath) {
322
+ const loadUrl = compiledUrl(p.loadPath, outDir)
323
+ const modLoad = await import(loadUrl)
324
+ loadFn = modLoad.default
325
+ }
326
+
327
+ const layoutComponents: any[] = []
328
+ for (const lp of p.layouts) {
329
+ const lUrl = compiledUrl(lp, outDir)
330
+ const modL = await import(lUrl)
331
+ layoutComponents.push(modL.default)
332
+ }
333
+
334
+ const handler = makeSsrHandler(
335
+ Component, loadFn, layoutComponents,
336
+ p.entryPath, p.layouts, pagesDir, router,
337
+ )
338
+ router.get(p.route, handler)
339
+
340
+ // route.ts — skip GET (handled by page.tsx SSR)
341
+ if (p.routePath) {
342
+ const rUrl = compiledUrl(p.routePath, outDir)
343
+ const modR = await import(rUrl)
344
+ const methods = (['POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const)
345
+ for (const method of methods) {
346
+ if (modR[method]) {
347
+ router.route(method, p.route, modR[method])
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ return router
354
+ }