@znight/http 0.1.2 → 0.1.4
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 +154 -35
- package/dist/compiler.d.mts +77 -2
- package/dist/compiler.mjs +259 -101
- package/dist/compiler.mjs.map +1 -1
- package/dist/express.mjs +3 -2
- package/dist/express.mjs.map +1 -1
- package/dist/fastify.mjs +200 -23
- package/dist/fastify.mjs.map +1 -1
- package/dist/hono.mjs +174 -43
- package/dist/hono.mjs.map +1 -1
- package/dist/index.d.mts +36 -4
- package/dist/index.mjs +1005 -257
- package/dist/index.mjs.map +1 -1
- package/dist/{node-Clm1Ia7T.mjs → node-DnYv_AMI.mjs} +4 -3
- package/dist/{node-Clm1Ia7T.mjs.map → node-DnYv_AMI.mjs.map} +1 -1
- package/dist/{node-io-CVnqpVNS.mjs → node-io-BkdRSQeg.mjs} +99 -60
- package/dist/{node-io-CVnqpVNS.mjs.map → node-io-BkdRSQeg.mjs.map} +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/adapters/express.ts +4 -1
- package/src/adapters/fastify.ts +265 -25
- package/src/adapters/hono.ts +225 -58
- package/src/adapters/node-io.ts +128 -58
- package/src/adapters/node.ts +4 -1
- package/src/body.ts +31 -2
- package/src/coerce.ts +31 -0
- package/src/compiler/analyze.ts +3 -3
- package/src/compiler/generate.ts +127 -22
- package/src/compiler/index.ts +13 -0
- package/src/compiler/route-decorators.ts +45 -2
- package/src/compiler/route-groups.ts +216 -0
- package/src/compiler/validate.ts +15 -126
- package/src/context.ts +148 -78
- package/src/decorators.ts +19 -3
- package/src/index.ts +2 -1
- package/src/pipeline.ts +936 -177
- package/src/plugin-routes.ts +19 -0
- package/src/plugin.ts +7 -3
- package/src/response.ts +6 -8
- package/src/serialize.ts +48 -21
- 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,11 +218,13 @@ 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
223
|
return new NodeAdapterRequest(request, response, options)
|
|
187
224
|
}
|
|
188
225
|
|
|
226
|
+
const NO_OPTIONS: NodeRequestOptions = Object.freeze({})
|
|
227
|
+
|
|
189
228
|
/** A Node.js request/response pair as an {@link AdapterRequest} (a class: no closure per request). */
|
|
190
229
|
class NodeAdapterRequest implements AdapterRequest {
|
|
191
230
|
readonly method: string
|
|
@@ -269,7 +308,10 @@ export function writeNodeReply(response: ServerResponse, reply: AdapterReply): v
|
|
|
269
308
|
try {
|
|
270
309
|
// one call (merged over headers set earlier with setHeader(), e.g. by Express middleware); the
|
|
271
310
|
// cookies set earlier are kept before the reply's own (writeHead would replace them)
|
|
272
|
-
const headers =
|
|
311
|
+
const headers =
|
|
312
|
+
reply.headers['set-cookie'] === undefined
|
|
313
|
+
? reply.headers
|
|
314
|
+
: withNativeCookies(response.getHeader('set-cookie'), reply.headers)
|
|
273
315
|
response.writeHead(reply.status, headers as OutgoingHttpHeaders)
|
|
274
316
|
} catch {
|
|
275
317
|
// invalid header name or value set by user code
|
|
@@ -334,44 +376,72 @@ export function writeNodeFailure(response: ServerResponse): void {
|
|
|
334
376
|
response.end('{"statusCode":500,"error":"Internal Server Error"}')
|
|
335
377
|
}
|
|
336
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
|
+
|
|
337
389
|
/**
|
|
338
390
|
* Tracks the connections of a `node:http` server to close them on shutdown: idle keep-alive
|
|
339
391
|
* connections first (graceful), every connection when forced. `server.closeIdleConnections()` /
|
|
340
|
-
* `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).
|
|
341
395
|
*/
|
|
342
396
|
export class ConnectionTracker {
|
|
343
|
-
|
|
344
|
-
readonly #sockets = new Map<Socket, number>()
|
|
397
|
+
readonly #sockets = new Set<TrackedSocket>()
|
|
345
398
|
#closing = false
|
|
346
399
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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)
|
|
350
409
|
socket.once('close', () => this.#sockets.delete(socket))
|
|
351
410
|
})
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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()
|
|
363
433
|
}
|
|
364
434
|
|
|
365
435
|
/** Ends idle connections now and the others once their requests completed. */
|
|
366
436
|
closeIdle(): void {
|
|
367
437
|
this.#closing = true
|
|
368
|
-
for (const
|
|
438
|
+
for (const socket of this.#sockets) if (socket[ACTIVE] === 0) socket.end()
|
|
369
439
|
}
|
|
370
440
|
|
|
371
441
|
/** Destroys every connection. */
|
|
372
442
|
destroyAll(): void {
|
|
373
443
|
this.#closing = true
|
|
374
|
-
for (const socket of this.#sockets
|
|
444
|
+
for (const socket of this.#sockets) socket.destroy()
|
|
375
445
|
}
|
|
376
446
|
}
|
|
377
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/analyze.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { joinPaths, normalizePath } from '../path.ts'
|
|
|
31
31
|
import { parseByteSize } from '../size.ts'
|
|
32
32
|
import { acceptsUndefined, isRaw, parameterType, planCoercion } from './coercion.ts'
|
|
33
33
|
import { Codes, HTTP_PACKAGE, Ids, PARAM_SOURCES, ROUTE_METHODS } from './model.ts'
|
|
34
|
-
import { isRouteMetaDecorator, isValueRef,
|
|
34
|
+
import { isRouteMetaDecorator, isValueRef, readRouteMeta } from './route-decorators.ts'
|
|
35
35
|
|
|
36
36
|
/** Name of the extension (owner of the decorators in `DecoratorInfo.owner`). */
|
|
37
37
|
export const EXTENSION_NAME = 'http'
|
|
@@ -96,7 +96,7 @@ export function analyzeHttpClass(cls: ClassInfo, ctx: Context): void {
|
|
|
96
96
|
const basePath = normalizePath(options.path)
|
|
97
97
|
const guards = classRefs(ctx, cls, cls.decorators, Ids.UseGuard, 'guard')
|
|
98
98
|
const interceptors = classRefs(ctx, cls, cls.decorators, Ids.UseInterceptor, 'interceptor')
|
|
99
|
-
const meta =
|
|
99
|
+
const meta = readRouteMeta(ctx, cls)
|
|
100
100
|
const bodyLimit = bodyLimitOf(ctx, cls.decorators)
|
|
101
101
|
const routes: HttpRouteData[] = []
|
|
102
102
|
for (const method of cls.methods) {
|
|
@@ -328,7 +328,7 @@ function analyzeMethod(
|
|
|
328
328
|
let headers = responseHeaders(ctx, decorators)
|
|
329
329
|
const guards = classRefs(ctx, cls, decorators, Ids.UseGuard, 'guard')
|
|
330
330
|
const interceptors = classRefs(ctx, cls, decorators, Ids.UseInterceptor, 'interceptor')
|
|
331
|
-
const meta =
|
|
331
|
+
const meta = readRouteMeta(ctx, cls, method)
|
|
332
332
|
const bodySchema =
|
|
333
333
|
bodyValidation !== undefined && !params.some(param => param.source === 'body') ? bodyValidation.schema : undefined
|
|
334
334
|
let rawResponse = params.some(param => param.source === 'response')
|
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. */
|
package/src/compiler/index.ts
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* - `generate`: `metadata.extensions.http` with one `invoke` function per route (README,
|
|
11
11
|
* "Compiler contract").
|
|
12
12
|
*
|
|
13
|
+
* It also exports the route decorator readers of other extensions: `readRouteMeta`,
|
|
14
|
+
* `effectiveRouteMeta` and `requiredRouteGroups`.
|
|
15
|
+
*
|
|
13
16
|
* This entry only `import type`s from `@znight/compiler` and never loads the HTTP runtime.
|
|
14
17
|
*/
|
|
15
18
|
import type { CompilerExtension, DecoratorSpec } from '@znight/compiler'
|
|
@@ -31,6 +34,16 @@ export type {
|
|
|
31
34
|
SseRouteData,
|
|
32
35
|
UploadData,
|
|
33
36
|
} from './model.ts'
|
|
37
|
+
export type { RequiredRouteGroup, RouteGroupMember } from './route-groups.ts'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Route decorators for other extensions (RFC 0003 §6.4, RFC 0005 H3): read `createRouteDecorator`
|
|
41
|
+
* metadata on any class or method, and the required groups of the application, with the exact
|
|
42
|
+
* rules of controllers and routes. Stable API, see the README ("Route metadata in other
|
|
43
|
+
* extensions").
|
|
44
|
+
*/
|
|
45
|
+
export { effectiveRouteMeta, readRouteMeta } from './route-decorators.ts'
|
|
46
|
+
export { requiredRouteGroups } from './route-groups.ts'
|
|
34
47
|
|
|
35
48
|
const ROUTE: DecoratorSpec = { targets: ['method'], params: ['static'] }
|
|
36
49
|
const NAME_OR_SCHEMA: DecoratorSpec = { targets: ['parameter'], params: ['value', 'value'] }
|