@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.
- package/LICENSE +21 -0
- package/README.md +2333 -2
- package/dist/adapter-CGSceVEN.d.mts +876 -0
- package/dist/compiler.d.mts +156 -0
- package/dist/compiler.mjs +2180 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/express.d.mts +39 -0
- package/dist/express.mjs +135 -0
- package/dist/express.mjs.map +1 -0
- package/dist/fastify.d.mts +33 -0
- package/dist/fastify.mjs +191 -0
- package/dist/fastify.mjs.map +1 -0
- package/dist/hono.d.mts +49 -0
- package/dist/hono.mjs +319 -0
- package/dist/hono.mjs.map +1 -0
- package/dist/index.d.mts +1254 -0
- package/dist/index.mjs +5618 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-BUnkdSsa.mjs +183 -0
- package/dist/node-BUnkdSsa.mjs.map +1 -0
- package/dist/node-io-BY1ZDLRQ.mjs +1566 -0
- package/dist/node-io-BY1ZDLRQ.mjs.map +1 -0
- package/dist/node.d.mts +30 -0
- package/dist/node.mjs +1 -0
- package/dist/path-JI87C4k8.d.mts +109 -0
- package/dist/path.d.mts +2 -0
- package/dist/path.mjs +245 -0
- package/dist/path.mjs.map +1 -0
- package/dist/size-D8rsQcV0.mjs +184 -0
- package/dist/size-D8rsQcV0.mjs.map +1 -0
- package/dist/utils-DCbu3a4w.mjs +27 -0
- package/dist/utils-DCbu3a4w.mjs.map +1 -0
- package/package.json +142 -4
- package/src/adapter.ts +240 -0
- package/src/adapters/express.ts +201 -0
- package/src/adapters/fastify.ts +280 -0
- package/src/adapters/hono.ts +488 -0
- package/src/adapters/node-io.ts +368 -0
- package/src/adapters/node.ts +135 -0
- package/src/adapters/shared.ts +508 -0
- package/src/body.ts +135 -0
- package/src/coerce.ts +195 -0
- package/src/compiler/analyze.ts +1513 -0
- package/src/compiler/ast.ts +90 -0
- package/src/compiler/coercion.ts +267 -0
- package/src/compiler/generate.ts +374 -0
- package/src/compiler/index.ts +76 -0
- package/src/compiler/model.ts +260 -0
- package/src/compiler/route-decorators.ts +405 -0
- package/src/compiler/validate.ts +288 -0
- package/src/context.ts +533 -0
- package/src/cookie-settings.ts +68 -0
- package/src/cookie-signer.ts +106 -0
- package/src/cookies.ts +726 -0
- package/src/cors.ts +349 -0
- package/src/decisions.ts +331 -0
- package/src/decorators.ts +486 -0
- package/src/event-channel.ts +197 -0
- package/src/exceptions.ts +257 -0
- package/src/files.ts +429 -0
- package/src/headers.ts +39 -0
- package/src/index.ts +185 -0
- package/src/inject-sse.ts +309 -0
- package/src/interfaces.ts +101 -0
- package/src/metadata.ts +162 -0
- package/src/mime.ts +157 -0
- package/src/multipart.ts +597 -0
- package/src/option-error.ts +31 -0
- package/src/path.ts +288 -0
- package/src/pipeline.ts +918 -0
- package/src/plugin-routes.ts +188 -0
- package/src/plugin.ts +1432 -0
- package/src/proxy.ts +236 -0
- package/src/query.ts +30 -0
- package/src/response.ts +240 -0
- package/src/router.ts +119 -0
- package/src/security-headers.ts +161 -0
- package/src/serialize.ts +264 -0
- package/src/size.ts +66 -0
- package/src/sse-parse.ts +194 -0
- package/src/sse.ts +650 -0
- package/src/standard-schema.ts +81 -0
- package/src/status.ts +78 -0
- package/src/stream.ts +200 -0
- package/src/tokens.ts +21 -0
- package/src/uploads.ts +176 -0
- package/src/utils.ts +31 -0
- 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
|
+
}
|