weifuwu 0.2.1 → 0.2.3
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 +168 -0
- package/dist/compress.d.ts +6 -0
- package/dist/cookie.d.ts +12 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1420 -0
- package/dist/middleware.d.ts +21 -0
- package/dist/rate-limit.d.ts +8 -0
- package/dist/router.d.ts +55 -0
- package/dist/serve.d.ts +19 -0
- package/dist/static.d.ts +7 -0
- package/dist/tsx.d.ts +17 -0
- package/dist/types.d.ts +9 -0
- package/dist/upload.d.ts +14 -0
- package/dist/validate.d.ts +9 -0
- package/package.json +14 -2
- package/AGENTS.md +0 -105
- package/compress.ts +0 -69
- package/cookie.ts +0 -58
- package/index.ts +0 -21
- package/middleware.ts +0 -178
- package/rate-limit.ts +0 -68
- package/router.ts +0 -702
- package/serve.ts +0 -126
- package/static.ts +0 -113
- package/test/compress.test.ts +0 -106
- package/test/cookie.test.ts +0 -79
- package/test/fixtures/pages/about/page.tsx +0 -3
- package/test/fixtures/pages/blog/[slug]/load.ts +0 -3
- package/test/fixtures/pages/blog/[slug]/page.tsx +0 -3
- package/test/fixtures/pages/blog/[slug]/route.ts +0 -7
- package/test/fixtures/pages/blog/layout.tsx +0 -3
- package/test/fixtures/pages/layout.tsx +0 -12
- package/test/fixtures/pages/page.tsx +0 -3
- package/test/middleware.test.ts +0 -407
- package/test/rate-limit.test.ts +0 -94
- package/test/static.test.ts +0 -93
- package/test/tsx.test.ts +0 -285
- package/test/unode.test.ts +0 -401
- package/test/upload.test.ts +0 -130
- package/test/validate.test.ts +0 -133
- package/tsconfig.json +0 -13
- package/tsx.ts +0 -354
- package/types.ts +0 -23
- package/upload.ts +0 -101
- package/validate.ts +0 -88
package/test/upload.test.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { describe, it, before, after } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { readFile, rm, mkdir } from 'node:fs/promises'
|
|
4
|
-
import { resolve } from 'node:path'
|
|
5
|
-
import { tmpdir } from 'node:os'
|
|
6
|
-
import { Router } from '../router.ts'
|
|
7
|
-
import { upload } from '../upload.ts'
|
|
8
|
-
|
|
9
|
-
function createFormData(fields?: Record<string, string>, files?: Record<string, { name: string; data: string; type?: string }>): [Request, string] {
|
|
10
|
-
const boundary = '----boundary123'
|
|
11
|
-
const parts: string[] = []
|
|
12
|
-
|
|
13
|
-
for (const [key, value] of Object.entries(fields ?? {})) {
|
|
14
|
-
parts.push(`--${boundary}`)
|
|
15
|
-
parts.push(`Content-Disposition: form-data; name="${key}"`)
|
|
16
|
-
parts.push('')
|
|
17
|
-
parts.push(value)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
for (const [key, file] of Object.entries(files ?? {})) {
|
|
21
|
-
parts.push(`--${boundary}`)
|
|
22
|
-
parts.push(`Content-Disposition: form-data; name="${key}"; filename="${file.name}"`)
|
|
23
|
-
if (file.type) parts.push(`Content-Type: ${file.type}`)
|
|
24
|
-
parts.push('')
|
|
25
|
-
parts.push(file.data)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
parts.push(`--${boundary}--`)
|
|
29
|
-
const body = parts.join('\r\n')
|
|
30
|
-
|
|
31
|
-
const req = new Request('http://localhost/upload', {
|
|
32
|
-
method: 'POST',
|
|
33
|
-
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
|
|
34
|
-
body,
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
return [req, boundary]
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe('upload', () => {
|
|
41
|
-
it('parses fields from multipart form', async () => {
|
|
42
|
-
const r = new Router()
|
|
43
|
-
.post('/upload', upload(), (req, ctx) => Response.json(ctx.parsed?.fields))
|
|
44
|
-
|
|
45
|
-
const [req] = createFormData({ title: 'hello', desc: 'world' })
|
|
46
|
-
const res = await r.handler()(req, { params: {}, query: {} })
|
|
47
|
-
assert.equal(res.status, 200)
|
|
48
|
-
const data = await res.json() as Record<string, string>
|
|
49
|
-
assert.deepEqual(data, { title: 'hello', desc: 'world' })
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('parses files in memory', async () => {
|
|
53
|
-
const r = new Router()
|
|
54
|
-
.post('/upload', upload(), (req, ctx) => {
|
|
55
|
-
const files = ctx.parsed?.files as Record<string, unknown>
|
|
56
|
-
return Response.json(files)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
const [req] = createFormData({}, {
|
|
60
|
-
avatar: { name: 'photo.png', data: 'fakeimagedata', type: 'image/png' },
|
|
61
|
-
})
|
|
62
|
-
const res = await r.handler()(req, { params: {}, query: {} })
|
|
63
|
-
assert.equal(res.status, 200)
|
|
64
|
-
const data = await res.json() as Record<string, any>
|
|
65
|
-
const file = data.avatar
|
|
66
|
-
assert.equal(file.name, 'photo.png')
|
|
67
|
-
assert.equal(file.type, 'image/png')
|
|
68
|
-
assert.ok(file.size)
|
|
69
|
-
assert.ok(file.buffer)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('saves files to disk when dir is set', async () => {
|
|
73
|
-
const uploadDir = resolve(tmpdir(), 'weifuwu-upload-test')
|
|
74
|
-
await mkdir(uploadDir, { recursive: true })
|
|
75
|
-
|
|
76
|
-
const r = new Router()
|
|
77
|
-
.post('/upload', upload({ dir: uploadDir }), (req, ctx) => {
|
|
78
|
-
const files = ctx.parsed?.files as Record<string, any>
|
|
79
|
-
return Response.json(files)
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
const [req] = createFormData({}, {
|
|
83
|
-
doc: { name: 'test.txt', data: 'file content' },
|
|
84
|
-
})
|
|
85
|
-
const res = await r.handler()(req, { params: {}, query: {} })
|
|
86
|
-
const data = await res.json() as Record<string, any>
|
|
87
|
-
assert.ok(data.doc.path)
|
|
88
|
-
const saved = await readFile(data.doc.path, 'utf-8')
|
|
89
|
-
assert.equal(saved, 'file content')
|
|
90
|
-
await rm(uploadDir, { recursive: true, force: true })
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('rejects oversized files', async () => {
|
|
94
|
-
const r = new Router()
|
|
95
|
-
.post('/upload', upload({ maxFileSize: 5 }), () => new Response('ok'))
|
|
96
|
-
|
|
97
|
-
const [req] = createFormData({}, {
|
|
98
|
-
big: { name: 'big.txt', data: 'too large content' },
|
|
99
|
-
})
|
|
100
|
-
const res = await r.handler()(req, { params: {}, query: {} })
|
|
101
|
-
assert.equal(res.status, 413)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('rejects disallowed file types', async () => {
|
|
105
|
-
const r = new Router()
|
|
106
|
-
.post('/upload', upload({ allowedTypes: ['image/png'] }), () => new Response('ok'))
|
|
107
|
-
|
|
108
|
-
const [req] = createFormData({}, {
|
|
109
|
-
bad: { name: 'script.exe', data: 'evil', type: 'application/x-msdownload' },
|
|
110
|
-
})
|
|
111
|
-
const res = await r.handler()(req, { params: {}, query: {} })
|
|
112
|
-
assert.equal(res.status, 415)
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('passes through non-multipart requests', async () => {
|
|
116
|
-
let reached = false
|
|
117
|
-
const r = new Router()
|
|
118
|
-
.post('/upload', upload(), (req, ctx, next) => {
|
|
119
|
-
reached = true
|
|
120
|
-
return next(req, ctx)
|
|
121
|
-
}, () => new Response('ok'))
|
|
122
|
-
|
|
123
|
-
const res = await r.handler()(
|
|
124
|
-
new Request('http://localhost/upload', { method: 'POST', body: 'plain text' }),
|
|
125
|
-
{ params: {}, query: {} },
|
|
126
|
-
)
|
|
127
|
-
assert.equal(res.status, 200)
|
|
128
|
-
assert.equal(reached, true)
|
|
129
|
-
})
|
|
130
|
-
})
|
package/test/validate.test.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import { z } from 'zod'
|
|
4
|
-
import { Router } from '../router.ts'
|
|
5
|
-
import { validate } from '../validate.ts'
|
|
6
|
-
|
|
7
|
-
describe('validate', () => {
|
|
8
|
-
it('validates body with Zod schema', async () => {
|
|
9
|
-
const r = new Router()
|
|
10
|
-
.post('/users',
|
|
11
|
-
validate({ body: z.object({ name: z.string(), age: z.number() }) }),
|
|
12
|
-
async (req, ctx) => Response.json(ctx.parsed?.body),
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
const res = await r.handler()(
|
|
16
|
-
new Request('http://localhost/users', {
|
|
17
|
-
method: 'POST',
|
|
18
|
-
headers: { 'Content-Type': 'application/json' },
|
|
19
|
-
body: JSON.stringify({ name: 'Alice', age: 30 }),
|
|
20
|
-
}),
|
|
21
|
-
{ params: {}, query: {} },
|
|
22
|
-
)
|
|
23
|
-
assert.equal(res.status, 200)
|
|
24
|
-
const data = await res.json() as Record<string, unknown>
|
|
25
|
-
assert.deepEqual(data, { name: 'Alice', age: 30 })
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('rejects invalid body with 400', async () => {
|
|
29
|
-
const r = new Router()
|
|
30
|
-
.post('/users',
|
|
31
|
-
validate({ body: z.object({ name: z.string().min(1) }) }),
|
|
32
|
-
() => new Response('ok'),
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
const res = await r.handler()(
|
|
36
|
-
new Request('http://localhost/users', {
|
|
37
|
-
method: 'POST',
|
|
38
|
-
headers: { 'Content-Type': 'application/json' },
|
|
39
|
-
body: JSON.stringify({ name: '' }),
|
|
40
|
-
}),
|
|
41
|
-
{ params: {}, query: {} },
|
|
42
|
-
)
|
|
43
|
-
assert.equal(res.status, 400)
|
|
44
|
-
const data = await res.json() as Record<string, unknown>
|
|
45
|
-
assert.ok((data as any).issues)
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('validates query params', async () => {
|
|
49
|
-
const r = new Router()
|
|
50
|
-
.get('/search',
|
|
51
|
-
validate({ query: z.object({ q: z.string(), page: z.coerce.number().optional() }) }),
|
|
52
|
-
(req, ctx) => Response.json(ctx.parsed?.query),
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
const res = await r.handler()(
|
|
56
|
-
new Request('http://localhost/search?q=hello&page=2'),
|
|
57
|
-
{ params: {}, query: { q: 'hello', page: '2' } },
|
|
58
|
-
)
|
|
59
|
-
assert.equal(res.status, 200)
|
|
60
|
-
const data = await res.json() as Record<string, unknown>
|
|
61
|
-
assert.deepEqual(data, { q: 'hello', page: 2 })
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('validates params', async () => {
|
|
65
|
-
const r = new Router()
|
|
66
|
-
.get('/:id',
|
|
67
|
-
validate({ params: z.object({ id: z.string().length(24) }) }),
|
|
68
|
-
(req, ctx) => Response.json(ctx.parsed?.params),
|
|
69
|
-
)
|
|
70
|
-
|
|
71
|
-
const res = await r.handler()(
|
|
72
|
-
new Request('http://localhost/507f1f77bcf86cd799439011'),
|
|
73
|
-
{ params: { id: '507f1f77bcf86cd799439011' }, query: {} },
|
|
74
|
-
)
|
|
75
|
-
assert.equal(res.status, 200)
|
|
76
|
-
const data = await res.json() as Record<string, unknown>
|
|
77
|
-
assert.deepEqual(data, { id: '507f1f77bcf86cd799439011' })
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('rejects invalid params with 400', async () => {
|
|
81
|
-
const r = new Router()
|
|
82
|
-
.get('/:id',
|
|
83
|
-
validate({ params: z.object({ id: z.string().length(24) }) }),
|
|
84
|
-
() => new Response('ok'),
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
const res = await r.handler()(
|
|
88
|
-
new Request('http://localhost/bad-id'),
|
|
89
|
-
{ params: { id: 'bad-id' }, query: {} },
|
|
90
|
-
)
|
|
91
|
-
assert.equal(res.status, 400)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it('validates both body and query simultaneously', async () => {
|
|
95
|
-
const r = new Router()
|
|
96
|
-
.post('/data',
|
|
97
|
-
validate({
|
|
98
|
-
body: z.object({ value: z.number() }),
|
|
99
|
-
query: z.object({ token: z.string() }),
|
|
100
|
-
}),
|
|
101
|
-
(req, ctx) => Response.json(ctx.parsed),
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
const res = await r.handler()(
|
|
105
|
-
new Request('http://localhost/data?token=abc', {
|
|
106
|
-
method: 'POST',
|
|
107
|
-
headers: { 'Content-Type': 'application/json' },
|
|
108
|
-
body: JSON.stringify({ value: 42 }),
|
|
109
|
-
}),
|
|
110
|
-
{ params: {}, query: { token: 'abc' } },
|
|
111
|
-
)
|
|
112
|
-
assert.equal(res.status, 200)
|
|
113
|
-
const data = await res.json() as Record<string, unknown>
|
|
114
|
-
assert.deepEqual(data, { body: { value: 42 }, query: { token: 'abc' } })
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
it('passes through when no schemas provided', async () => {
|
|
118
|
-
const r = new Router()
|
|
119
|
-
.post('/data',
|
|
120
|
-
validate({}),
|
|
121
|
-
() => new Response('ok'),
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
const res = await r.handler()(
|
|
125
|
-
new Request('http://localhost/data', {
|
|
126
|
-
method: 'POST',
|
|
127
|
-
body: JSON.stringify({ x: 1 }),
|
|
128
|
-
}),
|
|
129
|
-
{ params: {}, query: {} },
|
|
130
|
-
)
|
|
131
|
-
assert.equal(res.status, 200)
|
|
132
|
-
})
|
|
133
|
-
})
|
package/tsconfig.json
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,354 +0,0 @@
|
|
|
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
|
-
}
|
package/types.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
export interface Context {
|
|
2
|
-
params: Record<string, string>
|
|
3
|
-
query: Record<string, string>
|
|
4
|
-
user?: unknown
|
|
5
|
-
parsed?: Record<string, unknown>
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export type Handler = (
|
|
9
|
-
req: Request,
|
|
10
|
-
ctx: Context,
|
|
11
|
-
) => Response | Promise<Response>
|
|
12
|
-
|
|
13
|
-
export type Middleware = (
|
|
14
|
-
req: Request,
|
|
15
|
-
ctx: Context,
|
|
16
|
-
next: Handler,
|
|
17
|
-
) => Response | Promise<Response>
|
|
18
|
-
|
|
19
|
-
export type ErrorHandler = (
|
|
20
|
-
error: Error,
|
|
21
|
-
req: Request,
|
|
22
|
-
ctx: Context,
|
|
23
|
-
) => Response
|