@boo-labs/server 0.0.0-stage → 1.0.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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +287 -2
  3. package/index.mjs +23 -0
  4. package/package.json +71 -4
  5. package/source/application/Kernel/index.mjs +1137 -0
  6. package/source/application/Server/index.mjs +1201 -0
  7. package/source/application/configurations/Http2CleartextServerConfiguration/index.mjs +168 -0
  8. package/source/application/configurations/Http2SecureServerConfiguration/index.mjs +172 -0
  9. package/source/application/configurations/HttpServerConfiguration/index.mjs +169 -0
  10. package/source/application/configurations/HttpsServerConfiguration/index.mjs +172 -0
  11. package/source/application/configurations/ServerConfiguration/Options/CORSOptions/CORSOptionsValue.mjs +158 -0
  12. package/source/application/configurations/ServerConfiguration/Options/CORSOptions/index.mjs +87 -0
  13. package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/CSRFOptionsValue.mjs +178 -0
  14. package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/index.mjs +80 -0
  15. package/source/application/configurations/ServerConfiguration/Options/CompressOptions/CompressOptionsValue.mjs +126 -0
  16. package/source/application/configurations/ServerConfiguration/Options/CompressOptions/index.mjs +81 -0
  17. package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/CookieParserOptionsValue.mjs +107 -0
  18. package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/index.mjs +82 -0
  19. package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/CookieSessionOptionsValue.mjs +182 -0
  20. package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/index.mjs +86 -0
  21. package/source/application/configurations/ServerConfiguration/Options/ListenOptions/ListenOptionsValue.mjs +95 -0
  22. package/source/application/configurations/ServerConfiguration/Options/ListenOptions/index.mjs +47 -0
  23. package/source/application/configurations/ServerConfiguration/Options/MiddlewareOption.mjs +102 -0
  24. package/source/application/configurations/ServerConfiguration/Options/Options.mjs +105 -0
  25. package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/RateLimitOptionsValue.mjs +164 -0
  26. package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/index.mjs +84 -0
  27. package/source/application/configurations/ServerConfiguration/Options/RequestOptions/RequestOptionsValue.mjs +108 -0
  28. package/source/application/configurations/ServerConfiguration/Options/RequestOptions/index.mjs +47 -0
  29. package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/ResponseOptionsValue.mjs +101 -0
  30. package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/index.mjs +47 -0
  31. package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/SecurityHeadersOptionsValue.mjs +272 -0
  32. package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/index.mjs +85 -0
  33. package/source/application/configurations/ServerConfiguration/Options/ServerOption.mjs +50 -0
  34. package/source/application/configurations/ServerConfiguration/Options/StaticOptions/StaticOptionsValue.mjs +164 -0
  35. package/source/application/configurations/ServerConfiguration/Options/StaticOptions/index.mjs +89 -0
  36. package/source/application/configurations/ServerConfiguration/index.mjs +781 -0
  37. package/source/application/resolvers/TransportResolver/index.mjs +119 -0
  38. package/source/application/use-cases/CloseServer/index.mjs +85 -0
  39. package/source/application/use-cases/ListenServer/index.mjs +98 -0
  40. package/source/core/EventSystem/index.mjs +110 -0
  41. package/source/core/Exchange/ExchangeContext.mjs +291 -0
  42. package/source/core/Exchange/Telemetry.mjs +193 -0
  43. package/source/core/Exchange/index.mjs +604 -0
  44. package/source/core/ExchangeLayerBuilder/index.mjs +133 -0
  45. package/source/core/IPCMain/IPCMain.mjs +507 -0
  46. package/source/core/IPCMain/IPCProtocol.mjs +441 -0
  47. package/source/core/IPCMain/IPCServerChannel.mjs +331 -0
  48. package/source/core/IPCMain/WebSocketAdapter.mjs +189 -0
  49. package/source/core/Request/BodyParser/BodyParser.mjs +197 -0
  50. package/source/core/Request/BodyParser/JSONParser.mjs +70 -0
  51. package/source/core/Request/BodyParser/MultipartParser.mjs +612 -0
  52. package/source/core/Request/BodyParser/URLEncodedParser.mjs +71 -0
  53. package/source/core/Request/RequestHeaders.mjs +177 -0
  54. package/source/core/Request/index.mjs +805 -0
  55. package/source/core/Response/ResponseHeaders.mjs +402 -0
  56. package/source/core/Response/index.mjs +1370 -0
  57. package/source/core/middlewares/CORSMiddleware/index.mjs +289 -0
  58. package/source/core/middlewares/CSRFMiddleware/index.mjs +284 -0
  59. package/source/core/middlewares/CompressMiddleware/index.mjs +460 -0
  60. package/source/core/middlewares/CookieParserMiddleware/index.mjs +194 -0
  61. package/source/core/middlewares/CookieSessionMiddleware/index.mjs +460 -0
  62. package/source/core/middlewares/RateLimitMiddleware/index.mjs +245 -0
  63. package/source/core/middlewares/SecurityHeadersMiddleware/index.mjs +151 -0
  64. package/source/core/middlewares/StaticMiddleware/index.mjs +628 -0
  65. package/source/core/routing/ExchangeLayer/ExchangeMatcher.mjs +127 -0
  66. package/source/core/routing/ExchangeLayer/index.mjs +254 -0
  67. package/source/core/routing/Pipeline/index.mjs +157 -0
  68. package/source/core/routing/Router/Route.mjs +184 -0
  69. package/source/core/routing/Router/index.mjs +523 -0
  70. package/source/core/routing/RouterRegister/OptionsMiddleware.mjs +147 -0
  71. package/source/core/routing/RouterRegister/index.mjs +150 -0
  72. package/source/core/routing/RouterSystem/index.mjs +225 -0
  73. package/source/core/value-objects/Address/index.mjs +281 -0
  74. package/source/core/value-objects/Endpoint/index.mjs +293 -0
  75. package/source/index.mjs +62 -0
  76. package/source/infrastructure/adapters/Http2Adapter/index.mjs +144 -0
  77. package/source/infrastructure/adapters/Http2CleartextAdapter/index.mjs +147 -0
  78. package/source/infrastructure/adapters/HttpAdapter/index.mjs +124 -0
  79. package/source/infrastructure/adapters/HttpsAdapter/index.mjs +124 -0
  80. package/source/infrastructure/adapters/TransportAdapter/createWebSocketOpenRequest.mjs +843 -0
  81. package/source/infrastructure/adapters/TransportAdapter/index.mjs +555 -0
  82. package/source/shared/Identifier/index.mjs +671 -0
  83. package/source/shared/constants/symbols/brands/index.mjs +45 -0
  84. package/source/shared/domain/UUIDV7/index.mjs +346 -0
  85. package/source/shared/errors/BaseError/index.mjs +157 -0
  86. package/source/shared/errors/IPCError/index.mjs +97 -0
  87. package/source/shared/errors/MiddlewareError/index.mjs +106 -0
  88. package/source/shared/errors/PathnamePatternError/index.mjs +103 -0
  89. package/source/shared/errors/RequestError/index.mjs +116 -0
  90. package/source/shared/errors/ResponseError/index.mjs +140 -0
  91. package/source/shared/errors/RouterError/index.mjs +90 -0
  92. package/source/shared/errors/ServerConfigurationError/index.mjs +551 -0
  93. package/source/shared/errors/ServerError/index.mjs +127 -0
  94. package/source/shared/errors/WebSocketError/index.mjs +58 -0
  95. package/source/shared/outcome/ErrorSchema.mjs +361 -0
  96. package/source/shared/outcome/Result/index.mjs +239 -0
  97. package/source/shared/outcome/Validation/index.mjs +208 -0
  98. package/source/shared/validations/Middleware/index.mjs +160 -0
  99. package/source/shared/validations/PathnamePattern/index.mjs +256 -0
  100. package/source/shared/validations/Server/NotFoundHandler/index.mjs +94 -0
  101. package/source/shared/validations/Server/Router/index.mjs +68 -0
  102. package/source/shared/validations/Server/ServerConfiguration/index.mjs +68 -0
  103. package/source/shared/validations/Server/SystemErrorHandler/index.mjs +94 -0
  104. package/source/shared/validations/ServerConfiguration/CORSOptions/index.mjs +310 -0
  105. package/source/shared/validations/ServerConfiguration/CSRFOptions/index.mjs +299 -0
  106. package/source/shared/validations/ServerConfiguration/CompressOptions/index.mjs +204 -0
  107. package/source/shared/validations/ServerConfiguration/CookieParserOptions/index.mjs +102 -0
  108. package/source/shared/validations/ServerConfiguration/CookieSessionOptions/index.mjs +332 -0
  109. package/source/shared/validations/ServerConfiguration/ListenOptions/index.mjs +178 -0
  110. package/source/shared/validations/ServerConfiguration/RateLimitOptions/index.mjs +229 -0
  111. package/source/shared/validations/ServerConfiguration/RequestOptions/index.mjs +132 -0
  112. package/source/shared/validations/ServerConfiguration/ResponseOptions/index.mjs +122 -0
  113. package/source/shared/validations/ServerConfiguration/SecurityHeadersOptions/index.mjs +137 -0
  114. package/source/shared/validations/ServerConfiguration/StaticOptions/index.mjs +248 -0
  115. package/types/index.d.ts +719 -0
