weifuwu 0.1.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 +47 -0
- package/README.md +316 -0
- package/compress.ts +69 -0
- package/cookie.ts +58 -0
- package/index.ts +19 -0
- package/middleware.ts +178 -0
- package/package.json +21 -0
- package/rate-limit.ts +68 -0
- package/router.ts +702 -0
- package/serve.ts +126 -0
- package/static.ts +113 -0
- package/test/compress.test.ts +106 -0
- package/test/cookie.test.ts +79 -0
- package/test/middleware.test.ts +407 -0
- package/test/rate-limit.test.ts +94 -0
- package/test/static.test.ts +93 -0
- package/test/unode.test.ts +401 -0
- package/test/upload.test.ts +130 -0
- package/test/validate.test.ts +133 -0
- package/types.ts +23 -0
- package/upload.ts +101 -0
- package/validate.ts +88 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { WebSocket } from 'ws'
|
|
4
|
+
import { serve, Router, type Handler, type Server } from '../index.ts'
|
|
5
|
+
|
|
6
|
+
async function createTestServer(handler: Handler): Promise<{ server: Server; url: string }> {
|
|
7
|
+
const server = serve(handler, { port: 0 })
|
|
8
|
+
await server.ready
|
|
9
|
+
return { server, url: `http://localhost:${server.port}` }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ── serve ────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe('serve', () => {
|
|
15
|
+
it('handles GET request', async () => {
|
|
16
|
+
const { server, url } = await createTestServer(() => new Response('hello'))
|
|
17
|
+
const res = await fetch(url)
|
|
18
|
+
assert.equal(res.status, 200)
|
|
19
|
+
assert.equal(await res.text(), 'hello')
|
|
20
|
+
server.stop()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('handles POST with body echo', async () => {
|
|
24
|
+
const { server, url } = await createTestServer(async (req) => {
|
|
25
|
+
const body = await req.text()
|
|
26
|
+
return new Response(body, { status: 201 })
|
|
27
|
+
})
|
|
28
|
+
const res = await fetch(url, { method: 'POST', body: 'test data' })
|
|
29
|
+
assert.equal(res.status, 201)
|
|
30
|
+
assert.equal(await res.text(), 'test data')
|
|
31
|
+
server.stop()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('passes response headers through', async () => {
|
|
35
|
+
const { server, url } = await createTestServer(() =>
|
|
36
|
+
new Response('ok', { headers: { 'x-custom': 'value', 'content-type': 'text/plain' } }),
|
|
37
|
+
)
|
|
38
|
+
const res = await fetch(url)
|
|
39
|
+
assert.equal(res.headers.get('x-custom'), 'value')
|
|
40
|
+
assert.equal(res.headers.get('content-type'), 'text/plain')
|
|
41
|
+
server.stop()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('provides ctx.query from URL', async () => {
|
|
45
|
+
const { server, url } = await createTestServer((req, ctx) =>
|
|
46
|
+
Response.json(ctx.query),
|
|
47
|
+
)
|
|
48
|
+
const res = await fetch(`${url}?foo=bar&baz=qux`)
|
|
49
|
+
const data = await res.json() as Record<string, string>
|
|
50
|
+
assert.equal(data.foo, 'bar')
|
|
51
|
+
assert.equal(data.baz, 'qux')
|
|
52
|
+
server.stop()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns 500 on handler error', async () => {
|
|
56
|
+
const { server, url } = await createTestServer(() => {
|
|
57
|
+
throw new Error('boom')
|
|
58
|
+
})
|
|
59
|
+
const res = await fetch(url)
|
|
60
|
+
assert.equal(res.status, 500)
|
|
61
|
+
assert.match(await res.text(), /Internal Server Error/)
|
|
62
|
+
server.stop()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('server.stop() closes the server', async () => {
|
|
66
|
+
const server = serve(() => new Response('ok'), { port: 0 })
|
|
67
|
+
await server.ready
|
|
68
|
+
const port = server.port
|
|
69
|
+
server.stop()
|
|
70
|
+
await assert.rejects(() => fetch(`http://localhost:${port}`))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('AbortSignal prevents server from starting', async () => {
|
|
74
|
+
const ac = new AbortController()
|
|
75
|
+
ac.abort()
|
|
76
|
+
const server = serve(() => new Response('ok'), { port: 0, signal: ac.signal })
|
|
77
|
+
await server.ready
|
|
78
|
+
assert.equal(server.port, 0)
|
|
79
|
+
server.stop()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// ── Router ────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
describe('Router', () => {
|
|
86
|
+
it('matches GET route', async () => {
|
|
87
|
+
const r = new Router().get('/hello', () => new Response('world'))
|
|
88
|
+
const res = await r.handler()(new Request('http://localhost/hello'), { params: {}, query: {} })
|
|
89
|
+
assert.equal(res.status, 200)
|
|
90
|
+
assert.equal(await res.text(), 'world')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('matches POST route', async () => {
|
|
94
|
+
const r = new Router().post('/data', async (req) => {
|
|
95
|
+
const body = await req.text()
|
|
96
|
+
return new Response(body, { status: 201 })
|
|
97
|
+
})
|
|
98
|
+
const res = await r.handler()(new Request('http://localhost/data', { method: 'POST', body: 'hello' }), { params: {}, query: {} })
|
|
99
|
+
assert.equal(res.status, 201)
|
|
100
|
+
assert.equal(await res.text(), 'hello')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('provides ctx.params', async () => {
|
|
104
|
+
const r = new Router().get('/users/:id', (req, ctx) =>
|
|
105
|
+
Response.json({ id: ctx.params.id }),
|
|
106
|
+
)
|
|
107
|
+
const res = await r.handler()(new Request('http://localhost/users/42'), { params: {}, query: {} })
|
|
108
|
+
const data = await res.json() as Record<string, string>
|
|
109
|
+
assert.equal(data.id, '42')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('multiple params', async () => {
|
|
113
|
+
const r = new Router().get('/:a/:b', (req, ctx) =>
|
|
114
|
+
Response.json({ a: ctx.params.a, b: ctx.params.b }),
|
|
115
|
+
)
|
|
116
|
+
const res = await r.handler()(new Request('http://localhost/foo/bar'), { params: {}, query: {} })
|
|
117
|
+
const data = await res.json() as Record<string, string>
|
|
118
|
+
assert.equal(data.a, 'foo')
|
|
119
|
+
assert.equal(data.b, 'bar')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('static route wins over param route', async () => {
|
|
123
|
+
const r = new Router()
|
|
124
|
+
.get('/users/me', () => new Response('me'))
|
|
125
|
+
.get('/users/:id', (req, ctx) => new Response(ctx.params.id))
|
|
126
|
+
const res1 = await r.handler()(new Request('http://localhost/users/me'), { params: {}, query: {} })
|
|
127
|
+
assert.equal(await res1.text(), 'me')
|
|
128
|
+
const res2 = await r.handler()(new Request('http://localhost/users/42'), { params: {}, query: {} })
|
|
129
|
+
assert.equal(await res2.text(), '42')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('wildcard matches remaining path', async () => {
|
|
133
|
+
const r = new Router().all('/api/*', (req, ctx) =>
|
|
134
|
+
Response.json({ wildcard: ctx.params['*'] }),
|
|
135
|
+
)
|
|
136
|
+
const res = await r.handler()(new Request('http://localhost/api/foo/bar'), { params: {}, query: {} })
|
|
137
|
+
const data = await res.json() as Record<string, string>
|
|
138
|
+
assert.equal(data.wildcard, 'foo/bar')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('returns 404 for unmatched route', async () => {
|
|
142
|
+
const r = new Router().get('/exists', () => new Response('ok'))
|
|
143
|
+
const res = await r.handler()(new Request('http://localhost/nonexistent'), { params: {}, query: {} })
|
|
144
|
+
assert.equal(res.status, 404)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('all() matches any method', async () => {
|
|
148
|
+
const r = new Router().all('/any', () => new Response('ok'))
|
|
149
|
+
for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']) {
|
|
150
|
+
const res = await r.handler()(new Request('http://localhost/any', { method }), { params: {}, query: {} })
|
|
151
|
+
assert.equal(res.status, 200)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('sub-router mounting', async () => {
|
|
156
|
+
const sub = new Router().get('/nested', () => new Response('sub'))
|
|
157
|
+
const main = new Router().use('/api', sub)
|
|
158
|
+
const res = await main.handler()(new Request('http://localhost/api/nested'), { params: {}, query: {} })
|
|
159
|
+
assert.equal(await res.text(), 'sub')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('sub-router preserves params', async () => {
|
|
163
|
+
const sub = new Router().get('/:userId', (req, ctx) =>
|
|
164
|
+
Response.json({ userId: ctx.params.userId }),
|
|
165
|
+
)
|
|
166
|
+
const main = new Router().use('/orgs/:orgId', sub)
|
|
167
|
+
const res = await main.handler()(new Request('http://localhost/orgs/acme/john'), { params: {}, query: {} })
|
|
168
|
+
const data = await res.json() as Record<string, string>
|
|
169
|
+
assert.equal(data.userId, 'john')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('middleware order: global → path → route', async () => {
|
|
173
|
+
const order: number[] = []
|
|
174
|
+
const r = new Router()
|
|
175
|
+
.use((_req, _ctx, next) => { order.push(1); return next(_req, _ctx) })
|
|
176
|
+
.use('/scoped', (_req, _ctx, next) => { order.push(2); return next(_req, _ctx) })
|
|
177
|
+
.get('/scoped/route',
|
|
178
|
+
(_req, _ctx, next) => { order.push(3); return next(_req, _ctx) },
|
|
179
|
+
() => { order.push(4); return Response.json(order) },
|
|
180
|
+
)
|
|
181
|
+
await r.handler()(new Request('http://localhost/scoped/route'), { params: {}, query: {} })
|
|
182
|
+
assert.deepEqual(order, [1, 2, 3, 4])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('middleware short-circuits', async () => {
|
|
186
|
+
const r = new Router()
|
|
187
|
+
.use(() => new Response('blocked', { status: 403 }))
|
|
188
|
+
.get('/blocked', () => new Response('should not reach'))
|
|
189
|
+
const res = await r.handler()(new Request('http://localhost/blocked'), { params: {}, query: {} })
|
|
190
|
+
assert.equal(res.status, 403)
|
|
191
|
+
assert.equal(await res.text(), 'blocked')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('middleware modifies response after next', async () => {
|
|
195
|
+
const r = new Router()
|
|
196
|
+
.use(async (_req, _ctx, next) => {
|
|
197
|
+
const res = await next(_req, _ctx)
|
|
198
|
+
const body = await res.json() as Record<string, unknown>
|
|
199
|
+
body.modified = true
|
|
200
|
+
return Response.json(body)
|
|
201
|
+
})
|
|
202
|
+
.get('/modify', () => Response.json({ original: true }))
|
|
203
|
+
const res = await r.handler()(new Request('http://localhost/modify'), { params: {}, query: {} })
|
|
204
|
+
const data = await res.json() as Record<string, unknown>
|
|
205
|
+
assert.equal(data.original, true)
|
|
206
|
+
assert.equal(data.modified, true)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('onError catches handler exceptions', async () => {
|
|
210
|
+
const r = new Router()
|
|
211
|
+
.onError((err) => Response.json({ error: err.message }, { status: 500 }))
|
|
212
|
+
.get('/crash', () => { throw new Error('oops') })
|
|
213
|
+
const res = await r.handler()(new Request('http://localhost/crash'), { params: {}, query: {} })
|
|
214
|
+
assert.equal(res.status, 500)
|
|
215
|
+
const data = await res.json() as Record<string, string>
|
|
216
|
+
assert.equal(data.error, 'oops')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('root path / matches', async () => {
|
|
220
|
+
const r = new Router().get('/', () => new Response('root'))
|
|
221
|
+
const res = await r.handler()(new Request('http://localhost/'), { params: {}, query: {} })
|
|
222
|
+
assert.equal(await res.text(), 'root')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('same param name on same path position works', async () => {
|
|
226
|
+
const r = new Router()
|
|
227
|
+
.get('/:id', () => new Response('a'))
|
|
228
|
+
.get('/:id/profile', () => new Response('b'))
|
|
229
|
+
assert.equal(await (await r.handler()(new Request('http://localhost/foo'), { params: {}, query: {} })).text(), 'a')
|
|
230
|
+
assert.equal(await (await r.handler()(new Request('http://localhost/foo/profile'), { params: {}, query: {} })).text(), 'b')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('different param names on same path position throw', () => {
|
|
234
|
+
const r = new Router()
|
|
235
|
+
r.get('/:id', () => new Response('ok'))
|
|
236
|
+
assert.throws(() => r.get('/:slug', () => new Response('ng')), /Param name conflict/)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('multiple methods on same path', async () => {
|
|
240
|
+
const r = new Router()
|
|
241
|
+
.get('/resource', () => Response.json({ method: 'GET' }))
|
|
242
|
+
.post('/resource', () => Response.json({ method: 'POST' }))
|
|
243
|
+
.put('/resource', () => Response.json({ method: 'PUT' }))
|
|
244
|
+
.delete('/resource', () => Response.json({ method: 'DELETE' }))
|
|
245
|
+
|
|
246
|
+
const h = r.handler()
|
|
247
|
+
const tests = ['GET', 'POST', 'PUT', 'DELETE'] as const
|
|
248
|
+
for (const method of tests) {
|
|
249
|
+
const res = await h(new Request('http://localhost/resource', { method }), { params: {}, query: {} })
|
|
250
|
+
const data = await res.json() as Record<string, string>
|
|
251
|
+
assert.equal(data.method, method)
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('concurrent requests do not interfere', async () => {
|
|
256
|
+
const r = new Router().get('/echo/:val', (req, ctx) =>
|
|
257
|
+
Response.json({ val: ctx.params.val }),
|
|
258
|
+
)
|
|
259
|
+
const h = r.handler()
|
|
260
|
+
const results = await Promise.all(
|
|
261
|
+
Array.from({ length: 50 }, (_, i) =>
|
|
262
|
+
Promise.resolve(h(new Request(`http://localhost/echo/${i}`), { params: {}, query: {} })).then(r => r.json()) as Promise<Record<string, string>>,
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
results.forEach((data, i) => {
|
|
266
|
+
assert.equal(data.val, String(i))
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// ── Router.ws ────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
describe('Router.ws', () => {
|
|
274
|
+
it('echos messages', async () => {
|
|
275
|
+
const router = new Router()
|
|
276
|
+
.ws('/echo', { message(ws, _ctx, data) { ws.send(`echo: ${data}`) } })
|
|
277
|
+
|
|
278
|
+
const server = serve(router.handler(), {
|
|
279
|
+
port: 0,
|
|
280
|
+
websocket: router.websocketHandler(),
|
|
281
|
+
})
|
|
282
|
+
await server.ready
|
|
283
|
+
|
|
284
|
+
const ws = new WebSocket(`ws://localhost:${server.port}/echo`)
|
|
285
|
+
const msg = await new Promise<string>((resolve, reject) => {
|
|
286
|
+
ws.on('open', () => ws.send('hello'))
|
|
287
|
+
ws.on('message', (data) => resolve(data.toString()))
|
|
288
|
+
ws.on('error', reject)
|
|
289
|
+
setTimeout(() => reject(new Error('timeout')), 3000)
|
|
290
|
+
})
|
|
291
|
+
assert.equal(msg, 'echo: hello')
|
|
292
|
+
ws.close()
|
|
293
|
+
server.stop()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('passes params from URL', async () => {
|
|
297
|
+
const router = new Router()
|
|
298
|
+
.ws('/chat/:room', { open(ws, ctx) { ws.send(ctx.params.room!) } })
|
|
299
|
+
|
|
300
|
+
const server = serve(router.handler(), {
|
|
301
|
+
port: 0,
|
|
302
|
+
websocket: router.websocketHandler(),
|
|
303
|
+
})
|
|
304
|
+
await server.ready
|
|
305
|
+
|
|
306
|
+
const ws = new WebSocket(`ws://localhost:${server.port}/chat/lobby`)
|
|
307
|
+
const msg = await new Promise<string>((resolve, reject) => {
|
|
308
|
+
ws.on('message', (data) => resolve(data.toString()))
|
|
309
|
+
ws.on('error', reject)
|
|
310
|
+
setTimeout(() => reject(new Error('timeout')), 3000)
|
|
311
|
+
})
|
|
312
|
+
assert.equal(msg, 'lobby')
|
|
313
|
+
ws.close()
|
|
314
|
+
server.stop()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('middleware can reject upgrade', async () => {
|
|
318
|
+
const router = new Router()
|
|
319
|
+
.ws('/secure',
|
|
320
|
+
(req, _ctx, next) => {
|
|
321
|
+
const auth = req.headers.get('Authorization')
|
|
322
|
+
if (!auth) return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
323
|
+
return next(req, _ctx)
|
|
324
|
+
},
|
|
325
|
+
{ open(ws) { ws.send('authorized') } },
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
const server = serve(router.handler(), {
|
|
329
|
+
port: 0,
|
|
330
|
+
websocket: router.websocketHandler(),
|
|
331
|
+
})
|
|
332
|
+
await server.ready
|
|
333
|
+
|
|
334
|
+
// Should be rejected (no auth header from Node.js built-in WebSocket)
|
|
335
|
+
const ws = new WebSocket(`ws://localhost:${server.port}/secure`)
|
|
336
|
+
const error = await new Promise<string | null>((resolve) => {
|
|
337
|
+
ws.on('error', () => resolve('error'))
|
|
338
|
+
ws.on('open', () => resolve(null))
|
|
339
|
+
ws.on('unexpected-response', () => resolve('unexpected-response'))
|
|
340
|
+
setTimeout(() => resolve('timeout'), 3000)
|
|
341
|
+
})
|
|
342
|
+
// Without auth, the middleware returns 401 and upgrade is rejected
|
|
343
|
+
assert.ok(error === 'unexpected-response' || error === 'error', `Expected rejection, got: ${error}`)
|
|
344
|
+
server.stop()
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
// ── Router.graphql ────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
describe('Router.graphql', () => {
|
|
351
|
+
it('handles GET query', async () => {
|
|
352
|
+
const r = new Router()
|
|
353
|
+
.graphql('/graphql', {
|
|
354
|
+
schema: `type Query { hello: String }`,
|
|
355
|
+
resolvers: { Query: { hello: () => 'world' } },
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
const { server, url } = await createTestServer(r.handler())
|
|
359
|
+
const res = await fetch(`${url}/graphql?query={hello}`)
|
|
360
|
+
assert.equal(res.status, 200)
|
|
361
|
+
const data = await res.json() as Record<string, unknown>
|
|
362
|
+
assert.deepEqual(data, { data: { hello: 'world' } })
|
|
363
|
+
server.stop()
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('handles POST query', async () => {
|
|
367
|
+
const r = new Router()
|
|
368
|
+
.graphql('/graphql', {
|
|
369
|
+
schema: `type Query { hello: String }`,
|
|
370
|
+
resolvers: { Query: { hello: () => 'world' } },
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
const { server, url } = await createTestServer(r.handler())
|
|
374
|
+
const res = await fetch(`${url}/graphql`, {
|
|
375
|
+
method: 'POST',
|
|
376
|
+
headers: { 'Content-Type': 'application/json' },
|
|
377
|
+
body: JSON.stringify({ query: '{ hello }' }),
|
|
378
|
+
})
|
|
379
|
+
assert.equal(res.status, 200)
|
|
380
|
+
const data = await res.json() as Record<string, unknown>
|
|
381
|
+
assert.deepEqual(data, { data: { hello: 'world' } })
|
|
382
|
+
server.stop()
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('returns GraphiQL HTML on GET without query', async () => {
|
|
386
|
+
const r = new Router()
|
|
387
|
+
.graphql('/graphql', {
|
|
388
|
+
schema: `type Query { hello: String }`,
|
|
389
|
+
resolvers: { Query: { hello: () => 'world' } },
|
|
390
|
+
graphiql: true,
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
const { server, url } = await createTestServer(r.handler())
|
|
394
|
+
const res = await fetch(`${url}/graphql`)
|
|
395
|
+
assert.equal(res.status, 200)
|
|
396
|
+
const text = await res.text()
|
|
397
|
+
assert.ok(text.includes('GraphiQL'))
|
|
398
|
+
assert.ok(text.includes('graphiql'))
|
|
399
|
+
server.stop()
|
|
400
|
+
})
|
|
401
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
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/types.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|