@znight/http 0.0.0-stage → 0.1.1

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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2333 -2
  3. package/dist/adapter-CGSceVEN.d.mts +876 -0
  4. package/dist/compiler.d.mts +156 -0
  5. package/dist/compiler.mjs +2180 -0
  6. package/dist/compiler.mjs.map +1 -0
  7. package/dist/express.d.mts +39 -0
  8. package/dist/express.mjs +135 -0
  9. package/dist/express.mjs.map +1 -0
  10. package/dist/fastify.d.mts +33 -0
  11. package/dist/fastify.mjs +191 -0
  12. package/dist/fastify.mjs.map +1 -0
  13. package/dist/hono.d.mts +49 -0
  14. package/dist/hono.mjs +319 -0
  15. package/dist/hono.mjs.map +1 -0
  16. package/dist/index.d.mts +1254 -0
  17. package/dist/index.mjs +5618 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/node-BUnkdSsa.mjs +183 -0
  20. package/dist/node-BUnkdSsa.mjs.map +1 -0
  21. package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
  22. package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
  23. package/dist/node.d.mts +30 -0
  24. package/dist/node.mjs +1 -0
  25. package/dist/path-JI87C4k8.d.mts +109 -0
  26. package/dist/path.d.mts +2 -0
  27. package/dist/path.mjs +245 -0
  28. package/dist/path.mjs.map +1 -0
  29. package/dist/size-D8rsQcV0.mjs +184 -0
  30. package/dist/size-D8rsQcV0.mjs.map +1 -0
  31. package/dist/utils-DCbu3a4w.mjs +27 -0
  32. package/dist/utils-DCbu3a4w.mjs.map +1 -0
  33. package/package.json +142 -4
  34. package/src/adapter.ts +240 -0
  35. package/src/adapters/express.ts +201 -0
  36. package/src/adapters/fastify.ts +280 -0
  37. package/src/adapters/hono.ts +488 -0
  38. package/src/adapters/node-io.ts +368 -0
  39. package/src/adapters/node.ts +135 -0
  40. package/src/adapters/shared.ts +508 -0
  41. package/src/body.ts +135 -0
  42. package/src/coerce.ts +195 -0
  43. package/src/compiler/analyze.ts +1513 -0
  44. package/src/compiler/ast.ts +90 -0
  45. package/src/compiler/coercion.ts +267 -0
  46. package/src/compiler/generate.ts +374 -0
  47. package/src/compiler/index.ts +76 -0
  48. package/src/compiler/model.ts +260 -0
  49. package/src/compiler/route-decorators.ts +405 -0
  50. package/src/compiler/validate.ts +288 -0
  51. package/src/context.ts +533 -0
  52. package/src/cookie-settings.ts +68 -0
  53. package/src/cookie-signer.ts +106 -0
  54. package/src/cookies.ts +726 -0
  55. package/src/cors.ts +349 -0
  56. package/src/decisions.ts +331 -0
  57. package/src/decorators.ts +486 -0
  58. package/src/event-channel.ts +197 -0
  59. package/src/exceptions.ts +257 -0
  60. package/src/files.ts +429 -0
  61. package/src/headers.ts +39 -0
  62. package/src/index.ts +185 -0
  63. package/src/inject-sse.ts +309 -0
  64. package/src/interfaces.ts +101 -0
  65. package/src/metadata.ts +162 -0
  66. package/src/mime.ts +157 -0
  67. package/src/multipart.ts +597 -0
  68. package/src/option-error.ts +31 -0
  69. package/src/path.ts +288 -0
  70. package/src/pipeline.ts +918 -0
  71. package/src/plugin-routes.ts +188 -0
  72. package/src/plugin.ts +1432 -0
  73. package/src/proxy.ts +236 -0
  74. package/src/query.ts +30 -0
  75. package/src/response.ts +240 -0
  76. package/src/router.ts +119 -0
  77. package/src/security-headers.ts +161 -0
  78. package/src/serialize.ts +264 -0
  79. package/src/size.ts +66 -0
  80. package/src/sse-parse.ts +194 -0
  81. package/src/sse.ts +650 -0
  82. package/src/standard-schema.ts +81 -0
  83. package/src/status.ts +78 -0
  84. package/src/stream.ts +200 -0
  85. package/src/tokens.ts +21 -0
  86. package/src/uploads.ts +176 -0
  87. package/src/utils.ts +31 -0
  88. package/src/validate.ts +65 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,1432 @@