@@ -0,0 +1,805 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Defines the Request abstraction.
4
+ *
5
+ * @description The Request is an immutable, transport-independent view of an
6
+ * incoming HTTP message. It exposes normalized request metadata and lazy body
7
+ * access.
8
+ *
9
+ * HTTP/2 pseudo-headers (`:scheme`, `:authority`, `:method`, `:path`) are
10
+ * consumed internally to resolve protocol and authority, then removed from
11
+ * the public `headers` view so that callers see a uniform HTTP header map
12
+ * across HTTP/1.1 and HTTP/2.
13
+ *
14
+ * CONNECT requests are not represented by this abstraction. The request
15
+ * target of a CONNECT message does not have path form, and the URL built
16
+ * here does not reflect its semantics.
17
+ *
18
+ * @module Request
19
+ */
20
+
21
+ // ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22
+ // » IMPORT LOCAL MODULES
23
+ import RequestError from '#shared/errors/RequestError';
24
+ import RequestHeaders from './RequestHeaders.mjs';
25
+ import JSONParser from './BodyParser/JSONParser.mjs';
26
+ import MultipartParser from './BodyParser/MultipartParser.mjs';
27
+ import URLEncodedParser from './BodyParser/URLEncodedParser.mjs';
28
+
29
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
30
+ /**
31
+ * Represents an incoming HTTP/1.1 or HTTPS/1.1 request message.
32
+ *
33
+ * @typedef {import('node:http').IncomingMessage} HttpIncomingMessage
34
+ */
35
+
36
+ /**
37
+ * Represents an incoming HTTP/2 request message.
38
+ *
39
+ * @typedef {import('node:http2').Http2ServerRequest} Http2IncomingMessage
40
+ */
41
+
42
+ /**
43
+ * Represents an incoming request message supported by the server.
44
+ *
45
+ * @typedef {HttpIncomingMessage|Http2IncomingMessage} IncomingRequest
46
+ */
47
+
48
+ /**
49
+ * Represents the constructor signature of a body parser.
50
+ *
51
+ * A body parser constructor produces an instance of a concrete parser
52
+ * that satisfies the `BodyParser` contract. It is used as the value type
53
+ * of the parser registry.
54
+ *
55
+ * @typedef {typeof import('./BodyParser/BodyParser.mjs').default} BodyParserConstructor
56
+ */
57
+
58
+ /**
59
+ * Configures how proxy headers are trusted.
60
+ *
61
+ * - `false`: no proxy header is trusted.
62
+ * - `true`: all proxies are trusted. The first value of `X-Forwarded-For`,
63
+ * `X-Forwarded-Proto`, and `X-Forwarded-Host` is used.
64
+ * - `number`: the trusted number of proxies. `X-Forwarded-For` is walked
65
+ * that many hops from the closest proxy. `X-Forwarded-Proto` and
66
+ * `X-Forwarded-Host` are read as single values.
67
+ *
68
+ * @typedef {boolean|number} TrustProxyOption
69
+ */
70
+
71
+ /**
72
+ * Defines the lifecycle options supported by a request.
73
+ *
74
+ * @typedef {object} RequestOptions
75
+ * @property {number} [bodyLimit] - Maximum number of bytes allowed for the request body.
76
+ * @property {TrustProxyOption} [trustProxy=false] - Whether (and how much) to trust proxy headers.
77
+ */
78
+
79
+ /**
80
+ * Represents the protocol scheme used by the incoming request.
81
+ *
82
+ * @typedef {'http'|'https'} Protocol
83
+ */
84
+
85
+ // ━━ CONSTANTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
86
+ /**
87
+ * Default limit for the size of a request body, in bytes.
88
+ *
89
+ * @type {number}
90
+ */
91
+ const DEFAULT_BODY_LIMIT = 10 * 1024 * 1024;
92
+
93
+ /**
94
+ * Detects an absolute-form or protocol-relative URL.
95
+ *
96
+ * @type {RegExp}
97
+ */
98
+ const ABSOLUTE_URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
99
+
100
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
101
+ /**
102
+ * Represents an incoming HTTP request.
103
+ *
104
+ * @classdesc The `Request` normalizes HTTP/1.1, HTTPS/1.1, and HTTP/2
105
+ * requests into a common interface. It is immutable from the consumer's
106
+ * perspective: no property is mutable and no native object is exposed.
107
+ *
108
+ * Body parsing is lazy, cached, and delegated to a parser selected by the
109
+ * request `Content-Type`.
110
+ */
111
+ class Request {
112
+ /**
113
+ * Maximum number of bytes allowed for the request body.
114
+ *
115
+ * @type {number}
116
+ */
117
+ #bodyLimit;
118
+
119
+ /**
120
+ * Proxy trust policy used to resolve the client IP, protocol, and host.
121
+ *
122
+ * @type {TrustProxyOption}
123
+ */
124
+ #trustProxy;
125
+
126
+ /**
127
+ * Incoming HTTP request message.
128
+ *
129
+ * @type {IncomingRequest}
130
+ */
131
+ #incomingMessage;
132
+
133
+ /**
134
+ * Parsed body, or `null` before parsing.
135
+ *
136
+ * @type {unknown}
137
+ */
138
+ #body = null;
139
+
140
+ /**
141
+ * In-flight body parsing promise, used to coalesce concurrent `body()` calls.
142
+ *
143
+ * @type {Promise<unknown>|null}
144
+ */
145
+ #bodyPromise = null;
146
+
147
+ /**
148
+ * Error produced while reading or parsing the body, if any.
149
+ *
150
+ * @type {Error|null}
151
+ */
152
+ #bodyError = null;
153
+
154
+ /**
155
+ * Whether the body has already been processed (successfully or not).
156
+ *
157
+ * @type {boolean}
158
+ */
159
+ #bodyProcessed = false;
160
+
161
+ /**
162
+ * Protocol scheme used by the request.
163
+ *
164
+ * @type {Protocol}
165
+ */
166
+ #protocol;
167
+
168
+ /**
169
+ * Parsed request URL.
170
+ *
171
+ * @type {URL}
172
+ */
173
+ #url;
174
+
175
+ /**
176
+ * Immutable view of the request headers.
177
+ *
178
+ * @type {RequestHeaders}
179
+ */
180
+ #headers;
181
+
182
+ /**
183
+ * Full `Content-Type` header value, or `null` when absent.
184
+ *
185
+ * @type {string|null}
186
+ */
187
+ #contentType;
188
+
189
+ /**
190
+ * Normalized media type extracted from `Content-Type`, or `null` when absent.
191
+ *
192
+ * @type {string|null}
193
+ */
194
+ #mediaType;
195
+
196
+ /**
197
+ * HTTP version used by the request.
198
+ *
199
+ * @type {string|undefined}
200
+ */
201
+ #httpVersion;
202
+
203
+ /**
204
+ * Resolved client IP address, respecting the proxy trust policy.
205
+ *
206
+ * @type {string|undefined}
207
+ */
208
+ #clientIp;
209
+
210
+ /**
211
+ * Creates a request from an incoming HTTP message.
212
+ *
213
+ * @param {IncomingRequest} incomingMessage - Incoming HTTP request message.
214
+ * @param {RequestOptions} [options] - The request lifecycle options.
215
+ * @throws {RequestError} If the request URL cannot be parsed.
216
+ * @throws {RequestError} If the request authority cannot be resolved.
217
+ * @throws {TypeError} If `options.bodyLimit` is not a positive safe integer.
218
+ * @throws {TypeError} If `options.trustProxy` is not a boolean or a non-negative safe integer.
219
+ * @example
220
+ * ```js
221
+ * const request = new Request(incomingMessage, {
222
+ * bodyLimit: 1_000_000,
223
+ * trustProxy: 1,
224
+ * });
225
+ * ```
226
+ */
227
+ constructor(incomingMessage, options = {}) {
228
+ const bodyLimit = options.bodyLimit ?? DEFAULT_BODY_LIMIT;
229
+ const trustProxy = options.trustProxy ?? false;
230
+
231
+ if (!Number.isSafeInteger(bodyLimit) || bodyLimit <= 0) {
232
+ throw new TypeError('bodyLimit must be a positive safe integer');
233
+ }
234
+
235
+ if (typeof trustProxy !== 'boolean' && (!Number.isSafeInteger(trustProxy) || trustProxy < 0)) {
236
+ throw new TypeError('trustProxy must be a boolean or a non-negative safe integer');
237
+ }
238
+
239
+ this.#bodyLimit = bodyLimit;
240
+ this.#trustProxy = trustProxy;
241
+ this.#incomingMessage = incomingMessage;
242
+
243
+ const incomingHttpHeaders = incomingMessage.headers;
244
+
245
+ this.#headers = new RequestHeaders(incomingHttpHeaders);
246
+
247
+ this.#protocol = this.#resolveProtocol(incomingHttpHeaders);
248
+ this.#httpVersion = this.#resolveHttpVersion();
249
+
250
+ const host = this.#resolveHost(incomingHttpHeaders);
251
+ this.#url = this.#resolveUrl(incomingMessage.url || '/', host);
252
+
253
+ this.#clientIp = this.#resolveClientIp();
254
+
255
+ const contentType = this.#headers.get('content-type');
256
+ this.#contentType = typeof contentType === 'string' ? contentType : null;
257
+ this.#mediaType = this.#contentType
258
+ ? this.#contentType.split(';', 1)[0].trim().toLowerCase()
259
+ : null;
260
+ }
261
+
262
+ /**
263
+ * The path component of the request URL.
264
+ *
265
+ * This is derived from the resolved URL. For `OPTIONS *` requests, the
266
+ * pathname is `/`.
267
+ *
268
+ * @type {string}
269
+ */
270
+ get pathname() {
271
+ return this.#url.pathname;
272
+ }
273
+
274
+ /**
275
+ * The HTTP method of the request, normalized to uppercase when present.
276
+ *
277
+ * The transport layer does not validate the method against a supported
278
+ * set, and clients may send custom method names. The returned value is
279
+ * therefore a plain string, and callers that need to restrict the set
280
+ * of accepted methods should do so in the routing layer.
281
+ *
282
+ * Returns `undefined` when the underlying message does not declare a
283
+ * method. This should not occur for requests received by the server,
284
+ * but the transport type marks the property as optional.
285
+ *
286
+ * @type {string}
287
+ */
288
+ get method() {
289
+ const method = this.#incomingMessage.method;
290
+ if (method === undefined) {
291
+ throw new RequestError('MISSING_METHOD');
292
+ }
293
+ return method.toUpperCase();
294
+ }
295
+
296
+ /**
297
+ * The request target of the incoming HTTP message.
298
+ *
299
+ * It may be a path, an absolute URL, or `*` for `OPTIONS` requests.
300
+ * This is the value provided by the transport, not the normalized URL
301
+ * used internally by the request.
302
+ *
303
+ * @type {string|undefined}
304
+ */
305
+ get url() {
306
+ return this.#incomingMessage.url;
307
+ }
308
+
309
+ /**
310
+ * An immutable, case-insensitive view of the request headers.
311
+ *
312
+ * @type {RequestHeaders}
313
+ */
314
+ get headers() {
315
+ return this.#headers;
316
+ }
317
+
318
+ /**
319
+ * The remote IP address of the client.
320
+ *
321
+ * When `trustProxy` is enabled, the value is derived from the
322
+ * `X-Forwarded-For` header according to the configured trust level.
323
+ * Otherwise it falls back to the socket remote address.
324
+ *
325
+ * @type {string|undefined}
326
+ */
327
+ get ip() {
328
+ return this.#clientIp;
329
+ }
330
+
331
+ /**
332
+ * The request hostname.
333
+ *
334
+ * When `trustProxy` is enabled, the hostname may be derived from the
335
+ * `X-Forwarded-Host` header instead of the transport authority.
336
+ *
337
+ * @type {string}
338
+ */
339
+ get hostname() {
340
+ return this.#url.hostname;
341
+ }
342
+
343
+ /**
344
+ * The parsed query parameters of the request.
345
+ *
346
+ * A defensive copy is returned so that callers cannot mutate the internal
347
+ * request state through the returned object.
348
+ *
349
+ * @type {URLSearchParams}
350
+ */
351
+ get query() {
352
+ return new URLSearchParams(this.#url.searchParams);
353
+ }
354
+
355
+ /**
356
+ * The full `Content-Type` header, or `null` when absent.
357
+ *
358
+ * @type {string|null}
359
+ */
360
+ get contentType() {
361
+ return this.#contentType;
362
+ }
363
+
364
+ /**
365
+ * The normalized media type, or `null` when absent.
366
+ *
367
+ * @type {string|null}
368
+ */
369
+ get mediaType() {
370
+ return this.#mediaType;
371
+ }
372
+
373
+ /**
374
+ * The protocol scheme used by the request.
375
+ *
376
+ * When `trustProxy` is enabled, the value may be derived from the
377
+ * `X-Forwarded-Proto` header.
378
+ *
379
+ * @type {Protocol}
380
+ */
381
+ get protocol() {
382
+ return this.#protocol;
383
+ }
384
+
385
+ /**
386
+ * Whether the request declares an `https` scheme.
387
+ *
388
+ * This reflects the resolved request scheme, which may come from the
389
+ * transport layer or, when `trustProxy` is enabled, from the
390
+ * `X-Forwarded-Proto` header.
391
+ *
392
+ * @type {boolean}
393
+ */
394
+ get isSecure() {
395
+ return this.#protocol === 'https';
396
+ }
397
+
398
+ /**
399
+ * The HTTP version used by the request.
400
+ *
401
+ * @type {string|undefined}
402
+ */
403
+ get httpVersion() {
404
+ return this.#httpVersion;
405
+ }
406
+
407
+ /**
408
+ * Returns a request header by name.
409
+ *
410
+ * Header names are matched case-insensitively. Array values are copied
411
+ * so the caller cannot mutate the internal request state through the
412
+ * returned object.
413
+ *
414
+ * @param {string} name - The HTTP header name.
415
+ * @returns {string|string[]|undefined} The header value, or `undefined` when the header does not exist.
416
+ * @example
417
+ * ```js
418
+ * const contentType = request.getHeader('content-type');
419
+ * ```
420
+ */
421
+ getHeader(name) {
422
+ return this.#headers.get(name);
423
+ }
424
+
425
+ /**
426
+ * Returns the parsed request body.
427
+ *
428
+ * The request body is consumed at most once. Concurrent calls share
429
+ * the same in-flight parsing operation. The parsed result is cached and
430
+ * returned on subsequent calls. If parsing fails, the error is also
431
+ * cached and rethrown on subsequent calls.
432
+ *
433
+ * @returns {Promise<unknown>} The parsed request body, or `null` when the request has no body.
434
+ * @throws {RequestError} If the request media type is not supported.
435
+ * @throws {RequestError} If the request body exceeds the configured limit.
436
+ * @throws {RequestError} If the request body cannot be parsed.
437
+ * @example
438
+ * ```js
439
+ * const body = await request.body();
440
+ * ```
441
+ */
442
+ async body() {
443
+ if (this.#bodyError) {
444
+ throw this.#bodyError;
445
+ }
446
+
447
+ if (this.#bodyProcessed) {
448
+ return this.#body;
449
+ }
450
+
451
+ if (this.#bodyPromise === null) {
452
+ this.#bodyPromise = this.#parseBody();
453
+ }
454
+
455
+ try {
456
+ this.#body = await this.#bodyPromise;
457
+ this.#bodyProcessed = true;
458
+ return this.#body;
459
+ } catch (error) {
460
+ const normalized = error instanceof Error ? error : new Error(String(error));
461
+ this.#bodyProcessed = true;
462
+ this.#bodyError = normalized;
463
+ throw normalized;
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Resolves the client IP address according to the proxy trust policy.
469
+ *
470
+ * When `trustProxy` is `false`, the socket remote address is used.
471
+ *
472
+ * When `trustProxy` is `true`, the first address in `X-Forwarded-For`
473
+ * is used, assuming all proxies in the chain are trusted.
474
+ *
475
+ * When `trustProxy` is a number, the trusted number of proxies is
476
+ * skipped from the end of the `X-Forwarded-For` list, and the address
477
+ * immediately before them is used. If the list does not contain enough
478
+ * entries to apply the configured trust level, the socket remote
479
+ * address is used.
480
+ *
481
+ * @returns {string|undefined} The resolved client IP address.
482
+ */
483
+ #resolveClientIp() {
484
+ const socketIp = this.#incomingMessage.socket?.remoteAddress;
485
+
486
+ if (!this.#trustProxy) {
487
+ return socketIp;
488
+ }
489
+
490
+ const forwarded = this.#headers.get('x-forwarded-for');
491
+
492
+ if (!forwarded) {
493
+ return socketIp;
494
+ }
495
+
496
+ const list = (Array.isArray(forwarded) ? forwarded.join(',') : forwarded)
497
+ .split(',')
498
+ .map(ip => ip.trim())
499
+ .filter(Boolean);
500
+
501
+ if (list.length === 0) {
502
+ return socketIp;
503
+ }
504
+
505
+ if (this.#trustProxy === true) {
506
+ return list[0];
507
+ }
508
+
509
+ const clientIndex = list.length - this.#trustProxy - 1;
510
+
511
+ if (clientIndex >= 0) {
512
+ return list[clientIndex];
513
+ }
514
+
515
+ return socketIp;
516
+ }
517
+
518
+ /**
519
+ * Determines whether the incoming request contains a body.
520
+ *
521
+ * A request is considered to contain a body when it uses `chunked`
522
+ * transfer encoding or declares a positive `Content-Length`. Invalid
523
+ * or duplicate `Content-Length` values are rejected rather than treated
524
+ * as an absent body.
525
+ *
526
+ * @returns {boolean} `true` when a body is present.
527
+ * @throws {RequestError} If `Transfer-Encoding` is not exactly `chunked`.
528
+ * @throws {RequestError} If `Content-Length` is duplicated or invalid.
529
+ */
530
+ #hasBody() {
531
+ const headers = this.#incomingMessage.headers;
532
+
533
+ const transferEncoding = headers['transfer-encoding'];
534
+
535
+ if (transferEncoding !== undefined) {
536
+ const values = Array.isArray(transferEncoding) ? transferEncoding : [transferEncoding];
537
+ const normalized = values.map(value => value.trim().toLowerCase());
538
+
539
+ if (normalized.length !== 1 || normalized[0] !== 'chunked') {
540
+ throw new RequestError('INVALID_TRANSFER_ENCODING', {
541
+ values: { transferEncoding: normalized.join(', ') },
542
+ });
543
+ }
544
+
545
+ return true;
546
+ }
547
+
548
+ const contentLengthHeader = headers['content-length'];
549
+
550
+ if (contentLengthHeader === undefined) {
551
+ return false;
552
+ }
553
+
554
+ if (Array.isArray(contentLengthHeader)) {
555
+ throw new RequestError('INVALID_CONTENT_LENGTH', {
556
+ values: { contentLength: contentLengthHeader.join(', ') },
557
+ });
558
+ }
559
+
560
+ const contentLength = Number(contentLengthHeader);
561
+
562
+ if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
563
+ throw new RequestError('INVALID_CONTENT_LENGTH', {
564
+ values: { contentLength: contentLengthHeader },
565
+ });
566
+ }
567
+
568
+ return contentLength > 0;
569
+ }
570
+
571
+ /**
572
+ * Resolves and executes the body parser associated with the request
573
+ * media type.
574
+ *
575
+ * @returns {Promise<unknown>} The parsed body, or `null` when the request has no body.
576
+ * @throws {RequestError} If the media type is unsupported.
577
+ * @throws {RequestError} If the body exceeds the configured limit.
578
+ * @throws {RequestError} If the body cannot be parsed.
579
+ */
580
+ async #parseBody() {
581
+ if (!this.#hasBody()) {
582
+ return null;
583
+ }
584
+
585
+ const Parser = this.#resolveParser();
586
+
587
+ if (!Parser) {
588
+ throw new RequestError('UNSUPPORTED_MEDIA_TYPE', {
589
+ values: { mediaType: this.#mediaType },
590
+ });
591
+ }
592
+
593
+ return new Parser(this.#incomingMessage, this.#bodyLimit).parse();
594
+ }
595
+
596
+ /**
597
+ * Resolves the parser class for the current media type.
598
+ *
599
+ * Falls back to the JSON parser for structured suffixes such as
600
+ * `application/vnd.api+json`.
601
+ *
602
+ * @returns {BodyParserConstructor|null} The parser constructor for the current media type, or `null` when no parser matches.
603
+ */
604
+ #resolveParser() {
605
+ const parser = Request.#bodyParsers.get(this.#mediaType ?? '');
606
+
607
+ if (parser) {
608
+ return parser;
609
+ }
610
+
611
+ if (this.#mediaType?.endsWith('+json')) {
612
+ return Request.#bodyParsers.get('application/json') ?? null;
613
+ }
614
+
615
+ return null;
616
+ }
617
+
618
+ /**
619
+ * Resolves the protocol scheme used by the incoming request.
620
+ *
621
+ * When `trustProxy` is enabled, the `X-Forwarded-Proto` header has
622
+ * priority over the transport-derived scheme.
623
+ *
624
+ * @param {Record<string, string|string[]|undefined>} incomingHttpHeaders - The incoming header map.
625
+ * @returns {Protocol} The normalized request protocol.
626
+ */
627
+ #resolveProtocol(incomingHttpHeaders) {
628
+ if (this.#trustProxy) {
629
+ const forwardedProto = this.#headers.get('x-forwarded-proto');
630
+
631
+ if (forwardedProto !== undefined) {
632
+ const value = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
633
+
634
+ if (typeof value === 'string') {
635
+ const proto = value.split(',')[0].trim().toLowerCase();
636
+
637
+ if (proto === 'http' || proto === 'https') {
638
+ return proto;
639
+ }
640
+ }
641
+ }
642
+ }
643
+
644
+ return this.#resolveProtocolFromTransport(incomingHttpHeaders);
645
+ }
646
+
647
+ /**
648
+ * Resolves the protocol scheme from the transport layer.
649
+ *
650
+ * @param {Record<string, string|string[]|undefined>} incomingHttpHeaders - The incoming header map.
651
+ * @returns {Protocol} The normalized request protocol.
652
+ */
653
+ #resolveProtocolFromTransport(incomingHttpHeaders) {
654
+ const message = this.#incomingMessage;
655
+
656
+ if (message.httpVersionMajor >= 2) {
657
+ if ('scheme' in message && (message.scheme === 'https' || message.scheme === 'http')) {
658
+ return message.scheme;
659
+ }
660
+
661
+ const scheme = incomingHttpHeaders[':scheme'];
662
+
663
+ if (scheme === 'https' || scheme === 'http') {
664
+ return scheme;
665
+ }
666
+ }
667
+
668
+ const socket = message.socket;
669
+
670
+ if (socket && 'encrypted' in socket) {
671
+ return socket.encrypted === true ? 'https' : 'http';
672
+ }
673
+
674
+ return 'http';
675
+ }
676
+
677
+ /**
678
+ * Resolves the HTTP version used by the incoming request.
679
+ *
680
+ * @returns {string|undefined} The HTTP version string, or `undefined` when unknown.
681
+ */
682
+ #resolveHttpVersion() {
683
+ const message = this.#incomingMessage;
684
+
685
+ if (typeof message.httpVersion === 'string') {
686
+ return message.httpVersion;
687
+ }
688
+
689
+ if (message.httpVersionMajor != null) {
690
+ return `${message.httpVersionMajor}.${message.httpVersionMinor ?? 0}`;
691
+ }
692
+
693
+ return undefined;
694
+ }
695
+
696
+ /**
697
+ * Resolves the authority component used to build the request URL.
698
+ *
699
+ * When `trustProxy` is enabled, the `X-Forwarded-Host` header has
700
+ * priority over the transport authority.
701
+ *
702
+ * The authority is otherwise resolved from the HTTP/2 `:authority`
703
+ * pseudo-header, the HTTP/2 message `authority` property, or the
704
+ * HTTP/1.1 `Host` header, in that order. A request that does not
705
+ * declare any of them is rejected because it does not identify the
706
+ * target of the request.
707
+ *
708
+ * @param {Record<string, string|string[]|undefined>} incomingHttpHeaders - The incoming header map.
709
+ * @returns {string} The request authority.
710
+ * @throws {RequestError} If the request authority cannot be resolved.
711
+ */
712
+ #resolveHost(incomingHttpHeaders) {
713
+ if (this.#trustProxy) {
714
+ const forwardedHost = this.#headers.get('x-forwarded-host');
715
+
716
+ if (forwardedHost !== undefined) {
717
+ const value = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
718
+
719
+ if (typeof value === 'string') {
720
+ const host = value.split(',')[0].trim();
721
+
722
+ if (host.length > 0) {
723
+ return host;
724
+ }
725
+ }
726
+ }
727
+ }
728
+
729
+ const message = this.#incomingMessage;
730
+
731
+ if (
732
+ 'authority' in message &&
733
+ typeof message.authority === 'string' &&
734
+ message.authority.length > 0
735
+ ) {
736
+ return message.authority;
737
+ }
738
+
739
+ const authority = incomingHttpHeaders[':authority'];
740
+
741
+ if (typeof authority === 'string' && authority.length > 0) {
742
+ return authority;
743
+ }
744
+
745
+ const host = incomingHttpHeaders.host;
746
+
747
+ if (typeof host === 'string' && host.length > 0) {
748
+ return host;
749
+ }
750
+
751
+ throw new RequestError('MISSING_HOST');
752
+ }
753
+
754
+ /**
755
+ * Resolves the request URL from the request target.
756
+ *
757
+ * Absolute-form and protocol-relative targets are normalized so that the
758
+ * authority from the message is preserved and the original host cannot be
759
+ * overridden by the request target. The asterisk request-target used by
760
+ * `OPTIONS` is represented as the root path. Invalid targets cause a
761
+ * `RequestError`.
762
+ *
763
+ * @param {string} requestTarget - The request target.
764
+ * @param {string} host - The resolved request authority.
765
+ * @returns {URL} The parsed request URL.
766
+ * @throws {RequestError} If the request URL cannot be parsed.
767
+ */
768
+ #resolveUrl(requestTarget, host) {
769
+ const base = `${this.#protocol}://${host}`;
770
+
771
+ try {
772
+ if (requestTarget === '*') {
773
+ return new URL('/', base);
774
+ }
775
+
776
+ if (requestTarget.startsWith('//') || ABSOLUTE_URL_PATTERN.test(requestTarget)) {
777
+ const parsed = new URL(requestTarget, base);
778
+ return new URL(parsed.pathname + parsed.search, base);
779
+ }
780
+
781
+ return new URL(requestTarget, base);
782
+ } catch (error) {
783
+ throw new RequestError('INVALID_REQUEST_URL', {
784
+ cause: error instanceof Error ? error : new Error(String(error)),
785
+ });
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Body parsers indexed by normalized media type.
791
+ *
792
+ * To add support for a new media type, add a single entry to this map.
793
+ * No other change in the class is required.
794
+ *
795
+ * @type {Map<string, BodyParserConstructor>}
796
+ */
797
+ static #bodyParsers = new Map([
798
+ ['application/json', JSONParser],
799
+ ['multipart/form-data', MultipartParser],
800
+ ['application/x-www-form-urlencoded', URLEncodedParser],
801
+ ]);
802
+ }
803
+
804
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
805
+ export default Request;