weifuwu 0.1.0 → 0.2.0
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 +59 -1
- package/index.ts +2 -0
- package/package.json +8 -2
- package/test/fixtures/pages/about/page.tsx +3 -0
- package/test/fixtures/pages/blog/[slug]/load.ts +3 -0
- package/test/fixtures/pages/blog/[slug]/page.tsx +3 -0
- package/test/fixtures/pages/blog/[slug]/route.ts +7 -0
- package/test/fixtures/pages/blog/layout.tsx +3 -0
- package/test/fixtures/pages/layout.tsx +12 -0
- package/test/fixtures/pages/page.tsx +3 -0
- package/test/tsx.test.ts +285 -0
- package/tsconfig.json +13 -0
- package/tsx.ts +354 -0
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
|
|
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/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.
|
|
3
|
+
"version": "0.2.0",
|
|
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/
|
|
22
|
+
"@types/react": "^19",
|
|
23
|
+
"@types/react-dom": "^19",
|
|
24
|
+
"@types/ws": "^8.18.1",
|
|
25
|
+
"typescript": "^6.0.3"
|
|
20
26
|
}
|
|
21
27
|
}
|
|
@@ -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
|
+
}
|
package/test/tsx.test.ts
ADDED
|
@@ -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
|
+
}
|