1
+ import type { Container, Plugin, PluginContext, PluginOptions, ProviderKey, Token, ZnightMetadata } from '@znight/core'
2
+ import type {
3
+ AdapterReply,
4
+ HttpAdapter,
5
+ HttpServerAddress,
6
+ InjectRequest,
7
+ InjectResponse,
8
+ InjectStreamOptions,
9
+ InjectStreamResponse,
10
+ } from './adapter.ts'
11
+ import type { HttpContext } from './context.ts'
12
+ import type { CookieSettings } from './cookie-settings.ts'
13
+ import type { CookieDefaults } from './cookies.ts'
14
+ import type { CorsOptions, CorsPolicy } from './cors.ts'
15
+ import type { InjectSseResponse } from './inject-sse.ts'
16
+ import type {
17
+ CanActivate,
18
+ ExceptionMapper,
19
+ ExceptionMapperClass,
20
+ GuardClass,
21
+ HttpInterceptor,
22
+ HttpInterceptorClass,
23
+ } from './interfaces.ts'
24
+ import type { HttpControllerDefinition, HttpMetadata, HttpMethod, HttpRouteDefinition } from './metadata.ts'
25
+ import type { CollectedPluginRoute } from './plugin-routes.ts'
26
+ import type { TrustPolicy, TrustProxy } from './proxy.ts'
27
+ import type { SecurityHeadersOptions } from './security-headers.ts'
28
+ import type { ByteSize } from './size.ts'
29
+
30
+ import { displayName, ErrorCode, resolveOptions, ZnightError } from '@znight/core'
31
+
32
+ import { nodeAdapter } from './adapters/node.ts'
33
+ import { DEFAULT_COOKIE_SETTINGS, missingSecrets, signValue } from './cookie-settings.ts'
34
+ import { type CookieSecret, CookieSigner, checkSecrets } from './cookie-signer.ts'
35
+ import { DEFAULT_COOKIE_OPTIONS, decodeCookieValue, isCookieName, serializeResponseCookie } from './cookies.ts'
36
+ import { resolveCors } from './cors.ts'
37
+ import {
38
+ type CheckedRoute,
39
+ checkDecisions,
40
+ conflictingKeys,
41
+ type EnforcementMode,
42
+ effectiveMeta,
43
+ enforcedBy,
44
+ RouteGroups,
45
+ type RouteMeta,
46
+ } from './decisions.ts'
47
+ import { isHeaderName } from './headers.ts'
48
+ import { openInjectSse } from './inject-sse.ts'
49
+ import { isMediaTypePattern } from './mime.ts'
50
+ import { invalidOption } from './option-error.ts'
51
+ import { compareRoutePaths, compareStrings, joinPaths, parseRoutePath, type RoutePath, RoutePathError } from './path.ts'
52
+ import { DEFAULT_SSE_SETTINGS, Pipeline, type Resolvable, type RoutePlan, type SseSettings } from './pipeline.ts'
53
+ import { collectPluginRoutes } from './plugin-routes.ts'
54
+ import { resolveTrustProxy } from './proxy.ts'
55
+ import { RouteTable } from './router.ts'
56
+ import { resolveSecurityHeaders } from './security-headers.ts'
57
+ import { parseByteSize } from './size.ts'
58
+ import { MAX_SSE_TIMER_MS, MIN_SSE_INTERVAL_MS } from './sse.ts'
59
+ import { isValidStatus } from './status.ts'
60
+ import { HttpTokens } from './tokens.ts'
61
+ import { bodyPlan, DEFAULT_UPLOAD_LIMITS, type ResolvedUploadLimits, type UploadLimits } from './uploads.ts'
62
+
63
+ /** How requests whose path ends with an extra `/` are handled. */
64
+ export type TrailingSlashPolicy = 'strict' | 'ignore' | 'redirect'
65
+
66
+ /** A global guard: a provider class (resolved with the container) or an instance. */
67
+ export type GuardRef = GuardClass | CanActivate
68
+ /** A global interceptor: a provider class or an instance. */
69
+ export type InterceptorRef = HttpInterceptorClass | HttpInterceptor
70
+ /** An exception mapper: a provider class or an instance. */
71
+ export type ExceptionMapperRef = ExceptionMapperClass | ExceptionMapper
72
+
73
+ /** Options of {@link HttpPlugin.configure}. */
74
+ export interface HttpPluginOptions {
75
+ /** Server implementation. Default: `nodeAdapter()` (built-in, `node:http`). */
76
+ readonly adapter?: HttpAdapter
77
+ /** Port to listen on (`0` picks a free port). Default: `3000`. */
78
+ readonly port?: number
79
+ /** Interface to bind. Default: `0.0.0.0`. */
80
+ readonly host?: string
81
+ /** Path prefix of every route (`/api`). Default: none. */
82
+ readonly prefix?: string
83
+ /** Guards run before the controller and route guards of every route. */
84
+ readonly guards?: readonly GuardRef[]
85
+ /** Interceptors wrapping every route (outermost first). */
86
+ readonly interceptors?: readonly InterceptorRef[]
87
+ /** Error-to-response mappers, tried in order (see `ExceptionMapper`). */
88
+ readonly exceptionMappers?: readonly ExceptionMapperRef[]
89
+ /**
90
+ * `strict` (default): `/users/` does not match `/users`. `ignore`: it does. `redirect`: it is
91
+ * redirected (308) to `/users`.
92
+ */
93
+ readonly trailingSlash?: TrailingSlashPolicy
94
+ /** Send the message of unexpected errors in 500 responses (development only). Default: `false`. */
95
+ readonly exposeErrors?: boolean
96
+ /**
97
+ * Maximum request body size (413 above): bytes or a size with a unit (`'10mb'`; `kb` = 1024
98
+ * bytes), `Infinity` for no limit. Routes override it with `@BodyLimit`; upload routes default to
99
+ * the sum of their file limits plus this value. Default: 1 MiB.
100
+ */
101
+ readonly bodyLimit?: ByteSize
102
+ /** Limits of multipart bodies and defaults of the upload parameters (see `UploadLimits`). */
103
+ readonly uploads?: UploadLimits
104
+ /**
105
+ * Header carrying the request id: reused when the client sends a valid one (≤ 128 characters of
106
+ * `[A-Za-z0-9_-.:@/+=]`), echoed on every response. `false`: always generate, never echo.
107
+ * Default: `x-request-id`.
108
+ */
109
+ readonly requestIdHeader?: string | false
110
+ /** Request id generator. Default: `crypto.randomUUID()`. */
111
+ readonly generateRequestId?: () => string
112
+ /**
113
+ * Maximum time `onStop` waits for in-flight requests before closing the remaining connections.
114
+ * Keep it below the application `shutdown.hookTimeoutMs`. `Infinity`: no limit of its own (the
115
+ * application's `hookTimeoutMs` still bounds `onStop`). Default: 4 000 ms.
116
+ */
117
+ readonly drainTimeoutMs?: number
118
+ /**
119
+ * CORS: preflights are answered before routing and guards (never by a route), and replies to
120
+ * allowed origins get `access-control-allow-origin`, identically on every adapter. Default: off.
121
+ *
122
+ * ```ts
123
+ * cors: { origin: ['https://app.example.com', /^https:\/\/[a-z0-9-]+\.preview\.example\.com$/], credentials: true }
124
+ * ```
125
+ */
126
+ readonly cors?: CorsOptions | false
127
+ /**
128
+ * Security headers added to every reply that does not set them (`@Res()` raw replies excepted):
129
+ * `true` (default) for the API defaults, an object merged over them, `false` for none.
130
+ */
131
+ readonly securityHeaders?: boolean | SecurityHeadersOptions
132
+ /**
133
+ * Proxies trusted for `x-forwarded-for` / `x-forwarded-proto` (`ctx.ip`, `ctx.protocol`): a number
134
+ * of hops, or addresses, CIDR ranges and the aliases `'loopback'`, `'private'`, `'linklocal'`.
135
+ * Default `false`: the headers are ignored and `ctx.ip` is the adapter's address.
136
+ */
137
+ readonly trustProxy?: TrustProxy
138
+ /**
139
+ * What the boot check of route decisions does when a route misses a decision of a required group,
140
+ * or when no guard of its chain enforces its decision (ZNIGHT023): `'error'` (default) fails the
141
+ * boot, `'warn'` logs one warning per group (tests that deliberately run without guards).
142
+ */
143
+ readonly enforcement?: EnforcementMode
144
+ /** Cookies: signing keys and defaults (see {@link HttpCookieOptions}). */
145
+ readonly cookies?: HttpCookieOptions
146
+ /** Server-Sent Events defaults (see {@link HttpSseOptions}). */
147
+ readonly sse?: HttpSseOptions
148
+ }
149
+
150
+ /** The plugin option `cookies`. */
151
+ export interface HttpCookieOptions {
152
+ /**
153
+ * HMAC keys of signed cookies (`@SignedCookie`, `{ signed: true }`), at least 32 bytes each: the
154
+ * first signs, all verify (rotation: deploy `[next, current]`, wait for the longest cookie
155
+ * lifetime, then `[next]`). Default: none (signed cookies unavailable).
156
+ */
157
+ readonly secrets?: string | Uint8Array | readonly (string | Uint8Array)[]
158
+ /**
159
+ * Defaults of every cookie set by the application, merged over the secure defaults
160
+ * `{ path: '/', secure: true, httpOnly: true, sameSite: 'lax' }` (e.g. `{ secure: false }` for
161
+ * plain `http://` development setups on a non-localhost host).
162
+ */
163
+ readonly defaults?: CookieDefaults
164
+ }
165
+
166
+ /** The plugin option `sse`. */
167
+ export interface HttpSseOptions {
168
+ /** Default heartbeat (ms, an integer ≥ 1 000, or `false`). Default: 8 000. */
169
+ readonly heartbeat?: number | false
170
+ /** Default `retry` sent first (ms). Default: none. */
171
+ readonly retry?: number
172
+ /**
173
+ * Time an event source gets to finish after `ctx.signal` aborted on shutdown or `maxDuration` (ms),
174
+ * before the stream ends with a jittered `retry`. Must be lower than `drainTimeoutMs`. Default:
175
+ * 1 000.
176
+ */
177
+ readonly shutdownGraceMs?: number
178
+ }
179
+
180
+ /** A route registered by the plugin (see {@link HttpPlugin.routes}). */
181
+ export interface HttpRouteSummary {
182
+ readonly method: string
183
+ /** Registered path, prefix included. */
184
+ readonly path: string
185
+ /** Controller display name, or the name of the plugin declaring the route. */
186
+ readonly controller: string
187
+ readonly handler: string
188
+ /** `'controller'` for compiled routes, the plugin name for plugin routes. */
189
+ readonly source: string
190
+ /** Effective route metadata (the nearest decision of each group). */
191
+ readonly meta: Readonly<Record<string, unknown>>
192
+ /** Display names of the guards, in chain order (global, controller, route). */
193
+ readonly guards: readonly string[]
194
+ }
195
+
196
+ interface Resolved {
197
+ readonly adapter: HttpAdapter
198
+ readonly port: number
199
+ readonly host: string
200
+ readonly prefix: string
201
+ readonly trailingSlash: TrailingSlashPolicy
202
+ readonly exposeErrors: boolean
203
+ readonly bodyLimit: number
204
+ readonly uploads: ResolvedUploadLimits
205
+ readonly requestIdHeader: string | undefined
206
+ readonly generateRequestId: () => string
207
+ readonly drainTimeoutMs: number
208
+ readonly cors: CorsPolicy | undefined
209
+ readonly securityHeaders: Readonly<Record<string, string>>
210
+ readonly trustProxy: TrustPolicy | undefined
211
+ readonly enforcement: EnforcementMode
212
+ readonly cookies: CookieSettings
213
+ readonly sse: SseSettings
214
+ }
215
+
216
+ /** Resolved options and the raw options they come from (memoised per application). */
217
+ interface ResolvedOptions {
218
+ readonly raw: HttpPluginOptions
219
+ readonly options: Resolved
220
+ }
221
+
222
+ interface Runtime {
223
+ readonly adapter: HttpAdapter
224
+ readonly pipeline: Pipeline
225
+ readonly options: Resolved
226
+ readonly routes: readonly HttpRouteSummary[]
227
+ }
228
+
229
+ interface Registration {
230
+ readonly method: Exclude<HttpMethod, 'ALL'>
231
+ readonly path: RoutePath
232
+ readonly plan: RoutePlan
233
+ /** `BookController.list`, `plugin "openapi" (document)`. */
234
+ readonly label: string
235
+ readonly source: string
236
+ readonly guards: readonly string[]
237
+ }
238
+
239
+ /** A guard of a chain: how to resolve it, the class whose `enforces` counts, its display name. */
240
+ interface GuardEntry {
241
+ readonly resolvable: Resolvable<CanActivate>
242
+ readonly cls: unknown
243
+ readonly name: string
244
+ }
245
+
246
+ const DEFAULT_PORT = 3000
247
+ const DEFAULT_HOST = '0.0.0.0'
248
+ const DEFAULT_BODY_LIMIT = 1_048_576
249
+ const DEFAULT_DRAIN_TIMEOUT_MS = 4_000
250
+ const DEFAULT_REQUEST_ID_HEADER = 'x-request-id'
251
+ /** Longest `setTimeout` delay (larger ones overflow to 1 ms). */
252
+ const MAX_TIMER_MS = 2_147_483_647
253
+ /** Adapters bound to a plugin (an adapter instance serves one application). */
254
+ const BOUND_ADAPTERS = new WeakSet<HttpAdapter>()
255
+ const CONCRETE_METHODS: readonly Exclude<HttpMethod, 'ALL'>[] = [
256
+ 'GET',
257
+ 'HEAD',
258
+ 'POST',
259
+ 'PUT',
260
+ 'PATCH',
261
+ 'DELETE',
262
+ 'OPTIONS',
263
+ ]
264
+
265
+ /**
266
+ * The HTTP plugin: serves the controllers compiled into `metadata.extensions.http` with the chosen
267
+ * adapter.
268
+ *
269
+ * ```ts
270
+ * const app = await Application.create({
271
+ * metadata: __ZNIGHT_METADATA__,
272
+ * plugins: [HttpPlugin.configure({ port: 3000, prefix: '/api' })],
273
+ * })
274
+ * await app.start()
275
+ * ```
276
+ *
277
+ * Lifecycle: `register` declares the request-scoped externals `HttpContext` and `RequestId`;
278
+ * `onBootstrap` resolves the options, controllers, guards and interceptors, collects the routes of
279
+ * other plugins (`httpRoutes()`), checks route decisions and registers the routes; `onStart`
280
+ * listens; `onStop` stops accepting connections and drains in-flight requests (`drainTimeoutMs`);
281
+ * `onDispose` closes the server.
282
+ */
283
+ export class HttpPlugin implements Plugin {
284
+ readonly name = 'http'
285
+ readonly #options: PluginOptions<HttpPluginOptions>
286
+ /** Options resolved for the current application (reset in `register`). */
287
+ #resolved: ResolvedOptions | undefined
288
+ #container: Container | undefined
289
+ #runtime: Runtime | undefined
290
+ #address: HttpServerAddress | undefined
291
+ #disposed = false
292
+ /** Registered in an application that was not disposed yet. */
293
+ #active = false
294
+
295
+ private constructor(options: PluginOptions<HttpPluginOptions>) {
296
+ this.#options = options
297
+ }
298
+
299
+ /**
300
+ * Creates the plugin. `options` may be a function of the container, evaluated in `onBootstrap`:
301
+ * `HttpPlugin.configure(c => ({ port: c.get(AppConfig).port }))`.
302
+ */
303
+ static configure(options: PluginOptions<HttpPluginOptions> = {}): HttpPlugin {
304
+ return new HttpPlugin(options)
305
+ }
306
+
307
+ /** The adapter (available after `onBootstrap`). */
308
+ get adapter(): HttpAdapter {
309
+ return this.#require('adapter').adapter
310
+ }
311
+
312
+ /** Address of the server once listening (`String(address)` is its URL), `undefined` otherwise. */
313
+ get address(): HttpServerAddress | undefined {
314
+ return this.#address
315
+ }
316
+
317
+ /**
318
+ * The path prefix of every route (`''` without prefix, `/api`), for plugins that serve routes or
319
+ * publish URLs. Readable once the plugin is registered (before or after its `onBootstrap`; at any
320
+ * time when the options are not a function of the container). Throws the options error when
321
+ * the options are invalid.
322
+ */
323
+ get prefix(): string {
324
+ const prefix = this.#resolve('prefix').options.prefix
325
+ return prefix === '/' ? '' : prefix
326
+ }
327
+
328
+ /** Registered routes (after `onBootstrap`), in registration order. */
329
+ get routes(): readonly HttpRouteSummary[] {
330
+ return this.#runtime?.routes ?? []
331
+ }
332
+
333
+ /** Number of requests being processed. */
334
+ get inflight(): number {
335
+ return this.#runtime?.pipeline.inflight ?? 0
336
+ }
337
+
338
+ /** Number of open event streams (`@Sse`, `HttpResponse.sse()`): health checks, gauges, stream caps. */
339
+ get streams(): number {
340
+ return this.#runtime?.pipeline.streams ?? 0
341
+ }
342
+
343
+ /**
344
+ * The signed wire value of a cookie (`<encoded value>.<signature>`, signed with the first secret of
345
+ * `cookies.secrets`): what a browser would send back after `setCookie(name, value, { signed: true })`.
346
+ * For gateways minting cookies and for tests. Available after `onBootstrap`.
347
+ *
348
+ * @throws TypeError for an invalid name or value; Error without `cookies.secrets`.
349
+ */
350
+ signCookie(name: string, value: string): string {
351
+ const signer = this.#require('signCookie()').options.cookies.signer
352
+ if (!isCookieName(name)) throw new TypeError(`Invalid cookie name ${JSON.stringify(String(name))}`)
353
+ if (typeof value !== 'string') throw new TypeError(`The value of the cookie "${name}" must be a string`)
354
+ if (signer === undefined) throw missingSecrets(`Cannot sign the cookie "${name}"`)
355
+ return signValue(signer, name, value)
356
+ }
357
+
358
+ /**
359
+ * The provider classes the plugin resolves in `onBootstrap` although no compiled metadata
360
+ * references them: the classes of the `guards`, `interceptors` and `exceptionMappers` options
361
+ * (instances are left out), in that order, without duplicates. `TestApp` from `@znight/testing`
362
+ * creates them at boot with their dependencies (an async dependency cannot be created on demand);
363
+ * pass them to `init: { only }` when restricting the boot with `Application.create()`.
364
+ *
365
+ * Options given as a function of the container cannot be read before the bootstrap: the result is
366
+ * then empty. Never throws: invalid options are reported by `onBootstrap`.
367
+ */
368
+ initRoots(): readonly ProviderKey[] {
369
+ const options: unknown = this.#options
370
+ if (options === null || typeof options !== 'object') return []
371
+ const roots = new Set<ProviderKey>()
372
+ for (const key of GLOBAL_KEYS) {
373
+ try {
374
+ const refs: unknown = (options as HttpPluginOptions)[key]
375
+ if (!Array.isArray(refs)) continue
376
+ for (const ref of refs) {
377
+ if (typeof ref === 'function') roots.add(ref as GuardClass)
378
+ }
379
+ } catch {
380
+ // a throwing getter: reported when onBootstrap reads the option
381
+ }
382
+ }
383
+ return [...roots]
384
+ }
385
+
386
+ /**
387
+ * Sends a request in memory through the adapter and the whole pipeline (no socket; the server
388
+ * does not need to listen). Available after `onBootstrap`.
389
+ *
390
+ * ```ts
391
+ * const res = await http.inject({ method: 'POST', url: '/users', body: { name: 'Ada' } })
392
+ * expect(res.status).toBe(201)
393
+ * expect(res.json()).toEqual({ id: 1, name: 'Ada' })
394
+ * ```
395
+ */
396
+ inject(request: InjectRequest): Promise<InjectResponse> {
397
+ try {
398
+ const runtime = this.#require('inject()')
399
+ return runtime.adapter.inject(withSignedCookies(request, runtime.options.cookies, 'inject()'))
400
+ } catch (error) {
401
+ return Promise.reject(error)
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Sends a request in memory and resolves at the response headers, the body being read as it is
407
+ * produced (`body`). Cancelling `body` (or aborting `signal`) disconnects the client. Requires an
408
+ * adapter implementing `injectStream()` (the four built-in ones do).
409
+ */
410
+ injectStream(request: InjectStreamOptions): Promise<InjectStreamResponse> {
411
+ try {
412
+ if (request === null || typeof request !== 'object')
413
+ throw new TypeError('injectStream() expects a request object')
414
+ const runtime = this.#require('injectStream()')
415
+ const adapter = runtime.adapter
416
+ if (typeof adapter.injectStream !== 'function') {
417
+ throw new ZnightError(
418
+ ErrorCode.LifecycleFailed,
419
+ `HttpPlugin: the ${adapter.name} adapter does not implement injectStream()`,
420
+ { hints: ['Use a built-in adapter (node, fastify, express, hono) or implement HttpAdapter.injectStream()'] },
421
+ )
422
+ }
423
+ const sent = { ...request, method: request.method ?? 'GET' }
424
+ return adapter.injectStream(withSignedCookies(sent, runtime.options.cookies, 'injectStream()'))
425
+ } catch (error) {
426
+ return Promise.reject(error)
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Opens an event stream in memory (`accept: text/event-stream`) and reads its events like
432
+ * `EventSource`: `next()`, `take()`, `comments`, `retry`, `lastEventId`, `reconnect()`, `close()`
433
+ * (the server sees a client disconnect). A response that is not an event stream (404, 204...) is
434
+ * read to its end (`body`). Resolves at the first chunk of the stream (an `@Sse` stream sends
435
+ * `retry` or a comment at once), or after 2 000 ms for a stream that sends nothing yet; rejects,
436
+ * naming the request (which is aborted), when no response headers arrive within 2 000 ms.
437
+ *
438
+ * ```ts
439
+ * await using stream = await http.injectSse({ url: '/orders/o-1/events' })
440
+ * const event = await stream.next() // rejects after 2 000 ms
441
+ * expect(event.json()).toMatchObject({ status: 'paid' })
442
+ * ```
443
+ */
444
+ injectSse(request: InjectStreamOptions): Promise<InjectSseResponse> {
445
+ return openInjectSse(stream => this.injectStream(stream), request)
446
+ }
447
+
448
+ register(ctx: PluginContext): void {
449
+ if (this.#active) {
450
+ throw new ZnightError(ErrorCode.DuplicateProvider, 'This HttpPlugin is already used by another application', {
451
+ hints: ['Call HttpPlugin.configure() once per application'],
452
+ })
453
+ }
454
+ // an instance may serve several applications one after the other (shared test setups)
455
+ this.#active = true
456
+ this.#disposed = false
457
+ this.#runtime = undefined
458
+ this.#address = undefined
459
+ this.#resolved = undefined
460
+ this.#container = ctx.container
461
+ ctx.container.registerExternal({ token: HttpTokens.Context, scope: 'request', providedBy: 'HttpPlugin' })
462
+ ctx.container.registerExternal({ token: HttpTokens.RequestId, scope: 'request', providedBy: 'HttpPlugin' })
463
+ ctx.container.registerValue(HttpTokens.Plugin, this)
464
+ }
465
+
466
+ onBootstrap(ctx: PluginContext): void {
467
+ this.#container = ctx.container
468
+ const { raw, options } = this.#resolve('onBootstrap')
469
+ if (BOUND_ADAPTERS.has(options.adapter)) {
470
+ throw new ZnightError(
471
+ ErrorCode.LifecycleFailed,
472
+ `HttpPlugin: this ${options.adapter.name} adapter instance already served an application`,
473
+ {
474
+ hints: [
475
+ 'An adapter instance serves one application: create it per application, e.g. HttpPlugin.configure(() => ({ adapter: fastifyAdapter() }))',
476
+ ],
477
+ },
478
+ )
479
+ }
480
+ BOUND_ADAPTERS.add(options.adapter)
481
+ const table = new RouteTable<RoutePlan>()
482
+ const pipeline = new Pipeline(
483
+ ctx.container,
484
+ ctx.hooks,
485
+ ctx.logger,
486
+ {
487
+ adapter: options.adapter.name,
488
+ body: bodyPlan({}, options.bodyLimit, options.uploads),
489
+ exposeErrors: options.exposeErrors,
490
+ requestIdHeader: options.requestIdHeader,
491
+ generateRequestId: options.generateRequestId,
492
+ mappers: globalRefs<ExceptionMapper>(ctx.container, ctx.metadata, raw, 'exceptionMappers'),
493
+ cors: options.cors,
494
+ securityHeaders: options.securityHeaders,
495
+ trustProxy: options.trustProxy,
496
+ cookies: options.cookies,
497
+ sse: options.sse,
498
+ },
499
+ table,
500
+ )
501
+ const pluginRoutes = collectPluginRoutes(ctx.app.plugins, this, {
502
+ prefix: options.prefix === '/' ? '' : options.prefix,
503
+ container: ctx.container,
504
+ })
505
+ const { registrations, checked } = buildRegistrations(ctx, options, raw, pluginRoutes)
506
+ checkDecisions(checked.routes, checked.groups, options.enforcement, ctx.logger, checked.enforcers)
507
+ const adapter = options.adapter
508
+ const routes: HttpRouteSummary[] = []
509
+ for (const registration of registrations) {
510
+ table.add(registration.method, registration.path, registration.plan)
511
+ const plan = registration.plan
512
+ adapter.registerRoute({
513
+ method: registration.method,
514
+ path: registration.path,
515
+ handler: (request): Promise<AdapterReply> => pipeline.handle(plan, request),
516
+ })
517
+ routes.push(
518
+ Object.freeze({
519
+ method: registration.method,
520
+ path: registration.path.path,
521
+ controller: registration.source === 'controller' ? displayName(plan.info.controller) : registration.source,
522
+ handler: plan.info.handler,
523
+ source: registration.source,
524
+ meta: plan.info.meta,
525
+ guards: registration.guards,
526
+ }),
527
+ )
528
+ }
529
+ adapter.setFallbackHandler((request, error) => pipeline.fallback(request, error))
530
+ this.#runtime = { adapter, pipeline, options, routes: Object.freeze(routes) }
531
+ }
532
+
533
+ async onStart(ctx: PluginContext): Promise<void> {
534
+ const runtime = this.#require('onStart')
535
+ runtime.pipeline.draining = false
536
+ this.#address = await runtime.adapter.listen(runtime.options.port, runtime.options.host)
537
+ ctx.logger.info(`HTTP server listening on ${this.#address.url}`, {
538
+ adapter: runtime.adapter.name,
539
+ routes: runtime.routes.length,
540
+ })
541
+ }
542
+
543
+ async onStop(ctx: PluginContext): Promise<void> {
544
+ const runtime = this.#runtime
545
+ if (runtime === undefined) return
546
+ runtime.pipeline.draining = true
547
+ // event streams never end by themselves: let their sources finish, then send them a jittered retry
548
+ runtime.pipeline.closeStreams('shutdown')
549
+ const timeoutMs = runtime.options.drainTimeoutMs
550
+ let timer: ReturnType<typeof setTimeout> | undefined
551
+ // no timer for Infinity; larger delays than setTimeout supports are clamped (they would fire at once)
552
+ const timeout = new Promise<'timeout'>(resolve => {
553
+ if (timeoutMs !== Number.POSITIVE_INFINITY) {
554
+ timer = setTimeout(() => resolve('timeout'), Math.min(timeoutMs, MAX_TIMER_MS))
555
+ }
556
+ })
557
+ try {
558
+ const drained = Promise.all([runtime.adapter.close(), runtime.pipeline.whenIdle()]).then(() => 'drained' as const)
559
+ if ((await Promise.race([drained, timeout])) === 'timeout') {
560
+ ctx.logger.warn(`HTTP server: closing ${runtime.pipeline.inflight} in-flight request(s) after ${timeoutMs} ms`)
561
+ await runtime.adapter.close({ force: true })
562
+ }
563
+ } finally {
564
+ clearTimeout(timer)
565
+ this.#address = undefined
566
+ }
567
+ }
568
+
569
+ async onDispose(): Promise<void> {
570
+ this.#active = false
571
+ if (this.#disposed) return
572
+ this.#disposed = true
573
+ const runtime = this.#runtime
574
+ this.#address = undefined
575
+ if (runtime !== undefined) await runtime.adapter.close({ force: true })
576
+ }
577
+
578
+ /** Resolves the options once per application (`prefix` may need them before `onBootstrap`). */
579
+ #resolve(action: string): ResolvedOptions {
580
+ if (this.#resolved !== undefined) return this.#resolved
581
+ let raw: HttpPluginOptions
582
+ if (typeof this.#options === 'function') {
583
+ const container = this.#container
584
+ if (container === undefined) {
585
+ throw new ZnightError(
586
+ ErrorCode.LifecycleFailed,
587
+ `HttpPlugin: ${action} is only available once the plugin is registered in an application (its options are a function of the container)`,
588
+ )
589
+ }
590
+ raw = resolveOptions(this.#options, container)
591
+ } else {
592
+ raw = this.#options
593
+ }
594
+ const resolved: ResolvedOptions = { raw, options: resolve(raw) }
595
+ this.#resolved = resolved
596
+ return resolved
597
+ }
598
+
599
+ #require(action: string): Runtime {
600
+ if (this.#disposed) {
601
+ throw new ZnightError(ErrorCode.LifecycleFailed, `HttpPlugin: cannot use ${action} after the application stopped`)
602
+ }
603
+ if (this.#runtime === undefined) {
604
+ throw new ZnightError(
605
+ ErrorCode.LifecycleFailed,
606
+ `HttpPlugin: ${action} is only available once the application is created (after onBootstrap)`,
607
+ )
608
+ }
609
+ return this.#runtime
610
+ }
611
+ }
612
+
613
+ // -------------------------------------------------------------------------------------------------
614
+ // Options
615
+ // -------------------------------------------------------------------------------------------------
616
+
617
+ function resolve(options: HttpPluginOptions): Resolved {
618
+ if (options === null || typeof options !== 'object') throw invalidOption('options', 'an object', options)
619
+ const port = options.port ?? DEFAULT_PORT
620
+ if (!Number.isInteger(port) || port < 0 || port > 65_535)
621
+ throw invalidOption('port', 'an integer between 0 and 65535', port)
622
+ const host = options.host ?? DEFAULT_HOST
623
+ if (typeof host !== 'string' || host === '') throw invalidOption('host', 'a non-empty string', host)
624
+ const prefix = options.prefix ?? ''
625
+ if (typeof prefix !== 'string') throw invalidOption('prefix', 'a string', prefix)
626
+ let parsedPrefix: RoutePath
627
+ try {
628
+ parsedPrefix = parseRoutePath(prefix)
629
+ } catch (error) {
630
+ throw invalidOption('prefix', 'a valid static path', prefix, error)
631
+ }
632
+ if (parsedPrefix.segments.some(segment => segment.kind !== 'static')) {
633
+ throw invalidOption('prefix', 'a static path (no :param or *)', prefix)
634
+ }
635
+ const trailingSlash = options.trailingSlash ?? 'strict'
636
+ if (trailingSlash !== 'strict' && trailingSlash !== 'ignore' && trailingSlash !== 'redirect') {
637
+ throw invalidOption('trailingSlash', '"strict", "ignore" or "redirect"', trailingSlash)
638
+ }
639
+ const bodyLimit = parseByteSize(options.bodyLimit ?? DEFAULT_BODY_LIMIT)
640
+ if (bodyLimit === undefined) {
641
+ throw invalidOption('bodyLimit', "a size in bytes (1048576, '10mb', Infinity)", options.bodyLimit)
642
+ }
643
+ const uploads = resolveUploadLimits(options.uploads)
644
+ const drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS
645
+ if (typeof drainTimeoutMs !== 'number' || !(drainTimeoutMs >= 0)) {
646
+ throw invalidOption('drainTimeoutMs', 'a positive number (or Infinity)', drainTimeoutMs)
647
+ }
648
+ const requestIdHeader = options.requestIdHeader ?? DEFAULT_REQUEST_ID_HEADER
649
+ if (requestIdHeader !== false && (typeof requestIdHeader !== 'string' || !isHeaderName(requestIdHeader))) {
650
+ throw invalidOption('requestIdHeader', 'a header name or false', requestIdHeader)
651
+ }
652
+ const generateRequestId = options.generateRequestId ?? ((): string => crypto.randomUUID())
653
+ if (typeof generateRequestId !== 'function') throw invalidOption('generateRequestId', 'a function', generateRequestId)
654
+ const adapter = options.adapter ?? nodeAdapter()
655
+ if (
656
+ adapter === null ||
657
+ typeof adapter !== 'object' ||
658
+ typeof adapter.registerRoute !== 'function' ||
659
+ typeof adapter.listen !== 'function' ||
660
+ typeof adapter.inject !== 'function'
661
+ ) {
662
+ throw invalidOption('adapter', 'an HttpAdapter (nodeAdapter(), fastifyAdapter()...)', adapter)
663
+ }
664
+ const enforcement = options.enforcement ?? 'error'
665
+ if (enforcement !== 'error' && enforcement !== 'warn') {
666
+ throw invalidOption('enforcement', '"error" or "warn"', enforcement)
667
+ }
668
+ const resolvedRequestIdHeader = requestIdHeader === false ? undefined : requestIdHeader.toLowerCase()
669
+ const cookies = resolveCookies(options.cookies)
670
+ const sse = resolveSse(options.sse, drainTimeoutMs)
671
+ return {
672
+ adapter,
673
+ port,
674
+ host,
675
+ prefix: parsedPrefix.path,
676
+ trailingSlash,
677
+ exposeErrors: options.exposeErrors === true,
678
+ bodyLimit,
679
+ uploads,
680
+ requestIdHeader: resolvedRequestIdHeader,
681
+ generateRequestId,
682
+ drainTimeoutMs,
683
+ cors: resolveCors(options.cors, resolvedRequestIdHeader),
684
+ securityHeaders: resolveSecurityHeaders(options.securityHeaders),
685
+ trustProxy: resolveTrustProxy(options.trustProxy),
686
+ enforcement,
687
+ cookies,
688
+ sse,
689
+ }
690
+ }
691
+
692
+ const COOKIE_DEFAULT_KEYS = ['domain', 'path', 'secure', 'httpOnly', 'sameSite', 'partitioned', 'priority']
693
+
694
+ function resolveCookies(value: HttpCookieOptions | undefined): CookieSettings {
695
+ if (value === undefined) return DEFAULT_COOKIE_SETTINGS
696
+ if (value === null || typeof value !== 'object')
697
+ throw invalidOption('cookies', 'an object { secrets?, defaults? }', value)
698
+ for (const key of Object.keys(value)) {
699
+ if (key !== 'secrets' && key !== 'defaults') {
700
+ throw invalidOption('cookies', 'an object { secrets?, defaults? }', `unknown key "${key}"`)
701
+ }
702
+ }
703
+ let signer: CookieSigner | undefined
704
+ if (value.secrets !== undefined) {
705
+ const checked = checkSecrets(value.secrets)
706
+ // never echo a secret in an error
707
+ if ('problem' in checked) {
708
+ throw new ZnightError(
709
+ ErrorCode.InvalidDecorator,
710
+ `HttpPlugin: option "cookies.secrets" must be ${checked.problem}`,
711
+ {
712
+ hints: ["Generate secrets with crypto.randomBytes(32).toString('base64url') and load them with @Secret"],
713
+ },
714
+ )
715
+ }
716
+ signer = new CookieSigner(checked.secrets as readonly CookieSecret[])
717
+ }
718
+ const given = value.defaults
719
+ if (given !== undefined && (given === null || typeof given !== 'object' || Array.isArray(given))) {
720
+ throw invalidOption('cookies.defaults', `an object { ${COOKIE_DEFAULT_KEYS.join('?, ')}? }`, given)
721
+ }
722
+ const defaults: CookieDefaults = { ...DEFAULT_COOKIE_OPTIONS }
723
+ for (const [key, option] of Object.entries(given ?? {})) {
724
+ if (!COOKIE_DEFAULT_KEYS.includes(key)) {
725
+ throw invalidOption(
726
+ 'cookies.defaults',
727
+ `an object { ${COOKIE_DEFAULT_KEYS.join('?, ')}? }`,
728
+ `unknown key "${key}"`,
729
+ )
730
+ }
731
+ if (option !== undefined) (defaults as Record<string, unknown>)[key] = option
732
+ }
733
+ try {
734
+ // a probe cookie checks every default and their combination (SameSite=None without Secure...)
735
+ serializeResponseCookie({ name: 'probe', value: '', options: {} }, defaults)
736
+ } catch (error) {
737
+ throw invalidOption('cookies.defaults', 'valid cookie attributes', given, error, [
738
+ error instanceof Error ? error.message.replace('Invalid cookie "probe": ', '') : String(error),
739
+ ])
740
+ }
741
+ return Object.freeze({ defaults: Object.freeze(defaults), signer })
742
+ }
743
+
744
+ function resolveSse(value: HttpSseOptions | undefined, drainTimeoutMs: number): SseSettings {
745
+ if (value === undefined) return DEFAULT_SSE_SETTINGS
746
+ const shape = '{ heartbeat?, retry?, shutdownGraceMs? }'
747
+ if (value === null || typeof value !== 'object') throw invalidOption('sse', `an object ${shape}`, value)
748
+ for (const key of Object.keys(value)) {
749
+ if (key !== 'heartbeat' && key !== 'retry' && key !== 'shutdownGraceMs') {
750
+ throw invalidOption('sse', `an object ${shape}`, `unknown key "${key}"`)
751
+ }
752
+ }
753
+ const heartbeat = value.heartbeat ?? DEFAULT_SSE_SETTINGS.heartbeat
754
+ if (
755
+ heartbeat !== false &&
756
+ !(Number.isSafeInteger(heartbeat) && heartbeat >= MIN_SSE_INTERVAL_MS && heartbeat <= MAX_SSE_TIMER_MS)
757
+ ) {
758
+ throw invalidOption(
759
+ 'sse.heartbeat',
760
+ `an integer number of ms from ${MIN_SSE_INTERVAL_MS} to ${MAX_SSE_TIMER_MS}, or false`,
761
+ heartbeat,
762
+ )
763
+ }
764
+ const retry = value.retry
765
+ if (retry !== undefined && !(Number.isSafeInteger(retry) && retry >= 0)) {
766
+ throw invalidOption('sse.retry', 'an integer number of ms ≥ 0', retry)
767
+ }
768
+ const shutdownGraceMs = value.shutdownGraceMs ?? DEFAULT_SSE_SETTINGS.shutdownGraceMs
769
+ if (!(Number.isSafeInteger(shutdownGraceMs) && shutdownGraceMs >= 0 && shutdownGraceMs <= MAX_SSE_TIMER_MS)) {
770
+ throw invalidOption('sse.shutdownGraceMs', `an integer number of ms from 0 to ${MAX_SSE_TIMER_MS}`, shutdownGraceMs)
771
+ }
772
+ if (drainTimeoutMs !== Number.POSITIVE_INFINITY && shutdownGraceMs >= drainTimeoutMs) {
773
+ throw invalidOption(
774
+ 'sse.shutdownGraceMs',
775
+ `lower than drainTimeoutMs (${drainTimeoutMs})`,
776
+ shutdownGraceMs,
777
+ undefined,
778
+ [
779
+ 'Event streams get shutdownGraceMs to finish, within the drain of in-flight requests: shutdownGraceMs < drainTimeoutMs < shutdown.hookTimeoutMs',
780
+ ],
781
+ )
782
+ }
783
+ return Object.freeze({ heartbeat: heartbeat === false ? 0 : heartbeat, retry, shutdownGraceMs })
784
+ }
785
+
786
+ /**
787
+ * The request with its `signedCookies` signed (first secret) and merged into `cookies`: adapters
788
+ * never receive `signedCookies`.
789
+ */
790
+ function withSignedCookies<R extends InjectRequest>(request: R, cookies: CookieSettings, action: string): R {
791
+ if (request === null || typeof request !== 'object' || request.signedCookies === undefined) return request
792
+ const signed = request.signedCookies
793
+ if (signed === null || typeof signed !== 'object') throw new TypeError(`${action}: signedCookies must be an object`)
794
+ const signer = cookies.signer
795
+ if (signer === undefined) {
796
+ throw new TypeError(
797
+ `${action}: signedCookies require the HttpPlugin option cookies.secrets (HttpPlugin.configure({ cookies: { secrets: [...] } }))`,
798
+ )
799
+ }
800
+ const merged: Record<string, string> = { ...request.cookies }
801
+ for (const name of Object.keys(signed)) {
802
+ const value = signed[name]
803
+ if (!isCookieName(name)) throw new TypeError(`${action}: invalid signed cookie name ${JSON.stringify(name)}`)
804
+ if (typeof value !== 'string')
805
+ throw new TypeError(`${action}: the value of the signed cookie "${name}" must be a string`)
806
+ // the decoded wire value: inject() encodes cookie values again (the encoding is canonical)
807
+ merged[name] = decodeCookieValue(signValue(signer, name, value))
808
+ }
809
+ const { signedCookies: _signed, ...rest } = request
810
+ return { ...rest, cookies: merged } as R
811
+ }
812
+
813
+ function resolveUploadLimits(value: UploadLimits | undefined): ResolvedUploadLimits {
814
+ if (value === undefined) return DEFAULT_UPLOAD_LIMITS
815
+ if (value === null || typeof value !== 'object') throw invalidOption('uploads', 'an object', value)
816
+ const size = (key: 'maxFileSize' | 'maxFieldSize'): number => {
817
+ const raw = value[key]
818
+ if (raw === undefined) return DEFAULT_UPLOAD_LIMITS[key]
819
+ const parsed = parseByteSize(raw)
820
+ if (parsed === undefined) throw invalidOption(`uploads.${key}`, "a size in bytes (1048576, '10mb', Infinity)", raw)
821
+ return parsed
822
+ }
823
+ const count = (key: 'maxFiles' | 'maxParts'): number => {
824
+ const raw = value[key]
825
+ if (raw === undefined) return DEFAULT_UPLOAD_LIMITS[key]
826
+ if (raw !== Number.POSITIVE_INFINITY && (!Number.isSafeInteger(raw) || raw < 1)) {
827
+ throw invalidOption(`uploads.${key}`, 'a positive integer (or Infinity)', raw)
828
+ }
829
+ return raw
830
+ }
831
+ for (const key of Object.keys(value)) {
832
+ if (!['maxFileSize', 'maxFiles', 'maxParts', 'maxFieldSize'].includes(key)) {
833
+ throw invalidOption('uploads', '{ maxFileSize?, maxFiles?, maxParts?, maxFieldSize? }', value)
834
+ }
835
+ }
836
+ return {
837
+ maxFileSize: size('maxFileSize'),
838
+ maxFiles: count('maxFiles'),
839
+ maxParts: count('maxParts'),
840
+ maxFieldSize: size('maxFieldSize'),
841
+ }
842
+ }
843
+
844
+ // -------------------------------------------------------------------------------------------------
845
+ // Routes
846
+ // -------------------------------------------------------------------------------------------------
847
+
848
+ function httpMetadata(metadata: ZnightMetadata): HttpMetadata {
849
+ const http = metadata.extensions?.http
850
+ if (http === undefined) return { controllers: [] }
851
+ if (http === null || typeof http !== 'object' || !Array.isArray(http.controllers)) {
852
+ throw new ZnightError(ErrorCode.InvalidRoute, 'Invalid metadata.extensions.http: expected { controllers: [...] }')
853
+ }
854
+ return http
855
+ }
856
+
857
+ /** Routes as seen by the boot check of route decisions, with the groups they are checked against. */
858
+ interface Checked {
859
+ readonly routes: readonly CheckedRoute[]
860
+ readonly groups: RouteGroups
861
+ /** Group → the guard classes enforcing it, in a chain or not. */
862
+ readonly enforcers: ReadonlyMap<string, ReadonlySet<string>>
863
+ }
864
+
865
+ /** A declared route (controller route or plugin route), before HEAD, `@All` and trailing-slash expansion. */
866
+ interface Declared {
867
+ readonly method: HttpMethod
868
+ readonly path: RoutePath
869
+ readonly plan: (method: string) => RoutePlan
870
+ readonly label: string
871
+ readonly source: string
872
+ readonly guards: readonly GuardEntry[]
873
+ /** Effective metadata (available once every group is known). */
874
+ meta(): RouteMeta
875
+ }
876
+
877
+ function buildRegistrations(
878
+ ctx: PluginContext,
879
+ options: Resolved,
880
+ raw: HttpPluginOptions,
881
+ pluginRoutes: readonly CollectedPluginRoute[],
882
+ ): { readonly registrations: Registration[]; readonly checked: Checked } {
883
+ const { container, metadata } = ctx
884
+ const http = httpMetadata(metadata)
885
+ const groups = RouteGroups.fromMetadata(http.groups)
886
+ const groupOf = (key: string): string => groups.groupOf(key)
887
+ const resolvables = new Map<Token, Resolvable<unknown>>()
888
+ const lookup = <T>(token: Token, what: string): Resolvable<T> => {
889
+ let found = resolvables.get(token)
890
+ if (found === undefined) {
891
+ found = resolvable(container, metadata, token, what)
892
+ resolvables.set(token, found)
893
+ }
894
+ return found as Resolvable<T>
895
+ }
896
+ const compiledGuard = (token: Token, what: string): GuardEntry => ({
897
+ resolvable: lookup<CanActivate>(token, what),
898
+ // the compiled class: a guard replaced through `overrides` in a test keeps its declaration
899
+ cls: Object.hasOwn(metadata.providers, token) ? metadata.providers[token]?.class : undefined,
900
+ name: displayName(token),
901
+ })
902
+ const globalGuards = guardEntries(container, metadata, raw.guards ?? [], ref =>
903
+ invalidOption('guards', 'classes or objects with a canActivate() method', ref),
904
+ )
905
+ const globalInterceptors = globalRefs<HttpInterceptor>(container, metadata, raw, 'interceptors')
906
+ const declared: Declared[] = []
907
+ /** Checks run once every group is declared. */
908
+ const pending: (() => void)[] = []
909
+
910
+ for (const controller of http.controllers) {
911
+ assertController(controller)
912
+ const name = displayName(controller.token)
913
+ const instance = lookup<object>(controller.token, `controller ${name}`)
914
+ const controllerGuards = controller.guards.map(token => compiledGuard(token, `guard of ${name}`))
915
+ const controllerInterceptors = controller.interceptors.map(token =>
916
+ lookup<HttpInterceptor>(token, `interceptor of ${name}`),
917
+ )
918
+ const controllerMeta = assertMeta(controller.meta, name)
919
+ pending.push(() => checkTargetMeta(controllerMeta, groupOf, name))
920
+ for (const route of controller.routes) {
921
+ const where = `${name}.${String(route?.handler)}`
922
+ assertRoute(route, where)
923
+ if (options.cookies.signer === undefined) {
924
+ const signed = route.params.find(param => param?.source === 'signedCookie')
925
+ if (signed !== undefined) {
926
+ throw new ZnightError(
927
+ ErrorCode.MissingConfig,
928
+ `HttpPlugin: ${where} reads a signed cookie (@SignedCookie('${signed.name ?? ''}')) but cookies.secrets is not set`,
929
+ {
930
+ hints: [
931
+ "Set HttpPlugin.configure({ cookies: { secrets: [...] } }) with secrets of at least 32 bytes (crypto.randomBytes(32).toString('base64url'))",
932
+ ],
933
+ },
934
+ )
935
+ }
936
+ }
937
+ let path: RoutePath
938
+ try {
939
+ path = parseRoutePath(joinPaths(options.prefix, route.fullPath))
940
+ } catch (error) {
941
+ if (!(error instanceof RoutePathError)) throw error
942
+ throw new ZnightError(
943
+ ErrorCode.InvalidRoute,
944
+ `Invalid route ${route.method} ${route.fullPath} (${where}): ${error.issue.message}`,
945
+ { cause: error },
946
+ )
947
+ }
948
+ const routeMeta = assertMeta(route.meta, where)
949
+ const guards = [
950
+ ...globalGuards,
951
+ ...controllerGuards,
952
+ ...route.guards.map(token => compiledGuard(token, `guard of ${where}`)),
953
+ ]
954
+ const interceptors = [
955
+ ...globalInterceptors,
956
+ ...controllerInterceptors,
957
+ ...route.interceptors.map(token => lookup<HttpInterceptor>(token, `interceptor of ${where}`)),
958
+ ]
959
+ const body = bodyPlan(route, options.bodyLimit, options.uploads)
960
+ const parseBody = body.upload || route.params.some(param => param.source === 'body')
961
+ const chain = guards.map(guard => guard.resolvable)
962
+ // computed once every group is declared (guards of every chain, plugin routes), then frozen
963
+ let meta: RouteMeta | undefined
964
+ const effective = (): RouteMeta => {
965
+ meta ??= effectiveMeta(controllerMeta, routeMeta, groupOf)
966
+ return meta
967
+ }
968
+ pending.push(() => checkTargetMeta(routeMeta, groupOf, where))
969
+ declared.push({
970
+ method: route.method,
971
+ path,
972
+ plan: (method: string): RoutePlan => ({
973
+ definition: route,
974
+ path,
975
+ info: Object.freeze({
976
+ method,
977
+ path: path.path,
978
+ controller: controller.token,
979
+ handler: route.handler,
980
+ meta: effective(),
981
+ }),
982
+ controller: instance,
983
+ guards: chain,
984
+ interceptors,
985
+ parseBody,
986
+ body,
987
+ }),
988
+ label: where,
989
+ source: 'controller',
990
+ guards,
991
+ meta: effective,
992
+ })
993
+ }
994
+ }
995
+
996
+ for (const route of pluginRoutes) {
997
+ const label = `plugin "${route.plugin}" (${route.name})`
998
+ const where = `${route.method} ${route.path.path} (${route.name})`
999
+ const guards = [
1000
+ ...globalGuards,
1001
+ ...guardEntries(
1002
+ container,
1003
+ metadata,
1004
+ route.guards,
1005
+ ref =>
1006
+ new ZnightError(
1007
+ ErrorCode.InvalidRoute,
1008
+ `HttpPlugin: plugin "${route.plugin}": the guards of the route ${where} must be classes or objects with a canActivate() method (received ${describeValue(ref)})`,
1009
+ ),
1010
+ ),
1011
+ ]
1012
+ const values: Record<string, unknown> = {}
1013
+ for (const entry of route.meta) {
1014
+ groups.declare(entry, `the plugin "${route.plugin}"`)
1015
+ values[entry.key] = entry.value
1016
+ }
1017
+ const meta: RouteMeta = Object.freeze(values)
1018
+ pending.push(() => checkTargetMeta(meta, groupOf, `the route ${where} of the plugin "${route.plugin}"`))
1019
+ const handler = route.handler
1020
+ const definition: HttpRouteDefinition = Object.freeze({
1021
+ method: route.method,
1022
+ path: route.relativePath,
1023
+ fullPath: route.relativePath,
1024
+ handler: route.name,
1025
+ meta,
1026
+ guards: [],
1027
+ interceptors: [],
1028
+ params: [],
1029
+ invoke: (_plugin: unknown, http: HttpContext): unknown => handler(http),
1030
+ })
1031
+ const instance: Resolvable<object> = { instance: route.instance }
1032
+ const body = bodyPlan({}, options.bodyLimit, options.uploads)
1033
+ const chain = guards.map(guard => guard.resolvable)
1034
+ const path = route.path
1035
+ declared.push({
1036
+ method: route.method,
1037
+ path,
1038
+ plan: (method: string): RoutePlan => ({
1039
+ definition,
1040
+ path,
1041
+ info: Object.freeze({ method, path: path.path, controller: route.plugin, handler: route.name, meta }),
1042
+ controller: instance,
1043
+ guards: chain,
1044
+ // global interceptors wrap controllers only (a response envelope would break a document)
1045
+ interceptors: [],
1046
+ parseBody: false,
1047
+ body,
1048
+ }),
1049
+ label,
1050
+ source: route.plugin,
1051
+ guards,
1052
+ meta: () => meta,
1053
+ })
1054
+ }
1055
+
1056
+ // `enforces` of every guard class, read once per class. The guards of the chains declare their
1057
+ // groups; the guard classes of the application that sit in no chain only make their groups known,
1058
+ // so that a decision whose guard was forgotten everywhere (a second entrypoint, a worker, a test)
1059
+ // is still reported instead of being silently unenforced
1060
+ const enforcedGroups = new Map<unknown, ReadonlySet<string>>()
1061
+ const enforcers = new Map<string, Set<string>>()
1062
+ const readEnforces = (cls: unknown, name: string, chained: boolean): void => {
1063
+ if (enforcedGroups.has(cls)) return
1064
+ const infos = enforcedBy(cls, name)
1065
+ if (chained) for (const info of infos) groups.declare(info, `the guard ${name}`)
1066
+ enforcedGroups.set(cls, new Set(infos.map(info => info.group)))
1067
+ for (const info of infos) {
1068
+ let names = enforcers.get(info.group)
1069
+ if (names === undefined) {
1070
+ names = new Set()
1071
+ enforcers.set(info.group, names)
1072
+ }
1073
+ names.add(name)
1074
+ }
1075
+ }
1076
+ for (const route of declared) for (const guard of route.guards) readEnforces(guard.cls, guard.name, true)
1077
+ for (const token of Object.keys(metadata.providers)) {
1078
+ const cls = metadata.providers[token]?.class
1079
+ if (isGuardClass(cls)) readEnforces(cls, displayName(token), false)
1080
+ }
1081
+ for (const check of pending) check()
1082
+ const checked: CheckedRoute[] = declared.map(route => {
1083
+ const enforced = new Set<string>()
1084
+ for (const guard of route.guards) for (const group of enforcedGroups.get(guard.cls) ?? []) enforced.add(group)
1085
+ return {
1086
+ description: `${route.method} ${route.path.path} (${route.label})`,
1087
+ meta: route.meta(),
1088
+ enforced,
1089
+ ...(route.source === 'controller' ? {} : { plugin: route.source }),
1090
+ }
1091
+ })
1092
+
1093
+ const registration = (route: Declared, method: Exclude<HttpMethod, 'ALL'>): Registration => ({
1094
+ method,
1095
+ path: route.path,
1096
+ plan: route.plan(method),
1097
+ label: route.label,
1098
+ source: route.source,
1099
+ guards: Object.freeze(route.guards.map(guard => guard.name)),
1100
+ })
1101
+ const explicit: Registration[] = []
1102
+ const catchAll: Declared[] = []
1103
+ for (const route of declared) {
1104
+ if (route.method === 'ALL') catchAll.push(route)
1105
+ else explicit.push(registration(route, route.method))
1106
+ }
1107
+
1108
+ // explicit routes, then HEAD for GET routes, then @All routes for the methods left
1109
+ const table = new RouteTable<Registration>()
1110
+ const accepted: Registration[] = []
1111
+ const add = (candidate: Registration, duplicate: boolean): void => {
1112
+ const existing = table.add(candidate.method, candidate.path, candidate)
1113
+ if (existing === undefined) accepted.push(candidate)
1114
+ else if (duplicate) throw duplicateRoute(candidate, existing)
1115
+ }
1116
+ for (const candidate of explicit) add(candidate, true)
1117
+ for (const candidate of explicit) {
1118
+ if (candidate.method === 'GET') add({ ...candidate, method: 'HEAD' }, false)
1119
+ }
1120
+ const catchAllShapes = new Map<string, Registration>()
1121
+ for (const route of catchAll) {
1122
+ const first = registration(route, 'GET')
1123
+ const other = catchAllShapes.get(route.path.shape)
1124
+ if (other !== undefined) throw duplicateRoute(first, other)
1125
+ catchAllShapes.set(route.path.shape, first)
1126
+ for (const method of CONCRETE_METHODS) add(method === 'GET' ? first : registration(route, method), false)
1127
+ }
1128
+
1129
+ // trailing-slash variants
1130
+ const result: Registration[] = [...accepted]
1131
+ if (options.trailingSlash !== 'strict') {
1132
+ for (const candidate of accepted) {
1133
+ const path = candidate.path
1134
+ if (path.segments.length === 0 || path.wildcard) continue
1135
+ const variant: RoutePath = {
1136
+ path: `${path.path}/`,
1137
+ segments: [...path.segments, { kind: 'static', value: '' }],
1138
+ params: path.params,
1139
+ wildcard: false,
1140
+ shape: `${path.shape}/`,
1141
+ }
1142
+ const plan: RoutePlan =
1143
+ options.trailingSlash === 'redirect'
1144
+ ? { ...candidate.plan, path: variant, redirect: true }
1145
+ : { ...candidate.plan, path: variant }
1146
+ result.push({ ...candidate, path: variant, plan })
1147
+ }
1148
+ }
1149
+
1150
+ // HEAD first (Express answers HEAD with the first matching GET route), then most specific first
1151
+ const registrations = result.sort((a, b) => {
1152
+ const head = (a.method === 'HEAD' ? 0 : 1) - (b.method === 'HEAD' ? 0 : 1)
1153
+ if (head !== 0) return head
1154
+ return compareRoutePaths(a.path, b.path) || compareStrings(a.method, b.method)
1155
+ })
1156
+ return { registrations, checked: { routes: checked, groups, enforcers } }
1157
+ }
1158
+
1159
+ const EMPTY_META: RouteMeta = Object.freeze({})
1160
+
1161
+ /** Hand-written metadata: two keys of one group on one target (ZNIGHT007). */
1162
+ function checkTargetMeta(meta: RouteMeta, groupOf: (key: string) => string, target: string): void {
1163
+ const conflict = conflictingKeys(meta, groupOf)
1164
+ if (conflict === undefined) return
1165
+ throw new ZnightError(
1166
+ ErrorCode.InvalidRoute,
1167
+ `Invalid route metadata of ${target}: "${conflict[0]}" and "${conflict[1]}" are two decisions of the "${conflict[2]}" group`,
1168
+ { hints: ['The decorators of a group are alternatives: keep one per controller or route'] },
1169
+ )
1170
+ }
1171
+
1172
+ function duplicateRoute(registration: Registration, existing: Registration): ZnightError {
1173
+ const plugin = registration.source === 'controller' ? existing.source : registration.source
1174
+ return new ZnightError(
1175
+ ErrorCode.InvalidRoute,
1176
+ `Duplicate route ${registration.method} ${registration.path.path}: ${registration.label} conflicts with ${existing.label} (${existing.path.path})`,
1177
+ {
1178
+ hints: [
1179
+ 'Two routes with the same method and the same path shape (parameter names aside) cannot coexist',
1180
+ ...(plugin === 'controller' ? [] : [`Change the path of the route in the options of the plugin "${plugin}"`]),
1181
+ ],
1182
+ },
1183
+ )
1184
+ }
1185
+
1186
+ /** Route metadata must be a plain object (hand-written metadata). */
1187
+ function assertMeta(meta: unknown, where: string): RouteMeta {
1188
+ if (meta === undefined) return EMPTY_META
1189
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
1190
+ throw new ZnightError(
1191
+ ErrorCode.InvalidRoute,
1192
+ `Invalid route metadata of ${where}: expected an object (received ${describeValue(meta)})`,
1193
+ )
1194
+ }
1195
+ return meta as RouteMeta
1196
+ }
1197
+
1198
+ function assertController(controller: HttpControllerDefinition): void {
1199
+ if (
1200
+ controller === null ||
1201
+ typeof controller !== 'object' ||
1202
+ typeof controller.token !== 'string' ||
1203
+ !Array.isArray(controller.routes) ||
1204
+ !Array.isArray(controller.guards) ||
1205
+ !Array.isArray(controller.interceptors)
1206
+ ) {
1207
+ throw new ZnightError(
1208
+ ErrorCode.InvalidRoute,
1209
+ `Invalid controller definition in metadata.extensions.http: ${describeValue(controller)}`,
1210
+ )
1211
+ }
1212
+ }
1213
+
1214
+ function assertRoute(route: HttpRouteDefinition, where: string): void {
1215
+ if (
1216
+ route === null ||
1217
+ typeof route !== 'object' ||
1218
+ typeof route.invoke !== 'function' ||
1219
+ typeof route.handler !== 'string'
1220
+ ) {
1221
+ throw new ZnightError(ErrorCode.InvalidRoute, `Invalid route definition ${where}: missing handler or invoke()`)
1222
+ }
1223
+ if (!(CONCRETE_METHODS as readonly string[]).includes(route.method) && route.method !== 'ALL') {
1224
+ throw new ZnightError(
1225
+ ErrorCode.InvalidRoute,
1226
+ `Invalid route ${where}: unknown method ${describeValue(route.method)}`,
1227
+ )
1228
+ }
1229
+ if (typeof route.fullPath !== 'string') {
1230
+ throw new ZnightError(ErrorCode.InvalidRoute, `Invalid route ${where}: missing fullPath`)
1231
+ }
1232
+ if (route.status !== undefined && !isValidStatus(route.status)) {
1233
+ throw new ZnightError(
1234
+ ErrorCode.InvalidRoute,
1235
+ `Invalid route ${where}: @HttpCode(${describeValue(route.status)}) is not a valid status`,
1236
+ )
1237
+ }
1238
+ if (!Array.isArray(route.guards) || !Array.isArray(route.interceptors) || !Array.isArray(route.params)) {
1239
+ throw new ZnightError(
1240
+ ErrorCode.InvalidRoute,
1241
+ `Invalid route ${where}: guards, interceptors and params must be arrays`,
1242
+ )
1243
+ }
1244
+ if (route.bodyLimit !== undefined && !isSize(route.bodyLimit)) {
1245
+ throw new ZnightError(
1246
+ ErrorCode.InvalidRoute,
1247
+ `Invalid route ${where}: bodyLimit must be a number of bytes (or Infinity), got ${describeValue(route.bodyLimit)}`,
1248
+ )
1249
+ }
1250
+ if (route.uploads !== undefined) assertUploads(route, where)
1251
+ if (route.sse !== undefined) assertSse(route, where)
1252
+ }
1253
+
1254
+ function assertSse(route: HttpRouteDefinition, where: string): void {
1255
+ const sse: unknown = route.sse
1256
+ const invalid = (reason: string): ZnightError =>
1257
+ new ZnightError(ErrorCode.InvalidRoute, `Invalid route ${where}: ${reason}`, {
1258
+ hints: [
1259
+ 'sse: { heartbeat?: 0 | 1000 ≤ ms ≤ 2147483647, retry?: ms ≥ 0, maxDuration?: 1000 ≤ ms ≤ 2147483647 } on a GET route',
1260
+ ],
1261
+ })
1262
+ if (route.method !== 'GET') throw invalid(`sse is only valid on GET routes (method ${route.method})`)
1263
+ if (sse === null || typeof sse !== 'object' || Array.isArray(sse)) throw invalid('sse must be an object')
1264
+ for (const [key, value] of Object.entries(sse)) {
1265
+ if (value === undefined) continue
1266
+ const integer = typeof value === 'number' && Number.isSafeInteger(value)
1267
+ const timer = integer && value >= MIN_SSE_INTERVAL_MS && value <= MAX_SSE_TIMER_MS
1268
+ const valid =
1269
+ (key === 'heartbeat' && integer && (value === 0 || timer)) ||
1270
+ (key === 'retry' && integer && value >= 0) ||
1271
+ (key === 'maxDuration' && timer)
1272
+ if (!valid) throw invalid(`invalid sse.${key} ${describeValue(value)}`)
1273
+ }
1274
+ }
1275
+
1276
+ function isSize(value: unknown): boolean {
1277
+ return value === Number.POSITIVE_INFINITY || (Number.isSafeInteger(value) && (value as number) >= 0)
1278
+ }
1279
+
1280
+ function assertUploads(route: HttpRouteDefinition, where: string): void {
1281
+ const invalid = (reason: string): ZnightError =>
1282
+ new ZnightError(ErrorCode.InvalidRoute, `Invalid route ${where}: ${reason}`, {
1283
+ hints: ['uploads: [{ field?, multiple?, required?, maxSize?, maxFiles?, mimeTypes? }]'],
1284
+ })
1285
+ if (!Array.isArray(route.uploads)) throw invalid('uploads must be an array')
1286
+ const fields = new Set<string | undefined>()
1287
+ for (const upload of route.uploads) {
1288
+ if (upload === null || typeof upload !== 'object') throw invalid(`invalid upload ${describeValue(upload)}`)
1289
+ const { field, maxSize, maxFiles, mimeTypes } = upload
1290
+ if (field !== undefined && (typeof field !== 'string' || field === '')) {
1291
+ throw invalid(`invalid upload field ${describeValue(field)}`)
1292
+ }
1293
+ if (fields.has(field) || (fields.size > 0 && (field === undefined || fields.has(undefined)))) {
1294
+ throw invalid(
1295
+ field === undefined
1296
+ ? 'an upload without field (every file) cannot be combined with other uploads'
1297
+ : `several uploads for the field "${field}"`,
1298
+ )
1299
+ }
1300
+ fields.add(field)
1301
+ if (maxSize !== undefined && !isSize(maxSize)) throw invalid(`invalid maxSize ${describeValue(maxSize)}`)
1302
+ if (maxFiles !== undefined && (!isSize(maxFiles) || maxFiles < 1)) {
1303
+ throw invalid(`invalid maxFiles ${describeValue(maxFiles)}`)
1304
+ }
1305
+ if (
1306
+ mimeTypes !== undefined &&
1307
+ (!Array.isArray(mimeTypes) ||
1308
+ mimeTypes.length === 0 ||
1309
+ !mimeTypes.every(type => typeof type === 'string' && isMediaTypePattern(type)))
1310
+ ) {
1311
+ throw invalid(`invalid mimeTypes ${describeValue(mimeTypes)}`)
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
+ function describeValue(value: unknown): string {
1317
+ try {
1318
+ return JSON.stringify(value) ?? String(value)
1319
+ } catch {
1320
+ return String(value)
1321
+ }
1322
+ }
1323
+
1324
+ // -------------------------------------------------------------------------------------------------
1325
+ // Provider resolution
1326
+ // -------------------------------------------------------------------------------------------------
1327
+
1328
+ /**
1329
+ * Singletons (and registered values) are resolved once at bootstrap; request-scoped and transient
1330
+ * providers on every request.
1331
+ */
1332
+ function resolvable<T>(container: Container, metadata: ZnightMetadata, token: Token, what: string): Resolvable<T> {
1333
+ const scope = Object.hasOwn(metadata.providers, token) ? metadata.providers[token]?.scope : undefined
1334
+ if (!container.has(token)) {
1335
+ throw new ZnightError(ErrorCode.MissingProvider, `HttpPlugin: the ${what} (${token}) is not a provider`, {
1336
+ hints: ['Add @Injectable() to the class (controllers are providers through @Controller())'],
1337
+ })
1338
+ }
1339
+ if (scope === 'request' || scope === 'transient') return { token }
1340
+ try {
1341
+ return { instance: container.get(token) as T }
1342
+ } catch (error) {
1343
+ if ((error as { code?: unknown } | null)?.code === ErrorCode.NoActiveScope) return { token }
1344
+ throw error
1345
+ }
1346
+ }
1347
+
1348
+ type GlobalKey = 'guards' | 'interceptors' | 'exceptionMappers'
1349
+ const GLOBAL_KEYS: readonly GlobalKey[] = ['guards', 'interceptors', 'exceptionMappers']
1350
+ const GLOBAL_METHOD: Readonly<Record<GlobalKey, string>> = {
1351
+ guards: 'canActivate',
1352
+ interceptors: 'intercept',
1353
+ exceptionMappers: 'map',
1354
+ }
1355
+ const GLOBAL_WHAT: Readonly<Record<GlobalKey, string>> = {
1356
+ guards: 'guard',
1357
+ interceptors: 'interceptor',
1358
+ exceptionMappers: 'exception mapper',
1359
+ }
1360
+
1361
+ /** Resolves global guards / interceptors / exception mappers given as classes or instances. */
1362
+ function globalRefs<T>(
1363
+ container: Container,
1364
+ metadata: ZnightMetadata,
1365
+ options: HttpPluginOptions,
1366
+ key: GlobalKey,
1367
+ ): Resolvable<T>[] {
1368
+ const refs: unknown = options[key] ?? []
1369
+ if (!Array.isArray(refs)) throw invalidOption(key, 'an array', refs)
1370
+ const method = GLOBAL_METHOD[key]
1371
+ const what = GLOBAL_WHAT[key]
1372
+ return refs.map(
1373
+ (ref: unknown): Resolvable<T> =>
1374
+ resolveRef<T>(container, metadata, ref, what, method, () =>
1375
+ invalidOption(key, `classes or objects with a ${method}() method`, ref),
1376
+ ).resolvable,
1377
+ )
1378
+ }
1379
+
1380
+ /** A provider class whose instances are guards (a `canActivate()` method on its prototype). */
1381
+ function isGuardClass(cls: unknown): boolean {
1382
+ if (typeof cls !== 'function') return false
1383
+ const prototype: unknown = (cls as { prototype?: unknown }).prototype
1384
+ return (
1385
+ typeof prototype === 'object' &&
1386
+ prototype !== null &&
1387
+ typeof (prototype as { canActivate?: unknown }).canActivate === 'function'
1388
+ )
1389
+ }
1390
+
1391
+ /** Guards given as classes or instances (`guards` option, plugin routes), with their class and name. */
1392
+ function guardEntries(
1393
+ container: Container,
1394
+ metadata: ZnightMetadata,
1395
+ refs: unknown,
1396
+ invalid: (ref: unknown) => ZnightError,
1397
+ ): GuardEntry[] {
1398
+ if (!Array.isArray(refs)) throw invalidOption('guards', 'an array', refs)
1399
+ return refs.map((ref: unknown) =>
1400
+ resolveRef<CanActivate>(container, metadata, ref, 'guard', 'canActivate', () => invalid(ref)),
1401
+ )
1402
+ }
1403
+
1404
+ function resolveRef<T>(
1405
+ container: Container,
1406
+ metadata: ZnightMetadata,
1407
+ ref: unknown,
1408
+ what: string,
1409
+ method: string,
1410
+ invalid: () => ZnightError,
1411
+ ): { readonly resolvable: Resolvable<T>; readonly cls: unknown; readonly name: string } {
1412
+ if (typeof ref === 'function') {
1413
+ const token = container.tokenOf(ref as GuardClass)
1414
+ if (token === undefined) {
1415
+ const name = (ref as { name?: string }).name || '<anonymous>'
1416
+ throw new ZnightError(ErrorCode.MissingProvider, `HttpPlugin: the global ${what} ${name} is not a provider`, {
1417
+ hints: ['Add @Injectable() to the class, or pass an instance'],
1418
+ })
1419
+ }
1420
+ return {
1421
+ resolvable: resolvable<T>(container, metadata, token, `global ${what}`),
1422
+ cls: ref,
1423
+ name: displayName(token),
1424
+ }
1425
+ }
1426
+ if (ref !== null && typeof ref === 'object' && typeof (ref as Record<string, unknown>)[method] === 'function') {
1427
+ const cls: unknown = (ref as { constructor?: unknown }).constructor
1428
+ const className = typeof cls === 'function' && cls !== Object ? (cls as { name?: string }).name : undefined
1429
+ return { resolvable: { instance: ref as T }, cls, name: className || `${what} instance` }
1430
+ }
1431
+ throw invalid()
1432
+ }