@znight/http 0.1.1 → 0.1.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/LICENSE +1 -1
- package/README.md +83 -32
- package/dist/{adapter-CGSceVEN.d.mts → adapter-J0UXFnNM.d.mts} +7 -1
- package/dist/compiler.d.mts +1 -1
- package/dist/compiler.mjs +82 -13
- package/dist/compiler.mjs.map +1 -1
- package/dist/express.d.mts +1 -1
- package/dist/express.mjs +3 -2
- package/dist/express.mjs.map +1 -1
- package/dist/fastify.d.mts +1 -1
- package/dist/fastify.mjs +254 -42
- package/dist/fastify.mjs.map +1 -1
- package/dist/hono.d.mts +1 -1
- package/dist/hono.mjs +272 -67
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +10 -2
- package/dist/index.mjs +930 -278
- package/dist/index.mjs.map +1 -1
- package/dist/{node-BUnkdSsa.mjs → node-DnYv_AMI.mjs} +4 -3
- package/dist/{node-BUnkdSsa.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
- package/dist/{node-io-BY1ZDLRQ.mjs → node-io-BkdRSQeg.mjs} +151 -86
- package/dist/{node-io-BY1ZDLRQ.mjs.map → node-io-BkdRSQeg.mjs.map} +1 -1
- package/dist/node.d.mts +1 -1
- package/dist/node.mjs +1 -1
- package/dist/utils-DIWdzVht.mjs +46 -0
- package/dist/utils-DIWdzVht.mjs.map +1 -0
- package/package.json +4 -4
- package/src/adapter.ts +6 -0
- package/src/adapters/express.ts +4 -1
- package/src/adapters/fastify.ts +329 -44
- package/src/adapters/hono.ts +341 -86
- package/src/adapters/node-io.ts +185 -82
- package/src/adapters/node.ts +4 -1
- package/src/body.ts +31 -2
- package/src/coerce.ts +31 -0
- package/src/compiler/generate.ts +127 -22
- package/src/context.ts +151 -78
- package/src/index.ts +1 -1
- package/src/pipeline.ts +809 -208
- package/src/plugin.ts +10 -4
- package/src/query.ts +7 -1
- package/src/response.ts +6 -8
- package/src/serialize.ts +83 -16
- package/src/utils.ts +25 -2
- package/src/validate.ts +28 -12
- package/dist/utils-DCbu3a4w.mjs +0 -27
- package/dist/utils-DCbu3a4w.mjs.map +0 -1
package/src/adapters/node-io.ts
CHANGED
|
@@ -56,47 +56,84 @@ export function readNodeBody(request: IncomingMessage, limit: number): Promise<U
|
|
|
56
56
|
request.resume()
|
|
57
57
|
return Promise.reject(payloadTooLarge(limit))
|
|
58
58
|
}
|
|
59
|
+
// a read in progress (the adapters read a body once: not expected)
|
|
60
|
+
const reading = (request as ReadingRequest)[BODY_READ]
|
|
61
|
+
if (reading != null) return reading.promise
|
|
59
62
|
if (consumedElsewhere(request)) return Promise.reject(consumedError())
|
|
60
63
|
if (request.readableEnded) return Promise.resolve(new Uint8Array(0))
|
|
61
64
|
if (request.destroyed) return Promise.reject(new BadRequestException('The request was aborted'))
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
request.off('end', onEnd)
|
|
68
|
-
request.off('error', onError)
|
|
69
|
-
request.off('close', onClose)
|
|
70
|
-
}
|
|
71
|
-
const onData = (chunk: Uint8Array | string): void => {
|
|
72
|
-
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
|
73
|
-
size += bytes.byteLength
|
|
74
|
-
if (size > limit) {
|
|
75
|
-
cleanup()
|
|
76
|
-
request.resume() // discard the rest; the 413 response closes the connection
|
|
77
|
-
reject(payloadTooLarge(limit))
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
chunks.push(bytes)
|
|
81
|
-
}
|
|
82
|
-
const onEnd = (): void => {
|
|
83
|
-
cleanup()
|
|
84
|
-
resolve(concat(chunks, size))
|
|
85
|
-
}
|
|
86
|
-
const onError = (error: Error): void => {
|
|
87
|
-
cleanup()
|
|
88
|
-
reject(new BadRequestException('Failed to read the request body', undefined, { cause: error }))
|
|
89
|
-
}
|
|
90
|
-
const onClose = (): void => {
|
|
91
|
-
if (request.complete) return
|
|
92
|
-
cleanup()
|
|
93
|
-
reject(new BadRequestException('The request was aborted'))
|
|
94
|
-
}
|
|
95
|
-
request.on('data', onData)
|
|
96
|
-
request.on('end', onEnd)
|
|
97
|
-
request.on('error', onError)
|
|
98
|
-
request.on('close', onClose)
|
|
65
|
+
let resolve!: (bytes: Uint8Array) => void
|
|
66
|
+
let reject!: (error: unknown) => void
|
|
67
|
+
const promise = new Promise<Uint8Array>((onRead, onError) => {
|
|
68
|
+
resolve = onRead
|
|
69
|
+
reject = onError
|
|
99
70
|
})
|
|
71
|
+
;(request as ReadingRequest)[BODY_READ] = { limit, chunks: [], size: 0, resolve, reject, promise }
|
|
72
|
+
if (reading === undefined) {
|
|
73
|
+
request.on('data', onBodyData)
|
|
74
|
+
request.on('end', onBodyEnd)
|
|
75
|
+
request.on('error', onBodyError)
|
|
76
|
+
request.on('close', onBodyClose)
|
|
77
|
+
}
|
|
78
|
+
return promise
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The state of the body read by {@link readNodeBody}, on its request: `null` once it is over (the
|
|
83
|
+
* listeners stay), `undefined` before.
|
|
84
|
+
*/
|
|
85
|
+
const BODY_READ = Symbol('znight.bodyRead')
|
|
86
|
+
|
|
87
|
+
interface BodyRead {
|
|
88
|
+
readonly limit: number
|
|
89
|
+
readonly chunks: Uint8Array[]
|
|
90
|
+
size: number
|
|
91
|
+
readonly resolve: (bytes: Uint8Array) => void
|
|
92
|
+
readonly reject: (error: unknown) => void
|
|
93
|
+
readonly promise: Promise<Uint8Array>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type ReadingRequest = IncomingMessage & { [BODY_READ]?: BodyRead | null }
|
|
97
|
+
|
|
98
|
+
// The listeners of readNodeBody: shared functions (`this` is the request), no closure per request.
|
|
99
|
+
// They stay attached once the read is over (removing them costs a lot more than leaving them): they
|
|
100
|
+
// then do nothing. Node.js emits `error` on a destroyed request only when it has listeners, so the
|
|
101
|
+
// `error` listener left behind changes nothing either.
|
|
102
|
+
|
|
103
|
+
/** Stops reading: returns the state of the read (`undefined` when it is already over). */
|
|
104
|
+
function stopBodyRead(request: ReadingRequest): BodyRead | undefined {
|
|
105
|
+
const read = request[BODY_READ]
|
|
106
|
+
if (read == null) return undefined
|
|
107
|
+
request[BODY_READ] = null
|
|
108
|
+
return read
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onBodyData(this: ReadingRequest, chunk: Uint8Array | string): void {
|
|
112
|
+
const read = this[BODY_READ]
|
|
113
|
+
if (read == null) return
|
|
114
|
+
const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
|
115
|
+
read.size += bytes.byteLength
|
|
116
|
+
if (read.size > read.limit) {
|
|
117
|
+
stopBodyRead(this)
|
|
118
|
+
this.resume() // discard the rest; the 413 response closes the connection
|
|
119
|
+
read.reject(payloadTooLarge(read.limit))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
read.chunks.push(bytes)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function onBodyEnd(this: ReadingRequest): void {
|
|
126
|
+
const read = stopBodyRead(this)
|
|
127
|
+
read?.resolve(concat(read.chunks, read.size))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function onBodyError(this: ReadingRequest, error: Error): void {
|
|
131
|
+
stopBodyRead(this)?.reject(new BadRequestException('Failed to read the request body', undefined, { cause: error }))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function onBodyClose(this: ReadingRequest): void {
|
|
135
|
+
if (this.complete) return
|
|
136
|
+
stopBodyRead(this)?.reject(new BadRequestException('The request was aborted'))
|
|
100
137
|
}
|
|
101
138
|
|
|
102
139
|
/**
|
|
@@ -181,25 +218,56 @@ export interface NodeRequestOptions {
|
|
|
181
218
|
export function nodeAdapterRequest(
|
|
182
219
|
request: IncomingMessage,
|
|
183
220
|
response: ServerResponse,
|
|
184
|
-
options: NodeRequestOptions =
|
|
221
|
+
options: NodeRequestOptions = NO_OPTIONS,
|
|
185
222
|
): AdapterRequest {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
223
|
+
return new NodeAdapterRequest(request, response, options)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const NO_OPTIONS: NodeRequestOptions = Object.freeze({})
|
|
227
|
+
|
|
228
|
+
/** A Node.js request/response pair as an {@link AdapterRequest} (a class: no closure per request). */
|
|
229
|
+
class NodeAdapterRequest implements AdapterRequest {
|
|
230
|
+
readonly method: string
|
|
231
|
+
readonly url: string
|
|
232
|
+
readonly headers: Readonly<Record<string, string | undefined>>
|
|
233
|
+
readonly ip: string | undefined
|
|
234
|
+
readonly remoteAddress: string | undefined
|
|
235
|
+
readonly encrypted: boolean
|
|
236
|
+
readonly raw: unknown
|
|
237
|
+
readonly rawResponse: unknown
|
|
238
|
+
readonly #request: IncomingMessage
|
|
239
|
+
readonly #response: ServerResponse
|
|
240
|
+
#body: Promise<Uint8Array> | undefined
|
|
241
|
+
|
|
242
|
+
constructor(request: IncomingMessage, response: ServerResponse, options: NodeRequestOptions) {
|
|
243
|
+
const remoteAddress = request.socket?.remoteAddress
|
|
244
|
+
this.method = request.method ?? 'GET'
|
|
245
|
+
this.url = options.url ?? request.url ?? '/'
|
|
246
|
+
this.headers = normalizeNodeHeaders(request.headers)
|
|
247
|
+
this.ip = options.ip ?? remoteAddress
|
|
248
|
+
this.remoteAddress = remoteAddress
|
|
249
|
+
this.encrypted = isEncrypted(request)
|
|
250
|
+
this.raw = options.raw ?? request
|
|
251
|
+
this.rawResponse = options.rawResponse ?? response
|
|
252
|
+
this.#request = request
|
|
253
|
+
this.#response = response
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
readBody(limit: number): Promise<Uint8Array> {
|
|
257
|
+
this.#body ??= readNodeBody(this.#request, limit)
|
|
258
|
+
return this.#body
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
streamBody(limit: number): ReadableStream<Uint8Array> {
|
|
262
|
+
return nodeBodyStream(this.#request, limit)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
rawStatus(): number {
|
|
266
|
+
return this.#response.statusCode
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
onAbort(listener: () => void): void {
|
|
270
|
+
onResponseAbort(this.#response, listener)
|
|
203
271
|
}
|
|
204
272
|
}
|
|
205
273
|
|
|
@@ -224,10 +292,10 @@ export function isEncrypted(request: IncomingMessage): boolean {
|
|
|
224
292
|
}
|
|
225
293
|
|
|
226
294
|
/**
|
|
227
|
-
* Writes a reply to a `ServerResponse
|
|
228
|
-
*
|
|
295
|
+
* Writes a reply to a `ServerResponse`, piping a stream body (a failing stream destroys the
|
|
296
|
+
* response). Never throws.
|
|
229
297
|
*/
|
|
230
|
-
export
|
|
298
|
+
export function writeNodeReply(response: ServerResponse, reply: AdapterReply): void {
|
|
231
299
|
if (reply.raw === true) {
|
|
232
300
|
discardStream(reply.body instanceof ReadableStream ? reply.body : null)
|
|
233
301
|
return
|
|
@@ -240,7 +308,10 @@ export async function writeNodeReply(response: ServerResponse, reply: AdapterRep
|
|
|
240
308
|
try {
|
|
241
309
|
// one call (merged over headers set earlier with setHeader(), e.g. by Express middleware); the
|
|
242
310
|
// cookies set earlier are kept before the reply's own (writeHead would replace them)
|
|
243
|
-
const headers =
|
|
311
|
+
const headers =
|
|
312
|
+
reply.headers['set-cookie'] === undefined
|
|
313
|
+
? reply.headers
|
|
314
|
+
: withNativeCookies(response.getHeader('set-cookie'), reply.headers)
|
|
244
315
|
response.writeHead(reply.status, headers as OutgoingHttpHeaders)
|
|
245
316
|
} catch {
|
|
246
317
|
// invalid header name or value set by user code
|
|
@@ -263,14 +334,18 @@ export async function writeNodeReply(response: ServerResponse, reply: AdapterRep
|
|
|
263
334
|
response.setTimeout(0)
|
|
264
335
|
}
|
|
265
336
|
if (body instanceof Uint8Array) {
|
|
266
|
-
|
|
337
|
+
// a string is written with the headers at once (bytes take a second write)
|
|
338
|
+
response.end(reply.text ?? body)
|
|
267
339
|
return
|
|
268
340
|
}
|
|
269
|
-
const
|
|
341
|
+
const fail = (): void => {
|
|
342
|
+
if (!response.destroyed) response.destroy()
|
|
343
|
+
}
|
|
270
344
|
try {
|
|
271
|
-
|
|
345
|
+
const stream = body instanceof Blob ? body.stream() : body
|
|
346
|
+
pipeline(Readable.fromWeb(stream as import('node:stream/web').ReadableStream), response).catch(fail)
|
|
272
347
|
} catch {
|
|
273
|
-
|
|
348
|
+
fail()
|
|
274
349
|
}
|
|
275
350
|
}
|
|
276
351
|
|
|
@@ -301,44 +376,72 @@ export function writeNodeFailure(response: ServerResponse): void {
|
|
|
301
376
|
response.end('{"statusCode":500,"error":"Internal Server Error"}')
|
|
302
377
|
}
|
|
303
378
|
|
|
379
|
+
/** The tracker of a socket (see {@link ConnectionTracker}). */
|
|
380
|
+
const TRACKER = Symbol('znight.tracker')
|
|
381
|
+
/** The number of requests in progress on a tracked socket. */
|
|
382
|
+
const ACTIVE = Symbol('znight.active')
|
|
383
|
+
|
|
384
|
+
interface TrackedSocket extends Socket {
|
|
385
|
+
[TRACKER]?: ConnectionTracker
|
|
386
|
+
[ACTIVE]?: number
|
|
387
|
+
}
|
|
388
|
+
|
|
304
389
|
/**
|
|
305
390
|
* Tracks the connections of a `node:http` server to close them on shutdown: idle keep-alive
|
|
306
391
|
* connections first (graceful), every connection when forced. `server.closeIdleConnections()` /
|
|
307
|
-
* `closeAllConnections()` are not reliable on every runtime (Bun), so sockets are tracked here.
|
|
392
|
+
* `closeAllConnections()` are not reliable on every runtime (Bun), so sockets are tracked here. The
|
|
393
|
+
* count of requests in progress lives on the socket, and one shared `close` listener per response
|
|
394
|
+
* decrements it (no closure, no map lookup per request).
|
|
308
395
|
*/
|
|
309
396
|
export class ConnectionTracker {
|
|
310
|
-
|
|
311
|
-
readonly #sockets = new Map<Socket, number>()
|
|
397
|
+
readonly #sockets = new Set<TrackedSocket>()
|
|
312
398
|
#closing = false
|
|
313
399
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
400
|
+
/**
|
|
401
|
+
* `requests: false` when the caller's own `request` listener calls {@link track} for every request
|
|
402
|
+
* of the server (one listener less to call per request).
|
|
403
|
+
*/
|
|
404
|
+
constructor(server: Server, options: { readonly requests?: boolean } = {}) {
|
|
405
|
+
server.on('connection', (socket: TrackedSocket) => {
|
|
406
|
+
socket[TRACKER] = this
|
|
407
|
+
socket[ACTIVE] = 0
|
|
408
|
+
this.#sockets.add(socket)
|
|
317
409
|
socket.once('close', () => this.#sockets.delete(socket))
|
|
318
410
|
})
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
411
|
+
if (options.requests !== false) {
|
|
412
|
+
server.on('request', (request: IncomingMessage, response: ServerResponse) => this.track(request, response))
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Counts a request in progress on its connection, until its response closes (other servers' requests are ignored). */
|
|
417
|
+
track(request: IncomingMessage, response: ServerResponse): void {
|
|
418
|
+
const socket = request.socket as TrackedSocket | null
|
|
419
|
+
if (socket?.[TRACKER] !== this) return
|
|
420
|
+
socket[ACTIVE] = (socket[ACTIVE] ?? 0) + 1
|
|
421
|
+
response.on('close', ConnectionTracker.#onResponseClose)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** A response closed (`this`): one request less on its socket, ended when idle while closing. */
|
|
425
|
+
static readonly #onResponseClose = function (this: ServerResponse): void {
|
|
426
|
+
// the socket of a finished response is detached: reach it through its request
|
|
427
|
+
const socket = (this.req as IncomingMessage | undefined)?.socket as TrackedSocket | null | undefined
|
|
428
|
+
const tracker = socket?.[TRACKER]
|
|
429
|
+
if (socket == null || tracker === undefined) return
|
|
430
|
+
const active = (socket[ACTIVE] ?? 1) - 1
|
|
431
|
+
socket[ACTIVE] = active
|
|
432
|
+
if (active === 0 && tracker.#closing) socket.end()
|
|
330
433
|
}
|
|
331
434
|
|
|
332
435
|
/** Ends idle connections now and the others once their requests completed. */
|
|
333
436
|
closeIdle(): void {
|
|
334
437
|
this.#closing = true
|
|
335
|
-
for (const
|
|
438
|
+
for (const socket of this.#sockets) if (socket[ACTIVE] === 0) socket.end()
|
|
336
439
|
}
|
|
337
440
|
|
|
338
441
|
/** Destroys every connection. */
|
|
339
442
|
destroyAll(): void {
|
|
340
443
|
this.#closing = true
|
|
341
|
-
for (const socket of this.#sockets
|
|
444
|
+
for (const socket of this.#sockets) socket.destroy()
|
|
342
445
|
}
|
|
343
446
|
}
|
|
344
447
|
|
package/src/adapters/node.ts
CHANGED
|
@@ -82,6 +82,8 @@ class NodeHttpAdapter implements NodeAdapter {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
readonly handler = (request: IncomingMessage, response: ServerResponse): void => {
|
|
85
|
+
// the listening server's connections (a request of another server is ignored)
|
|
86
|
+
this.#tracker?.track(request, response)
|
|
85
87
|
const url = request.url ?? '/'
|
|
86
88
|
const query = url.indexOf('?')
|
|
87
89
|
const handler = this.#routes.find(request.method ?? 'GET', query === -1 ? url : url.slice(0, query))
|
|
@@ -101,7 +103,8 @@ class NodeHttpAdapter implements NodeAdapter {
|
|
|
101
103
|
async listen(port: number, host: string): Promise<HttpServerAddress> {
|
|
102
104
|
const server = this.#server ?? createServer(this.#options.serverOptions ?? {})
|
|
103
105
|
this.#server = server
|
|
104
|
-
|
|
106
|
+
// `handler` counts the requests
|
|
107
|
+
this.#tracker ??= new ConnectionTracker(server, { requests: false })
|
|
105
108
|
this.#closing = undefined
|
|
106
109
|
if (!server.listeners('request').includes(this.handler)) server.on('request', this.handler)
|
|
107
110
|
await new Promise<void>((resolve, reject) => {
|
package/src/body.ts
CHANGED
|
@@ -19,9 +19,30 @@ export interface MediaType {
|
|
|
19
19
|
readonly charset: string | undefined
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Parsed `content-type` values: an application receives a handful of them, parsed twice per request
|
|
24
|
+
* with a body (request context, then {@link parseBody}). Bounded, since clients choose the values:
|
|
25
|
+
* cleared when full.
|
|
26
|
+
*/
|
|
27
|
+
const MEDIA_TYPES = new Map<string, MediaType>()
|
|
28
|
+
const MEDIA_TYPES_MAX = 64
|
|
29
|
+
|
|
22
30
|
/** Parses a `content-type` header value (`application/json; charset=utf-8`). */
|
|
23
31
|
export function parseMediaType(header: string | undefined): MediaType | undefined {
|
|
24
32
|
if (header === undefined) return undefined
|
|
33
|
+
// the most common value, compared faster than hashed
|
|
34
|
+
if (header === 'application/json') return { type: 'application/json', charset: undefined }
|
|
35
|
+
const cached = MEDIA_TYPES.get(header)
|
|
36
|
+
// a fresh object, as when parsing (callers own the result)
|
|
37
|
+
if (cached !== undefined) return { type: cached.type, charset: cached.charset }
|
|
38
|
+
const media = parseUncached(header)
|
|
39
|
+
if (media === undefined) return undefined
|
|
40
|
+
if (MEDIA_TYPES.size === MEDIA_TYPES_MAX) MEDIA_TYPES.clear()
|
|
41
|
+
MEDIA_TYPES.set(header, media)
|
|
42
|
+
return { type: media.type, charset: media.charset }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseUncached(header: string): MediaType | undefined {
|
|
25
46
|
const separator = header.indexOf(';')
|
|
26
47
|
const type = (separator === -1 ? header : header.slice(0, separator)).trim().toLowerCase()
|
|
27
48
|
if (type === '') return undefined
|
|
@@ -86,13 +107,21 @@ export function parseBody(
|
|
|
86
107
|
contentEncoding?: string | undefined,
|
|
87
108
|
): Promise<unknown> {
|
|
88
109
|
try {
|
|
89
|
-
return Promise.resolve(
|
|
110
|
+
return Promise.resolve(parseBodyBytes(bytes, contentType, contentEncoding))
|
|
90
111
|
} catch (error) {
|
|
91
112
|
return Promise.reject(error)
|
|
92
113
|
}
|
|
93
114
|
}
|
|
94
115
|
|
|
95
|
-
|
|
116
|
+
/**
|
|
117
|
+
* {@link parseBody} without the promise: returns the parsed body or throws its `HttpException`
|
|
118
|
+
* (internal: the request context parses the buffered body with it, without an extra microtask).
|
|
119
|
+
*/
|
|
120
|
+
export function parseBodyBytes(
|
|
121
|
+
bytes: Uint8Array,
|
|
122
|
+
contentType: string | undefined,
|
|
123
|
+
contentEncoding: string | undefined,
|
|
124
|
+
): unknown {
|
|
96
125
|
if (bytes.byteLength === 0) return undefined
|
|
97
126
|
checkContentEncoding(contentEncoding)
|
|
98
127
|
const media = parseMediaType(contentType)
|
package/src/coerce.ts
CHANGED
|
@@ -60,7 +60,31 @@ function single(value: unknown, label: string): string {
|
|
|
60
60
|
throw invalid(label, 'a string', value)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Longest run of decimal digits whose value is exact without parsing (`999999999999999` < 2^53). */
|
|
64
|
+
const MAX_FAST_DIGITS = 15
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The value of `text` when it is 1 to 15 ASCII decimal digits (the common `/users/42`), `-1`
|
|
68
|
+
* otherwise. The same number as `Number(text)` for such a string, without the regular expression,
|
|
69
|
+
* the trim and the parse of the general path.
|
|
70
|
+
*/
|
|
71
|
+
function digitsValue(text: string): number {
|
|
72
|
+
const length = text.length
|
|
73
|
+
if (length === 0 || length > MAX_FAST_DIGITS) return -1
|
|
74
|
+
let result = 0
|
|
75
|
+
for (let index = 0; index < length; index++) {
|
|
76
|
+
const digit = text.charCodeAt(index) - 48
|
|
77
|
+
if (digit < 0 || digit > 9) return -1
|
|
78
|
+
result = result * 10 + digit
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
function toNumber(value: unknown, label: string): number {
|
|
84
|
+
if (typeof value === 'string') {
|
|
85
|
+
const digits = digitsValue(value)
|
|
86
|
+
if (digits !== -1) return digits
|
|
87
|
+
}
|
|
64
88
|
const raw = single(value, label)
|
|
65
89
|
const text = raw.trim()
|
|
66
90
|
if (NUMBER.test(text)) {
|
|
@@ -71,6 +95,10 @@ function toNumber(value: unknown, label: string): number {
|
|
|
71
95
|
}
|
|
72
96
|
|
|
73
97
|
function toInteger(value: unknown, label: string): number {
|
|
98
|
+
if (typeof value === 'string') {
|
|
99
|
+
const digits = digitsValue(value)
|
|
100
|
+
if (digits !== -1) return digits
|
|
101
|
+
}
|
|
74
102
|
const raw = single(value, label)
|
|
75
103
|
const text = raw.trim()
|
|
76
104
|
if (INTEGER.test(text)) {
|
|
@@ -81,6 +109,9 @@ function toInteger(value: unknown, label: string): number {
|
|
|
81
109
|
}
|
|
82
110
|
|
|
83
111
|
function toBoolean(value: unknown, label: string): boolean {
|
|
112
|
+
// the canonical spellings, without the trim and the lower-casing of the general path
|
|
113
|
+
if (value === 'true' || value === '1') return true
|
|
114
|
+
if (value === 'false' || value === '0') return false
|
|
84
115
|
const raw = single(value, label)
|
|
85
116
|
switch (raw.trim().toLowerCase()) {
|
|
86
117
|
case 'true':
|
package/src/compiler/generate.ts
CHANGED
|
@@ -30,6 +30,7 @@ const UPLOAD_HELPERS = {
|
|
|
30
30
|
interface Helpers {
|
|
31
31
|
coerce(): GenNode
|
|
32
32
|
validate(): GenNode
|
|
33
|
+
isThenable(): GenNode
|
|
33
34
|
uploads(): GenNode
|
|
34
35
|
context(): GenNode
|
|
35
36
|
}
|
|
@@ -98,6 +99,7 @@ function groupsNode(ctx: Context, controllers: readonly HttpControllerData[]): G
|
|
|
98
99
|
function createHelpers(ctx: Context): Helpers {
|
|
99
100
|
let coerce: GenNode | undefined
|
|
100
101
|
let validate: GenNode | undefined
|
|
102
|
+
let isThenable: GenNode | undefined
|
|
101
103
|
let uploads: GenNode | undefined
|
|
102
104
|
let context: GenNode | undefined
|
|
103
105
|
return {
|
|
@@ -109,6 +111,10 @@ function createHelpers(ctx: Context): Helpers {
|
|
|
109
111
|
validate ??= ctx.importFrom(HTTP_PACKAGE, 'validate')
|
|
110
112
|
return validate
|
|
111
113
|
},
|
|
114
|
+
isThenable: () => {
|
|
115
|
+
isThenable ??= ctx.importFrom(HTTP_PACKAGE, 'isThenable')
|
|
116
|
+
return isThenable
|
|
117
|
+
},
|
|
112
118
|
uploads: () => {
|
|
113
119
|
uploads ??= ctx.importFrom(HTTP_PACKAGE, 'uploads')
|
|
114
120
|
return uploads
|
|
@@ -239,39 +245,138 @@ function tokens(ctx: Context, refs: readonly ClassRef[]): GenNode {
|
|
|
239
245
|
// -------------------------------------------------------------------------------------------------
|
|
240
246
|
|
|
241
247
|
/**
|
|
242
|
-
* `(c: Controller, ctx: HttpContext) => c.handler(...args)
|
|
243
|
-
*
|
|
248
|
+
* `(c: Controller, ctx: HttpContext) => c.handler(...args)`, synchronous when no argument is
|
|
249
|
+
* validated with a schema. A route validating arguments gets a block that stays synchronous as long
|
|
250
|
+
* as every `validate()` returns a value (see {@link validatingBlock}). `@Validate(S)` without
|
|
251
|
+
* `@Body()` parameter reads the body in `invoke`: that arrow is `async` and `await`s every
|
|
252
|
+
* `validate()`.
|
|
244
253
|
*/
|
|
245
254
|
function invokeNode(ctx: Context, helpers: Helpers, controller: HttpControllerData, route: HttpRouteData): GenNode {
|
|
246
|
-
const async = route.bodySchema !== undefined || route.params.some(param => param.schema !== undefined)
|
|
247
255
|
const params: [string, GenNode][] = [['c', ctx.importType(controller.identity)]]
|
|
248
256
|
// `ctx` is declared only when used (generated code type-checks with `noUnusedParameters`)
|
|
249
257
|
if (route.params.length > 0 || route.bodySchema !== undefined) {
|
|
250
258
|
params.push(['ctx', helpers.context()])
|
|
251
259
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
260
|
+
// arguments in evaluation order (handler parameter order); a hole is passed as `undefined`
|
|
261
|
+
const slots: (HttpParamData | undefined)[] = []
|
|
262
|
+
for (const param of route.params) {
|
|
263
|
+
slots[param.index] = param
|
|
264
|
+
}
|
|
265
|
+
const bodySchema = route.bodySchema
|
|
266
|
+
if (bodySchema !== undefined) {
|
|
267
|
+
return ctx.arrow(
|
|
268
|
+
params,
|
|
269
|
+
(c, http) => {
|
|
270
|
+
const call = handlerCall(
|
|
271
|
+
ctx,
|
|
272
|
+
c,
|
|
273
|
+
route,
|
|
274
|
+
Array.from(slots, param => awaitedArgument(ctx, helpers, http as GenNode, param)),
|
|
275
|
+
)
|
|
276
|
+
// @Validate(S) without @Body() parameter: the body is validated (and discarded)
|
|
277
|
+
const body = ctx.code`await ${ctx.call(ctx.member(http as GenNode, 'parseBody'))}`
|
|
278
|
+
const check = ctx.code`await ${validation(ctx, helpers, bodySchema, body, 'body')}`
|
|
279
|
+
return ctx.code`{ ${check}; return ${call} }`
|
|
280
|
+
},
|
|
281
|
+
{ async: true },
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
const last = slots.findLastIndex(param => param?.schema !== undefined)
|
|
285
|
+
return ctx.arrow(params, (c, http) =>
|
|
286
|
+
last === -1
|
|
287
|
+
? handlerCall(
|
|
288
|
+
ctx,
|
|
289
|
+
c,
|
|
290
|
+
route,
|
|
291
|
+
Array.from(slots, param => plainArgument(ctx, helpers, http as GenNode, param)),
|
|
292
|
+
)
|
|
293
|
+
: validatingBlock(ctx, helpers, route, slots, last, c, http as GenNode),
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** `c.handler(args)`. */
|
|
298
|
+
function handlerCall(ctx: Context, c: GenNode, route: HttpRouteData, args: readonly GenNode[]): GenNode {
|
|
299
|
+
return ctx.call(ctx.member(c, route.handler), args)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The body of the `invoke` of a route validating arguments with schemas: the arguments up to the
|
|
304
|
+
* last validated one are evaluated in order into `const`s (`a<index>`), the handler is called with
|
|
305
|
+
* them. `validate()` returns the value when the schema validates synchronously (Zod, Valibot and
|
|
306
|
+
* ArkType without async refinements), so the request needs no promise; when one returns a
|
|
307
|
+
* thenable, the rest runs in an `async` arrow that awaits it, then evaluates the remaining arguments
|
|
308
|
+
* (`await`ing their validations) and calls the handler: the order of evaluation, the error reported
|
|
309
|
+
* when several arguments are invalid and the value received by the handler are those of an `async`
|
|
310
|
+
* `invoke` awaiting every `validate()`.
|
|
311
|
+
*
|
|
312
|
+
* ```ts
|
|
313
|
+
* (c: UserController, ctx: HttpContext) => {
|
|
314
|
+
* const a0 = coerce.number(ctx.params['id'], 'param "id"')
|
|
315
|
+
* const a1 = validate(UpdateUser, ctx.body, 'body')
|
|
316
|
+
* if (isThenable(a1)) return (async () => c.update(a0, await a1, ctx))()
|
|
317
|
+
* return c.update(a0, a1, ctx)
|
|
318
|
+
* }
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
function validatingBlock(
|
|
322
|
+
ctx: Context,
|
|
323
|
+
helpers: Helpers,
|
|
324
|
+
route: HttpRouteData,
|
|
325
|
+
slots: readonly (HttpParamData | undefined)[],
|
|
326
|
+
last: number,
|
|
327
|
+
c: GenNode,
|
|
328
|
+
http: GenNode,
|
|
329
|
+
): GenNode {
|
|
330
|
+
// `@Ctx()` and holes are inlined: nothing to evaluate
|
|
331
|
+
const bound: number[] = []
|
|
332
|
+
for (let index = 0; index <= last; index++) {
|
|
333
|
+
const param = slots[index]
|
|
334
|
+
if (param !== undefined && param.source !== 'context') {
|
|
335
|
+
bound.push(index)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return ctx.block(
|
|
339
|
+
bound.map(index => `a${index}`),
|
|
340
|
+
(...locals) => {
|
|
341
|
+
const local = new Map(bound.map((index, position) => [index, locals[position] as GenNode]))
|
|
342
|
+
// the value of an argument evaluated before the current statement
|
|
343
|
+
const evaluated = (index: number): GenNode => local.get(index) ?? plainArgument(ctx, helpers, http, slots[index])
|
|
344
|
+
const statements: GenNode[] = []
|
|
345
|
+
for (const index of bound) {
|
|
346
|
+
const param = slots[index] as HttpParamData
|
|
347
|
+
const name = local.get(index) as GenNode
|
|
348
|
+
statements.push(ctx.code`const ${name} = ${plainArgument(ctx, helpers, http, param)}`)
|
|
349
|
+
if (param.schema !== undefined) {
|
|
350
|
+
const rest = Array.from(slots, (other, position) => {
|
|
351
|
+
if (position < index) {
|
|
352
|
+
return evaluated(position)
|
|
353
|
+
}
|
|
354
|
+
return position === index ? ctx.code`await ${name}` : awaitedArgument(ctx, helpers, http, other)
|
|
355
|
+
})
|
|
356
|
+
const resume = ctx.arrow([], () => handlerCall(ctx, c, route, rest), { async: true })
|
|
357
|
+
statements.push(ctx.code`if (${ctx.call(helpers.isThenable(), [name])}) return (${resume})()`)
|
|
358
|
+
}
|
|
258
359
|
}
|
|
259
|
-
const
|
|
260
|
-
ctx
|
|
261
|
-
Array.from(args, arg => arg ?? ctx.lit(undefined)),
|
|
360
|
+
const args = Array.from(slots, (param, position) =>
|
|
361
|
+
position <= last ? evaluated(position) : plainArgument(ctx, helpers, http, param),
|
|
262
362
|
)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
// @Validate(S) without @Body() parameter: the body is validated (and discarded)
|
|
267
|
-
const body = ctx.code`await ${ctx.call(ctx.member(http as GenNode, 'parseBody'))}`
|
|
268
|
-
const check = validation(ctx, helpers, route.bodySchema, body, 'body')
|
|
269
|
-
return ctx.code`{ ${check}; return ${call} }`
|
|
363
|
+
statements.push(ctx.code`return ${handlerCall(ctx, c, route, args)}`)
|
|
364
|
+
return statements
|
|
270
365
|
},
|
|
271
|
-
{ async },
|
|
272
366
|
)
|
|
273
367
|
}
|
|
274
368
|
|
|
369
|
+
/** An argument evaluated in an `async` arrow: its validation, if any, is `await`ed. */
|
|
370
|
+
function awaitedArgument(ctx: Context, helpers: Helpers, http: GenNode, param: HttpParamData | undefined): GenNode {
|
|
371
|
+
const node = plainArgument(ctx, helpers, http, param)
|
|
372
|
+
return param?.schema === undefined ? node : ctx.code`await ${node}`
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** An argument as an expression (`validate()` not awaited); `undefined` for a hole. */
|
|
376
|
+
function plainArgument(ctx: Context, helpers: Helpers, http: GenNode, param: HttpParamData | undefined): GenNode {
|
|
377
|
+
return param === undefined ? ctx.lit(undefined) : argumentNode(ctx, helpers, http, param)
|
|
378
|
+
}
|
|
379
|
+
|
|
275
380
|
/** The expression passed to the handler for one parameter. */
|
|
276
381
|
function argumentNode(ctx: Context, helpers: Helpers, http: GenNode, param: HttpParamData): GenNode {
|
|
277
382
|
switch (param.source) {
|
|
@@ -333,9 +438,9 @@ function argumentNode(ctx: Context, helpers: Helpers, http: GenNode, param: Http
|
|
|
333
438
|
}
|
|
334
439
|
}
|
|
335
440
|
|
|
336
|
-
/** `
|
|
441
|
+
/** `validate(schema, value, location)` (the value, or a promise when the schema validates asynchronously). */
|
|
337
442
|
function validation(ctx: Context, helpers: Helpers, schema: ValueRef, value: GenNode, location: string): GenNode {
|
|
338
|
-
return ctx.
|
|
443
|
+
return ctx.call(helpers.validate(), [ctx.ref(schema), value, location])
|
|
339
444
|
}
|
|
340
445
|
|
|
341
446
|
/** `coerce.X(value, label, ...)`, cast with `as never` when the result does not have the declared type. */
|