@znight/http 0.2.2 → 0.4.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/README.md +51 -2
- package/dist/{adapter-J0UXFnNM.d.mts → adapter-DmhQGqAR.d.mts} +18 -5
- package/dist/compiler.d.mts +23 -1
- package/dist/compiler.mjs +126 -9
- package/dist/compiler.mjs.map +1 -1
- package/dist/express.d.mts +1 -1
- package/dist/fastify.d.mts +1 -1
- package/dist/hono.d.mts +1 -1
- package/dist/index.d.mts +9 -1
- package/dist/index.mjs +54 -8
- package/dist/index.mjs.map +1 -1
- package/dist/node.d.mts +1 -1
- package/package.json +4 -4
- package/src/compiler/analyze.ts +43 -0
- package/src/compiler/generate.ts +122 -13
- package/src/compiler/model.ts +21 -0
- package/src/context.ts +7 -0
- package/src/metadata.ts +13 -4
- package/src/pipeline.ts +12 -7
- package/src/plugin-routes.ts +34 -1
- package/src/plugin.ts +39 -2
package/dist/node.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { s as HttpAdapter } from "./adapter-
|
|
1
|
+
import { s as HttpAdapter } from "./adapter-DmhQGqAR.mjs";
|
|
2
2
|
import { IncomingMessage, Server, ServerOptions, ServerResponse } from "node:http";
|
|
3
3
|
/** Options of {@link nodeAdapter}. */
|
|
4
4
|
export interface NodeAdapterOptions {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@znight/http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Znight HTTP plugin: controllers, routing, guards, interceptors, validation with Fastify, Express or Hono",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ugho STEPHAN",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"access": "public"
|
|
80
80
|
},
|
|
81
81
|
"dependencies": {
|
|
82
|
-
"@znight/core": "0.
|
|
82
|
+
"@znight/core": "0.4.0"
|
|
83
83
|
},
|
|
84
84
|
"peerDependencies": {
|
|
85
85
|
"@hono/node-server": "^2.1.5",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"fastify": "^5.12.7",
|
|
88
88
|
"hono": "^4.13.14",
|
|
89
89
|
"light-my-request": "^6.8.0",
|
|
90
|
-
"@znight/compiler": "^0.
|
|
90
|
+
"@znight/compiler": "^0.4.0"
|
|
91
91
|
},
|
|
92
92
|
"peerDependenciesMeta": {
|
|
93
93
|
"@hono/node-server": {
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"valibot": "1.5.0",
|
|
119
119
|
"zod": "4.6.7",
|
|
120
120
|
"light-my-request": "6.8.0",
|
|
121
|
-
"@znight/compiler": "0.
|
|
121
|
+
"@znight/compiler": "0.4.0"
|
|
122
122
|
},
|
|
123
123
|
"znight": {
|
|
124
124
|
"apiVersion": 1,
|
package/src/compiler/analyze.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
DecoratorInfo,
|
|
12
12
|
Location,
|
|
13
13
|
MethodInfo,
|
|
14
|
+
ParamDecoratorUse,
|
|
14
15
|
ParameterInfo,
|
|
15
16
|
StaticValue,
|
|
16
17
|
TypeInfo,
|
|
@@ -813,6 +814,24 @@ function analyzeParams(
|
|
|
813
814
|
const decorators = unique(param.decorators).filter(
|
|
814
815
|
decorator => decorator.owner === EXTENSION_NAME && PARAM_SOURCES.has(decorator.identity ?? ''),
|
|
815
816
|
)
|
|
817
|
+
// custom parameter decorators (RFC 0010); compilers before it have no paramDecorator
|
|
818
|
+
const custom = typeof ctx.paramDecorator === 'function' ? ctx.paramDecorator(param, 'http', cls) : undefined
|
|
819
|
+
if (custom !== undefined) {
|
|
820
|
+
for (const other of decorators) {
|
|
821
|
+
error(
|
|
822
|
+
ctx,
|
|
823
|
+
other.location,
|
|
824
|
+
`Parameter '${param.name}' of ${where} has several parameter decorators (@${custom.decorator.name} and @${other.name})`,
|
|
825
|
+
['Keep a single parameter decorator'],
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
if (custom.ok && decorators.length === 0) {
|
|
829
|
+
params.push(customParam(param, custom))
|
|
830
|
+
} else {
|
|
831
|
+
complete = false
|
|
832
|
+
}
|
|
833
|
+
continue
|
|
834
|
+
}
|
|
816
835
|
const [decorator, ...extra] = decorators
|
|
817
836
|
const foreign = decorator === undefined ? foreignParameterDecorator(param) : undefined
|
|
818
837
|
if (foreign !== undefined) {
|
|
@@ -852,6 +871,30 @@ function analyzeParams(
|
|
|
852
871
|
return { params, complete }
|
|
853
872
|
}
|
|
854
873
|
|
|
874
|
+
/** A custom parameter (`source: 'custom'`, the decorator name as `name`). */
|
|
875
|
+
function customParam(param: ParameterInfo, use: Extract<ParamDecoratorUse, { readonly ok: true }>): HttpParamData {
|
|
876
|
+
const data = use.data
|
|
877
|
+
return {
|
|
878
|
+
index: param.index,
|
|
879
|
+
name: param.name,
|
|
880
|
+
source: 'custom',
|
|
881
|
+
key: use.name,
|
|
882
|
+
type: undefined,
|
|
883
|
+
optional: param.optional || param.hasDefault,
|
|
884
|
+
schema: undefined,
|
|
885
|
+
coercion: undefined,
|
|
886
|
+
custom: {
|
|
887
|
+
identity: use.identity,
|
|
888
|
+
name: use.name,
|
|
889
|
+
data: data === undefined ? undefined : { value: data.ref ?? (data.value as StaticValue) },
|
|
890
|
+
resolver: use.resolver.kind === 'class' ? use.resolver.ref : undefined,
|
|
891
|
+
...(use.resolver.kind === 'class' && use.resolver.arity !== undefined ? { arity: use.resolver.arity } : {}),
|
|
892
|
+
cast: use.cast,
|
|
893
|
+
},
|
|
894
|
+
location: use.location,
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
855
898
|
function analyzeParam(
|
|
856
899
|
ctx: Context,
|
|
857
900
|
cls: ClassInfo,
|
package/src/compiler/generate.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* (README, "Compiler contract"). Controllers are sorted by token, routes keep their source order and
|
|
4
4
|
* every route gets an `invoke(c, ctx)` arrow extracting, coercing and validating its arguments.
|
|
5
5
|
*/
|
|
6
|
-
import type { ClassRef, GenerateContext, GenInput, GenNode, ValueRef } from '@znight/compiler'
|
|
6
|
+
import type { ClassRef, GenerateContext, GenInput, GenNode, StaticValue, ValueRef } from '@znight/compiler'
|
|
7
7
|
import type {
|
|
8
8
|
CoercionPlan,
|
|
9
|
+
CustomParamData,
|
|
9
10
|
HttpControllerData,
|
|
10
11
|
HttpParamData,
|
|
11
12
|
HttpRouteData,
|
|
@@ -17,6 +18,7 @@ import type {
|
|
|
17
18
|
|
|
18
19
|
import { compareStrings } from '../path.ts'
|
|
19
20
|
import { HTTP_PACKAGE } from './model.ts'
|
|
21
|
+
import { isValueRef } from './route-decorators.ts'
|
|
20
22
|
|
|
21
23
|
type Context = GenerateContext<HttpControllerData>
|
|
22
24
|
|
|
@@ -33,6 +35,9 @@ interface Helpers {
|
|
|
33
35
|
isThenable(): GenNode
|
|
34
36
|
uploads(): GenNode
|
|
35
37
|
context(): GenNode
|
|
38
|
+
paramFunctionOf(): GenNode
|
|
39
|
+
/** The resolver instances of the route being generated (`x`), with the index of each token. */
|
|
40
|
+
readonly resolvers?: { readonly x: GenNode; readonly index: ReadonlyMap<string, number> }
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/** Generates `metadata.extensions.http` (`undefined` when the application has no controller). */
|
|
@@ -102,6 +107,7 @@ function createHelpers(ctx: Context): Helpers {
|
|
|
102
107
|
let isThenable: GenNode | undefined
|
|
103
108
|
let uploads: GenNode | undefined
|
|
104
109
|
let context: GenNode | undefined
|
|
110
|
+
let paramFunctionOf: GenNode | undefined
|
|
105
111
|
return {
|
|
106
112
|
coerce: () => {
|
|
107
113
|
coerce ??= ctx.importFrom(HTTP_PACKAGE, 'coerce')
|
|
@@ -123,6 +129,10 @@ function createHelpers(ctx: Context): Helpers {
|
|
|
123
129
|
context ??= ctx.importFrom(HTTP_PACKAGE, 'HttpContext', { type: true })
|
|
124
130
|
return context
|
|
125
131
|
},
|
|
132
|
+
paramFunctionOf: () => {
|
|
133
|
+
paramFunctionOf ??= ctx.importFrom('@znight/core', 'ParamFunctionOf', { type: true })
|
|
134
|
+
return paramFunctionOf
|
|
135
|
+
},
|
|
126
136
|
}
|
|
127
137
|
}
|
|
128
138
|
|
|
@@ -169,7 +179,11 @@ function routeNode(ctx: Context, helpers: Helpers, controller: HttpControllerDat
|
|
|
169
179
|
if (route.sse !== undefined) {
|
|
170
180
|
entries.push(['sse', sseNode(ctx, route.sse)])
|
|
171
181
|
}
|
|
172
|
-
|
|
182
|
+
const resolvers = paramResolvers(route)
|
|
183
|
+
if (resolvers.length > 0) {
|
|
184
|
+
entries.push(['paramResolvers', tokens(ctx, resolvers)])
|
|
185
|
+
}
|
|
186
|
+
entries.push(['invoke', invokeNode(ctx, helpers, controller, route, resolvers)])
|
|
173
187
|
return ctx.obj(entries)
|
|
174
188
|
}
|
|
175
189
|
|
|
@@ -251,12 +265,63 @@ function tokens(ctx: Context, refs: readonly ClassRef[]): GenNode {
|
|
|
251
265
|
* `@Body()` parameter reads the body in `invoke`: that arrow is `async` and `await`s every
|
|
252
266
|
* `validate()`.
|
|
253
267
|
*/
|
|
254
|
-
function invokeNode(
|
|
268
|
+
function invokeNode(
|
|
269
|
+
ctx: Context,
|
|
270
|
+
helpers: Helpers,
|
|
271
|
+
controller: HttpControllerData,
|
|
272
|
+
route: HttpRouteData,
|
|
273
|
+
resolvers: readonly ClassRef[],
|
|
274
|
+
): GenNode {
|
|
255
275
|
const params: [string, GenNode][] = [['c', ctx.importType(controller.identity)]]
|
|
256
|
-
// `ctx` is declared only when used (generated code type-checks with `noUnusedParameters`)
|
|
276
|
+
// `ctx` is declared only when used (generated code type-checks with `noUnusedParameters`), and as
|
|
277
|
+
// `_ctx` before `x` when only class resolvers whose `resolve()` takes no parameter need arguments
|
|
257
278
|
if (route.params.length > 0 || route.bodySchema !== undefined) {
|
|
258
|
-
params.push(['ctx', helpers.context()])
|
|
279
|
+
params.push([readsContext(route) ? 'ctx' : '_ctx', helpers.context()])
|
|
280
|
+
}
|
|
281
|
+
// `x`: the instances of the parameter resolvers (RFC 0010), only for routes that have some
|
|
282
|
+
if (resolvers.length > 0) {
|
|
283
|
+
const tuple = resolvers.map(ref => ctx.importType(ref))
|
|
284
|
+
params.push(['x', ctx.code`readonly [${joinNodes(ctx, tuple)}]`])
|
|
285
|
+
const index = new Map(resolvers.map((ref, position) => [ref.token, position]))
|
|
286
|
+
const withResolvers = (x: GenNode | undefined): Helpers =>
|
|
287
|
+
x === undefined ? helpers : { ...helpers, resolvers: { x, index } }
|
|
288
|
+
return invokeArrow(ctx, params, route, (c, http, x) => [c, http, withResolvers(x)])
|
|
289
|
+
}
|
|
290
|
+
return invokeArrow(ctx, params, route, (c, http) => [c, http, helpers])
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** `true` when an argument (or the body validation) reads the context: all but a `resolve()` without parameter. */
|
|
294
|
+
function readsContext(route: HttpRouteData): boolean {
|
|
295
|
+
return (
|
|
296
|
+
route.bodySchema !== undefined ||
|
|
297
|
+
route.params.some(param => param.custom?.resolver === undefined || param.custom.arity !== 0)
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The provider classes resolving custom parameters, in order of first use (`x[i]`). */
|
|
302
|
+
function paramResolvers(route: HttpRouteData): ClassRef[] {
|
|
303
|
+
const found = new Map<string, ClassRef>()
|
|
304
|
+
for (const param of route.params) {
|
|
305
|
+
const resolver = param.custom?.resolver
|
|
306
|
+
if (resolver !== undefined && !found.has(resolver.token)) {
|
|
307
|
+
found.set(resolver.token, resolver)
|
|
308
|
+
}
|
|
259
309
|
}
|
|
310
|
+
return [...found.values()]
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** `a, b, c`. */
|
|
314
|
+
function joinNodes(ctx: Context, nodes: readonly GenNode[]): GenNode {
|
|
315
|
+
return nodes.slice(1).reduce((joined, node) => ctx.code`${joined}, ${node}`, nodes[0] as GenNode)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The arrow of {@link invokeNode}; `bind` maps the arrow parameters to the controller, the context and the helpers. */
|
|
319
|
+
function invokeArrow(
|
|
320
|
+
ctx: Context,
|
|
321
|
+
params: readonly [string, GenNode][],
|
|
322
|
+
route: HttpRouteData,
|
|
323
|
+
bind: (...params: GenNode[]) => readonly [GenNode, GenNode | undefined, Helpers],
|
|
324
|
+
): GenNode {
|
|
260
325
|
// arguments in evaluation order (handler parameter order); a hole is passed as `undefined`
|
|
261
326
|
const slots: (HttpParamData | undefined)[] = []
|
|
262
327
|
for (const param of route.params) {
|
|
@@ -266,7 +331,8 @@ function invokeNode(ctx: Context, helpers: Helpers, controller: HttpControllerDa
|
|
|
266
331
|
if (bodySchema !== undefined) {
|
|
267
332
|
return ctx.arrow(
|
|
268
333
|
params,
|
|
269
|
-
(
|
|
334
|
+
(...bound) => {
|
|
335
|
+
const [c, http, helpers] = bind(...bound)
|
|
270
336
|
const call = handlerCall(
|
|
271
337
|
ctx,
|
|
272
338
|
c,
|
|
@@ -281,17 +347,26 @@ function invokeNode(ctx: Context, helpers: Helpers, controller: HttpControllerDa
|
|
|
281
347
|
{ async: true },
|
|
282
348
|
)
|
|
283
349
|
}
|
|
284
|
-
const last = slots.findLastIndex(param => param
|
|
285
|
-
return ctx.arrow(params, (
|
|
286
|
-
|
|
350
|
+
const last = slots.findLastIndex(param => param !== undefined && isDeferred(param))
|
|
351
|
+
return ctx.arrow(params, (...bound) => {
|
|
352
|
+
const [c, http, helpers] = bind(...bound)
|
|
353
|
+
return last === -1
|
|
287
354
|
? handlerCall(
|
|
288
355
|
ctx,
|
|
289
356
|
c,
|
|
290
357
|
route,
|
|
291
358
|
Array.from(slots, param => plainArgument(ctx, helpers, http as GenNode, param)),
|
|
292
359
|
)
|
|
293
|
-
: validatingBlock(ctx, helpers, route, slots, last, c, http as GenNode)
|
|
294
|
-
)
|
|
360
|
+
: validatingBlock(ctx, helpers, route, slots, last, c, http as GenNode)
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* `true` for an argument whose expression may return a thenable: a validation with a schema, or a
|
|
366
|
+
* custom parameter (its resolver may return a promise).
|
|
367
|
+
*/
|
|
368
|
+
function isDeferred(param: HttpParamData): boolean {
|
|
369
|
+
return param.schema !== undefined || param.custom !== undefined
|
|
295
370
|
}
|
|
296
371
|
|
|
297
372
|
/** `c.handler(args)`. */
|
|
@@ -346,7 +421,7 @@ function validatingBlock(
|
|
|
346
421
|
const param = slots[index] as HttpParamData
|
|
347
422
|
const name = local.get(index) as GenNode
|
|
348
423
|
statements.push(ctx.code`const ${name} = ${plainArgument(ctx, helpers, http, param)}`)
|
|
349
|
-
if (param
|
|
424
|
+
if (isDeferred(param)) {
|
|
350
425
|
const rest = Array.from(slots, (other, position) => {
|
|
351
426
|
if (position < index) {
|
|
352
427
|
return evaluated(position)
|
|
@@ -369,7 +444,7 @@ function validatingBlock(
|
|
|
369
444
|
/** An argument evaluated in an `async` arrow: its validation, if any, is `await`ed. */
|
|
370
445
|
function awaitedArgument(ctx: Context, helpers: Helpers, http: GenNode, param: HttpParamData | undefined): GenNode {
|
|
371
446
|
const node = plainArgument(ctx, helpers, http, param)
|
|
372
|
-
return param
|
|
447
|
+
return param === undefined || !isDeferred(param) ? node : ctx.code`await ${node}`
|
|
373
448
|
}
|
|
374
449
|
|
|
375
450
|
/** An argument as an expression (`validate()` not awaited); `undefined` for a hole. */
|
|
@@ -435,9 +510,43 @@ function argumentNode(ctx: Context, helpers: Helpers, http: GenNode, param: Http
|
|
|
435
510
|
}
|
|
436
511
|
return coercion(ctx, helpers, param.coercion ?? { plan: { kind: 'raw' }, cast: false }, value, label)
|
|
437
512
|
}
|
|
513
|
+
case 'custom':
|
|
514
|
+
return customArgument(ctx, helpers, http, param.custom as CustomParamData)
|
|
438
515
|
}
|
|
439
516
|
}
|
|
440
517
|
|
|
518
|
+
/**
|
|
519
|
+
* A custom parameter (RFC 0010): `(D.resolvers.http as ParamFunctionOf<typeof D, 'http'>)(ctx, data)`
|
|
520
|
+
* for a function resolver, `x[i].resolve(ctx, data)` for a class resolver; cast with `as never` when
|
|
521
|
+
* the value type could not be checked against the parameter.
|
|
522
|
+
*/
|
|
523
|
+
function customArgument(ctx: Context, helpers: Helpers, http: GenNode, custom: CustomParamData): GenNode {
|
|
524
|
+
const data = custom.data === undefined ? ctx.lit(undefined) : dataNode(ctx, custom.data.value)
|
|
525
|
+
let call: GenNode
|
|
526
|
+
if (custom.resolver === undefined) {
|
|
527
|
+
const decorator = ctx.importValue(custom.identity)
|
|
528
|
+
const callee = ctx.code`${ctx.member(decorator, 'resolvers', 'http')} as ${helpers.paramFunctionOf()}<typeof ${decorator}, 'http'>`
|
|
529
|
+
call = ctx.call(callee, [http, data])
|
|
530
|
+
} else {
|
|
531
|
+
const resolvers = helpers.resolvers as NonNullable<Helpers['resolvers']>
|
|
532
|
+
const index = resolvers.index.get(custom.resolver.token) as number
|
|
533
|
+
if (custom.arity === undefined) {
|
|
534
|
+
// a `resolve` the compiler cannot read (inherited from a mixin...): the instance is cast to a
|
|
535
|
+
// method taking both arguments (method parameters are bivariant), the value with `as never`
|
|
536
|
+
return ctx.code`(${resolvers.x}[${index}] as { resolve(ctx: unknown, data: unknown): unknown }).resolve(${http}, ${data}) as never`
|
|
537
|
+
}
|
|
538
|
+
// a `resolve(ctx)` that ignores the data gets no data argument (TypeScript checks the arity)
|
|
539
|
+
const args = [http, data].slice(0, custom.arity)
|
|
540
|
+
call = ctx.code`${resolvers.x}[${index}].resolve(${args.length === 0 ? ctx.code`` : joinNodes(ctx, args)})`
|
|
541
|
+
}
|
|
542
|
+
return custom.cast ? ctx.code`${call} as never` : call
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** The data argument of a custom parameter: a literal (references imported) or a reference. */
|
|
546
|
+
function dataNode(ctx: Context, value: StaticValue): GenNode {
|
|
547
|
+
return isValueRef(value) ? ctx.ref(value) : ctx.lit(value as Parameters<Context['lit']>[0])
|
|
548
|
+
}
|
|
549
|
+
|
|
441
550
|
/** `validate(schema, value, location)` (the value, or a promise when the schema validates asynchronously). */
|
|
442
551
|
function validation(ctx: Context, helpers: Helpers, schema: ValueRef, value: GenNode, location: string): GenNode {
|
|
443
552
|
return ctx.call(helpers.validate(), [ctx.ref(schema), value, location])
|
package/src/compiler/model.ts
CHANGED
|
@@ -150,6 +150,25 @@ export interface UploadData {
|
|
|
150
150
|
readonly cast: boolean
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/** A custom parameter (`createParamDecorator`, RFC 0010). */
|
|
154
|
+
export interface CustomParamData {
|
|
155
|
+
/** Canonical identity of the decorator `const` (imported by the generated module). */
|
|
156
|
+
readonly identity: string
|
|
157
|
+
/** Name of the decorator (`CurrentUser`). */
|
|
158
|
+
readonly name: string
|
|
159
|
+
/** The data argument: evaluated value (it may contain references) or a reference; `undefined` without argument. */
|
|
160
|
+
readonly data: { readonly value: StaticValue } | undefined
|
|
161
|
+
/** The class resolver (`x[i].resolve(ctx, data)`); `undefined` for a function resolver. */
|
|
162
|
+
readonly resolver: ClassRef | undefined
|
|
163
|
+
/**
|
|
164
|
+
* Parameters of the class resolver's `resolve` (own, inherited or an arrow function property);
|
|
165
|
+
* `undefined` for a function resolver, or when the compiler cannot read it (the call is cast).
|
|
166
|
+
*/
|
|
167
|
+
readonly arity?: number
|
|
168
|
+
/** The value type could not be checked against the parameter: `as never`. */
|
|
169
|
+
readonly cast: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
153
172
|
/** A handler parameter. */
|
|
154
173
|
export interface HttpParamData {
|
|
155
174
|
/** Position in the handler signature. */
|
|
@@ -169,6 +188,8 @@ export interface HttpParamData {
|
|
|
169
188
|
readonly coercion: ParamCoercion | undefined
|
|
170
189
|
/** Upload parameter (`source` `file` / `files`). */
|
|
171
190
|
readonly upload?: UploadData
|
|
191
|
+
/** Custom parameter (`source` `custom`). */
|
|
192
|
+
readonly custom?: CustomParamData
|
|
172
193
|
/**
|
|
173
194
|
* The default value of the handler parameter (`@Query('page') page = 1` → `1`), when it is
|
|
174
195
|
* statically evaluable (literals, `const` references; references to exported bindings are left
|
package/src/context.ts
CHANGED
|
@@ -63,6 +63,13 @@ export interface HttpRouteInfo {
|
|
|
63
63
|
readonly meta: Readonly<Record<string, unknown>>
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
declare module '@znight/core' {
|
|
67
|
+
/** Custom parameter decorators (`createParamDecorator`) resolve HTTP parameters from the {@link HttpContext}. */
|
|
68
|
+
interface ParamContexts {
|
|
69
|
+
http: HttpContext
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
/**
|
|
67
74
|
* Adapter-neutral view of the current HTTP request, shared by guards, interceptors, exception
|
|
68
75
|
* mappers and handlers (`@Ctx()`), and injectable in request-scoped providers.
|
package/src/metadata.ts
CHANGED
|
@@ -26,6 +26,8 @@ export type HttpParamSource =
|
|
|
26
26
|
| 'files'
|
|
27
27
|
| 'cookie'
|
|
28
28
|
| 'signedCookie'
|
|
29
|
+
/** A custom parameter decorator (`createParamDecorator`, RFC 0010); `name` is the decorator's name. */
|
|
30
|
+
| 'custom'
|
|
29
31
|
|
|
30
32
|
/** A handler parameter (informational: extraction, coercion and validation happen in `invoke`). */
|
|
31
33
|
export interface HttpParamDefinition {
|
|
@@ -114,11 +116,18 @@ export interface HttpRouteDefinition {
|
|
|
114
116
|
*/
|
|
115
117
|
readonly sse?: HttpSseDefinition
|
|
116
118
|
/**
|
|
117
|
-
*
|
|
118
|
-
* the
|
|
119
|
+
* Provider classes resolving custom parameters (`ParamResolver`, RFC 0010), in order of first use:
|
|
120
|
+
* the plugin resolves them in the request scope and passes the instances to `invoke` as `x`
|
|
121
|
+
* (`x[i]` is the instance of `paramResolvers[i]`). Absent when the route has none.
|
|
119
122
|
*/
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
readonly paramResolvers?: readonly Token[]
|
|
124
|
+
/**
|
|
125
|
+
* Calls the handler: extracts, coerces and validates every argument from the context (custom
|
|
126
|
+
* parameters included), then calls the method on the controller instance. May return a promise.
|
|
127
|
+
* `resolvers` is passed when the route has {@link paramResolvers}.
|
|
128
|
+
*/
|
|
129
|
+
// biome-ignore lint/suspicious/noExplicitAny: generated functions take the concrete controller class and resolver tuple
|
|
130
|
+
readonly invoke: (controller: any, ctx: HttpContext, resolvers?: any) => unknown
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
/** A controller: a provider whose methods are routes. */
|
package/src/pipeline.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Container, CoreLogger, Hooks, HttpRequestEvent, OpenedScope, Token } from '@znight/core'
|
|
2
2
|
import type { AdapterHandler, AdapterReply, AdapterRequest, ReplyBody } from './adapter.ts'
|
|
3
|
+
import type { HttpContext } from './context.ts'
|
|
3
4
|
import type { CookieSettings } from './cookie-settings.ts'
|
|
4
5
|
import type { SerializedCookie } from './cookies.ts'
|
|
5
6
|
import type { CorsPolicy } from './cors.ts'
|
|
@@ -53,6 +54,11 @@ export type Resolvable<T> = { readonly instance: T } | { readonly token: Token }
|
|
|
53
54
|
/** Everything the pipeline needs to serve one registered route (internal). */
|
|
54
55
|
export interface RoutePlan {
|
|
55
56
|
readonly definition: HttpRouteDefinition
|
|
57
|
+
/**
|
|
58
|
+
* Calls the handler: `definition.invoke`, or for a route with parameter resolvers a function
|
|
59
|
+
* passing their instances (resolved in the request scope) as the third argument.
|
|
60
|
+
*/
|
|
61
|
+
readonly invoke: (controller: object, ctx: HttpContext) => unknown
|
|
56
62
|
/** The registered path (a trailing-slash variant has an extra empty static segment). */
|
|
57
63
|
readonly path: RoutePath
|
|
58
64
|
readonly info: HttpRouteInfo
|
|
@@ -436,7 +442,7 @@ export class Pipeline {
|
|
|
436
442
|
let result: unknown
|
|
437
443
|
let reply: AdapterReply | undefined
|
|
438
444
|
try {
|
|
439
|
-
result = plan.
|
|
445
|
+
result = plan.invoke(this.#resolve(plan.controller), ctx)
|
|
440
446
|
if (isThenable(result)) return this.#fastLater(route, request, ctx, result, startTime)
|
|
441
447
|
reply = this.#fastReply(route, ctx, result)
|
|
442
448
|
} catch (error) {
|
|
@@ -480,7 +486,7 @@ export class Pipeline {
|
|
|
480
486
|
if (result === NO_RESULT) {
|
|
481
487
|
const controller = this.#resolve(plan.controller)
|
|
482
488
|
await ctx.parseBody()
|
|
483
|
-
result = plan.
|
|
489
|
+
result = plan.invoke(controller, ctx)
|
|
484
490
|
}
|
|
485
491
|
if (isThenable(result)) result = await result
|
|
486
492
|
reply = this.#fastReply(route, ctx, result)
|
|
@@ -584,7 +590,7 @@ export class Pipeline {
|
|
|
584
590
|
if (plan.parseBody) return this.#fastOperationLater(x, route, NO_RESULT)
|
|
585
591
|
let result: unknown
|
|
586
592
|
try {
|
|
587
|
-
result = plan.
|
|
593
|
+
result = plan.invoke(this.#resolve(plan.controller), ctx)
|
|
588
594
|
} catch (error) {
|
|
589
595
|
return this.#fastChecked(x, this.#failed(error, ctx, x.event))
|
|
590
596
|
}
|
|
@@ -601,7 +607,7 @@ export class Pipeline {
|
|
|
601
607
|
if (result === NO_RESULT) {
|
|
602
608
|
const controller = this.#resolve(plan.controller)
|
|
603
609
|
await ctx.parseBody()
|
|
604
|
-
result = plan.
|
|
610
|
+
result = plan.invoke(controller, ctx)
|
|
605
611
|
}
|
|
606
612
|
if (isThenable(result)) result = await result
|
|
607
613
|
} catch (error) {
|
|
@@ -1235,13 +1241,12 @@ export class Pipeline {
|
|
|
1235
1241
|
if (allowed !== true) throw new ForbiddenException('Forbidden resource')
|
|
1236
1242
|
}
|
|
1237
1243
|
if (plan.parseBody) await ctx.parseBody()
|
|
1238
|
-
const definition = plan.definition
|
|
1239
1244
|
const interceptors = plan.interceptors
|
|
1240
1245
|
if (interceptors.length === 0) {
|
|
1241
|
-
result =
|
|
1246
|
+
result = plan.invoke(controller, ctx)
|
|
1242
1247
|
} else {
|
|
1243
1248
|
const chain = async (index: number): Promise<unknown> => {
|
|
1244
|
-
if (index === interceptors.length) return await
|
|
1249
|
+
if (index === interceptors.length) return await plan.invoke(controller, ctx)
|
|
1245
1250
|
const interceptor = this.#resolve(interceptors[index] as Resolvable<HttpInterceptor>)
|
|
1246
1251
|
return await interceptor.intercept(ctx, () => chain(index + 1))
|
|
1247
1252
|
}
|
package/src/plugin-routes.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* health or metrics endpoints), registered on every adapter in the same pipeline as the compiled
|
|
4
4
|
* controllers.
|
|
5
5
|
*/
|
|
6
|
-
import type { Container, Plugin } from '@znight/core'
|
|
6
|
+
import type { Container, CoreLogger, Plugin } from '@znight/core'
|
|
7
7
|
import type { HttpContext } from './context.ts'
|
|
8
8
|
import type { RouteMetaEntry } from './decorators.ts'
|
|
9
9
|
import type { HttpMethod } from './metadata.ts'
|
|
@@ -71,6 +71,14 @@ export interface PluginHttpRoutesContext {
|
|
|
71
71
|
*/
|
|
72
72
|
export interface PluginHttpRoutes {
|
|
73
73
|
httpRoutes(context: PluginHttpRoutesContext): readonly PluginRoute[]
|
|
74
|
+
/**
|
|
75
|
+
* Called once when `HttpPlugin` starts draining (its `onStop`, whatever the plugin order), after
|
|
76
|
+
* it stopped accepting connections and started closing its event streams: end the long-lived
|
|
77
|
+
* responses `HttpPlugin` does not know of (a stream another server produced), so that the drain
|
|
78
|
+
* does not wait for them until `drainTimeoutMs`. Requests still in flight are served. The drain
|
|
79
|
+
* waits for a returned promise too (within `drainTimeoutMs`); a failure is logged.
|
|
80
|
+
*/
|
|
81
|
+
onHttpDrain?(): void | Promise<void>
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
/** A validated plugin route (internal). */
|
|
@@ -122,6 +130,31 @@ export function collectPluginRoutes(
|
|
|
122
130
|
return collected
|
|
123
131
|
}
|
|
124
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Calls `onHttpDrain()` on every other plugin implementing it, in plugin order: the promises the
|
|
135
|
+
* drain waits for (they never reject: a failure is logged).
|
|
136
|
+
*/
|
|
137
|
+
export function drainPlugins(plugins: readonly Plugin[], self: Plugin, logger: CoreLogger): Promise<void>[] {
|
|
138
|
+
const pending: Promise<void>[] = []
|
|
139
|
+
for (const plugin of plugins) {
|
|
140
|
+
if (plugin === self) continue
|
|
141
|
+
const drain = (plugin as Plugin & Partial<PluginHttpRoutes>).onHttpDrain
|
|
142
|
+
if (typeof drain !== 'function') continue
|
|
143
|
+
const failed = (error: unknown): void => {
|
|
144
|
+
logger.error(`HttpPlugin: onHttpDrain() of the plugin "${String(plugin.name)}" failed`, { err: error })
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const result: unknown = drain.call(plugin)
|
|
148
|
+
if (typeof (result as PromiseLike<void> | undefined)?.then === 'function') {
|
|
149
|
+
pending.push(Promise.resolve(result as PromiseLike<void>).then(undefined, failed))
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
failed(error)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return pending
|
|
156
|
+
}
|
|
157
|
+
|
|
125
158
|
function validateRoute(instance: object, plugin: string, value: unknown, prefix: string): CollectedPluginRoute {
|
|
126
159
|
if (value === null || typeof value !== 'object') throw invalid(plugin, `invalid route ${describe(value)}`)
|
|
127
160
|
const route = value as Partial<PluginRoute>
|
package/src/plugin.ts
CHANGED
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
randomRequestId,
|
|
57
57
|
type SseSettings,
|
|
58
58
|
} from './pipeline.ts'
|
|
59
|
-
import { collectPluginRoutes } from './plugin-routes.ts'
|
|
59
|
+
import { collectPluginRoutes, drainPlugins } from './plugin-routes.ts'
|
|
60
60
|
import { resolveTrustProxy } from './proxy.ts'
|
|
61
61
|
import { RouteTable } from './router.ts'
|
|
62
62
|
import { resolveSecurityHeaders } from './security-headers.ts'
|
|
@@ -552,6 +552,8 @@ export class HttpPlugin implements Plugin {
|
|
|
552
552
|
runtime.pipeline.draining = true
|
|
553
553
|
// event streams never end by themselves: let their sources finish, then send them a jittered retry
|
|
554
554
|
runtime.pipeline.closeStreams('shutdown')
|
|
555
|
+
// the streams other plugins serve on their routes (a GraphQL driver's subscriptions)
|
|
556
|
+
const drains = drainPlugins(ctx.app.plugins, this, ctx.logger)
|
|
555
557
|
const timeoutMs = runtime.options.drainTimeoutMs
|
|
556
558
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
557
559
|
// no timer for Infinity; larger delays than setTimeout supports are clamped (they would fire at once)
|
|
@@ -561,7 +563,9 @@ export class HttpPlugin implements Plugin {
|
|
|
561
563
|
}
|
|
562
564
|
})
|
|
563
565
|
try {
|
|
564
|
-
const drained = Promise.all([runtime.adapter.close(), runtime.pipeline.whenIdle()]).then(
|
|
566
|
+
const drained = Promise.all([runtime.adapter.close(), runtime.pipeline.whenIdle(), ...drains]).then(
|
|
567
|
+
() => 'drained' as const,
|
|
568
|
+
)
|
|
565
569
|
if ((await Promise.race([drained, timeout])) === 'timeout') {
|
|
566
570
|
ctx.logger.warn(`HTTP server: closing ${runtime.pipeline.inflight} in-flight request(s) after ${timeoutMs} ms`)
|
|
567
571
|
await runtime.adapter.close({ force: true })
|
|
@@ -965,6 +969,7 @@ function buildRegistrations(
|
|
|
965
969
|
const body = bodyPlan(route, options.bodyLimit, options.uploads)
|
|
966
970
|
const parseBody = body.upload || route.params.some(param => param.source === 'body')
|
|
967
971
|
const chain = guards.map(guard => guard.resolvable)
|
|
972
|
+
const invoke = routeInvoke(container, route, lookup, where)
|
|
968
973
|
// computed once every group is declared (guards of every chain, plugin routes), then frozen
|
|
969
974
|
let meta: RouteMeta | undefined
|
|
970
975
|
const effective = (): RouteMeta => {
|
|
@@ -977,6 +982,7 @@ function buildRegistrations(
|
|
|
977
982
|
path,
|
|
978
983
|
plan: (method: string): RoutePlan => ({
|
|
979
984
|
definition: route,
|
|
985
|
+
invoke,
|
|
980
986
|
path,
|
|
981
987
|
info: Object.freeze({
|
|
982
988
|
method,
|
|
@@ -1048,6 +1054,7 @@ function buildRegistrations(
|
|
|
1048
1054
|
path,
|
|
1049
1055
|
plan: (method: string): RoutePlan => ({
|
|
1050
1056
|
definition,
|
|
1057
|
+
invoke: definition.invoke,
|
|
1051
1058
|
path,
|
|
1052
1059
|
info: Object.freeze({ method, path: path.path, controller: route.plugin, handler: route.name, meta }),
|
|
1053
1060
|
controller: instance,
|
|
@@ -1260,6 +1267,9 @@ function assertRoute(route: HttpRouteDefinition, where: string): void {
|
|
|
1260
1267
|
}
|
|
1261
1268
|
if (route.uploads !== undefined) assertUploads(route, where)
|
|
1262
1269
|
if (route.sse !== undefined) assertSse(route, where)
|
|
1270
|
+
if (route.paramResolvers !== undefined && !Array.isArray(route.paramResolvers)) {
|
|
1271
|
+
throw new ZnightError(ErrorCode.InvalidRoute, `Invalid route ${where}: paramResolvers must be an array of tokens`)
|
|
1272
|
+
}
|
|
1263
1273
|
}
|
|
1264
1274
|
|
|
1265
1275
|
function assertSse(route: HttpRouteDefinition, where: string): void {
|
|
@@ -1336,6 +1346,33 @@ function describeValue(value: unknown): string {
|
|
|
1336
1346
|
// Provider resolution
|
|
1337
1347
|
// -------------------------------------------------------------------------------------------------
|
|
1338
1348
|
|
|
1349
|
+
/**
|
|
1350
|
+
* The `invoke` of a route plan: the generated `invoke` itself, unless the route has parameter
|
|
1351
|
+
* resolvers (RFC 0010), whose instances are passed as its third argument: one frozen array resolved
|
|
1352
|
+
* at bootstrap when they are all singletons, otherwise an array resolved in the request scope.
|
|
1353
|
+
*/
|
|
1354
|
+
function routeInvoke(
|
|
1355
|
+
container: Container,
|
|
1356
|
+
route: HttpRouteDefinition,
|
|
1357
|
+
lookup: <T>(token: Token, what: string) => Resolvable<T>,
|
|
1358
|
+
where: string,
|
|
1359
|
+
): RoutePlan['invoke'] {
|
|
1360
|
+
const invoke = route.invoke
|
|
1361
|
+
const tokens = route.paramResolvers
|
|
1362
|
+
if (tokens === undefined || tokens.length === 0) return invoke
|
|
1363
|
+
const resolvers = tokens.map(token => lookup<object>(token, `parameter resolver of ${where}`))
|
|
1364
|
+
if (resolvers.every(resolver => 'instance' in resolver)) {
|
|
1365
|
+
const instances = Object.freeze(resolvers.map(resolver => (resolver as { readonly instance: object }).instance))
|
|
1366
|
+
return (controller, ctx) => invoke(controller, ctx, instances)
|
|
1367
|
+
}
|
|
1368
|
+
return (controller, ctx) =>
|
|
1369
|
+
invoke(
|
|
1370
|
+
controller,
|
|
1371
|
+
ctx,
|
|
1372
|
+
resolvers.map(resolver => ('instance' in resolver ? resolver.instance : container.get(resolver.token))),
|
|
1373
|
+
)
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1339
1376
|
/**
|
|
1340
1377
|
* Singletons (and registered values) are resolved once at bootstrap; request-scoped and transient
|
|
1341
1378
|
* providers on every request.
|