@boo-labs/server 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +229 -2
  2. package/index.mjs +23 -0
  3. package/package.json +71 -4
  4. package/source/application/Kernel/index.mjs +1137 -0
  5. package/source/application/Server/index.mjs +1201 -0
  6. package/source/application/configurations/Http2CleartextServerConfiguration/index.mjs +168 -0
  7. package/source/application/configurations/Http2SecureServerConfiguration/index.mjs +172 -0
  8. package/source/application/configurations/HttpServerConfiguration/index.mjs +169 -0
  9. package/source/application/configurations/HttpsServerConfiguration/index.mjs +172 -0
  10. package/source/application/configurations/ServerConfiguration/Options/CORSOptions/CORSOptionsValue.mjs +158 -0
  11. package/source/application/configurations/ServerConfiguration/Options/CORSOptions/index.mjs +87 -0
  12. package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/CSRFOptionsValue.mjs +178 -0
  13. package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/index.mjs +80 -0
  14. package/source/application/configurations/ServerConfiguration/Options/CompressOptions/CompressOptionsValue.mjs +126 -0
  15. package/source/application/configurations/ServerConfiguration/Options/CompressOptions/index.mjs +81 -0
  16. package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/CookieParserOptionsValue.mjs +107 -0
  17. package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/index.mjs +82 -0
  18. package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/CookieSessionOptionsValue.mjs +182 -0
  19. package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/index.mjs +86 -0
  20. package/source/application/configurations/ServerConfiguration/Options/ListenOptions/ListenOptionsValue.mjs +95 -0
  21. package/source/application/configurations/ServerConfiguration/Options/ListenOptions/index.mjs +47 -0
  22. package/source/application/configurations/ServerConfiguration/Options/MiddlewareOption.mjs +102 -0
  23. package/source/application/configurations/ServerConfiguration/Options/Options.mjs +105 -0
  24. package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/RateLimitOptionsValue.mjs +164 -0
  25. package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/index.mjs +84 -0
  26. package/source/application/configurations/ServerConfiguration/Options/RequestOptions/RequestOptionsValue.mjs +108 -0
  27. package/source/application/configurations/ServerConfiguration/Options/RequestOptions/index.mjs +47 -0
  28. package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/ResponseOptionsValue.mjs +101 -0
  29. package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/index.mjs +47 -0
  30. package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/SecurityHeadersOptionsValue.mjs +272 -0
  31. package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/index.mjs +85 -0
  32. package/source/application/configurations/ServerConfiguration/Options/ServerOption.mjs +50 -0
  33. package/source/application/configurations/ServerConfiguration/Options/StaticOptions/StaticOptionsValue.mjs +164 -0
  34. package/source/application/configurations/ServerConfiguration/Options/StaticOptions/index.mjs +89 -0
  35. package/source/application/configurations/ServerConfiguration/index.mjs +781 -0
  36. package/source/application/resolvers/TransportResolver/index.mjs +119 -0
  37. package/source/application/use-cases/CloseServer/index.mjs +85 -0
  38. package/source/application/use-cases/ListenServer/index.mjs +98 -0
  39. package/source/core/EventSystem/index.mjs +110 -0
  40. package/source/core/Exchange/ExchangeContext.mjs +291 -0
  41. package/source/core/Exchange/Telemetry.mjs +193 -0
  42. package/source/core/Exchange/index.mjs +604 -0
  43. package/source/core/ExchangeLayerBuilder/index.mjs +133 -0
  44. package/source/core/IPCMain/IPCMain.mjs +507 -0
  45. package/source/core/IPCMain/IPCProtocol.mjs +441 -0
  46. package/source/core/IPCMain/IPCServerChannel.mjs +331 -0
  47. package/source/core/IPCMain/WebSocketAdapter.mjs +189 -0
  48. package/source/core/Request/BodyParser/BodyParser.mjs +197 -0
  49. package/source/core/Request/BodyParser/JSONParser.mjs +70 -0
  50. package/source/core/Request/BodyParser/MultipartParser.mjs +612 -0
  51. package/source/core/Request/BodyParser/URLEncodedParser.mjs +71 -0
  52. package/source/core/Request/RequestHeaders.mjs +177 -0
  53. package/source/core/Request/index.mjs +805 -0
  54. package/source/core/Response/ResponseHeaders.mjs +402 -0
  55. package/source/core/Response/index.mjs +1370 -0
  56. package/source/core/middlewares/CORSMiddleware/index.mjs +289 -0
  57. package/source/core/middlewares/CSRFMiddleware/index.mjs +284 -0
  58. package/source/core/middlewares/CompressMiddleware/index.mjs +460 -0
  59. package/source/core/middlewares/CookieParserMiddleware/index.mjs +194 -0
  60. package/source/core/middlewares/CookieSessionMiddleware/index.mjs +460 -0
  61. package/source/core/middlewares/RateLimitMiddleware/index.mjs +245 -0
  62. package/source/core/middlewares/SecurityHeadersMiddleware/index.mjs +151 -0
  63. package/source/core/middlewares/StaticMiddleware/index.mjs +628 -0
  64. package/source/core/routing/ExchangeLayer/ExchangeMatcher.mjs +127 -0
  65. package/source/core/routing/ExchangeLayer/index.mjs +254 -0
  66. package/source/core/routing/Pipeline/index.mjs +157 -0
  67. package/source/core/routing/Router/Route.mjs +184 -0
  68. package/source/core/routing/Router/index.mjs +523 -0
  69. package/source/core/routing/RouterRegister/OptionsMiddleware.mjs +147 -0
  70. package/source/core/routing/RouterRegister/index.mjs +150 -0
  71. package/source/core/routing/RouterSystem/index.mjs +225 -0
  72. package/source/core/value-objects/Address/index.mjs +281 -0
  73. package/source/core/value-objects/Endpoint/index.mjs +293 -0
  74. package/source/index.mjs +62 -0
  75. package/source/infrastructure/adapters/Http2Adapter/index.mjs +144 -0
  76. package/source/infrastructure/adapters/Http2CleartextAdapter/index.mjs +147 -0
  77. package/source/infrastructure/adapters/HttpAdapter/index.mjs +124 -0
  78. package/source/infrastructure/adapters/HttpsAdapter/index.mjs +124 -0
  79. package/source/infrastructure/adapters/TransportAdapter/createWebSocketOpenRequest.mjs +843 -0
  80. package/source/infrastructure/adapters/TransportAdapter/index.mjs +555 -0
  81. package/source/shared/Identifier/index.mjs +671 -0
  82. package/source/shared/constants/symbols/brands/index.mjs +45 -0
  83. package/source/shared/domain/UUIDV7/index.mjs +346 -0
  84. package/source/shared/errors/BaseError/index.mjs +157 -0
  85. package/source/shared/errors/IPCError/index.mjs +97 -0
  86. package/source/shared/errors/MiddlewareError/index.mjs +106 -0
  87. package/source/shared/errors/PathnamePatternError/index.mjs +103 -0
  88. package/source/shared/errors/RequestError/index.mjs +116 -0
  89. package/source/shared/errors/ResponseError/index.mjs +140 -0
  90. package/source/shared/errors/RouterError/index.mjs +90 -0
  91. package/source/shared/errors/ServerConfigurationError/index.mjs +551 -0
  92. package/source/shared/errors/ServerError/index.mjs +127 -0
  93. package/source/shared/errors/WebSocketError/index.mjs +58 -0
  94. package/source/shared/outcome/ErrorSchema.mjs +361 -0
  95. package/source/shared/outcome/Result/index.mjs +239 -0
  96. package/source/shared/outcome/Validation/index.mjs +208 -0
  97. package/source/shared/validations/Middleware/index.mjs +160 -0
  98. package/source/shared/validations/PathnamePattern/index.mjs +256 -0
  99. package/source/shared/validations/Server/NotFoundHandler/index.mjs +94 -0
  100. package/source/shared/validations/Server/Router/index.mjs +68 -0
  101. package/source/shared/validations/Server/ServerConfiguration/index.mjs +68 -0
  102. package/source/shared/validations/Server/SystemErrorHandler/index.mjs +94 -0
  103. package/source/shared/validations/ServerConfiguration/CORSOptions/index.mjs +310 -0
  104. package/source/shared/validations/ServerConfiguration/CSRFOptions/index.mjs +299 -0
  105. package/source/shared/validations/ServerConfiguration/CompressOptions/index.mjs +204 -0
  106. package/source/shared/validations/ServerConfiguration/CookieParserOptions/index.mjs +102 -0
  107. package/source/shared/validations/ServerConfiguration/CookieSessionOptions/index.mjs +332 -0
  108. package/source/shared/validations/ServerConfiguration/ListenOptions/index.mjs +178 -0
  109. package/source/shared/validations/ServerConfiguration/RateLimitOptions/index.mjs +229 -0
  110. package/source/shared/validations/ServerConfiguration/RequestOptions/index.mjs +132 -0
  111. package/source/shared/validations/ServerConfiguration/ResponseOptions/index.mjs +122 -0
  112. package/source/shared/validations/ServerConfiguration/SecurityHeadersOptions/index.mjs +137 -0
  113. package/source/shared/validations/ServerConfiguration/StaticOptions/index.mjs +248 -0
  114. package/types/index.d.ts +719 -0
@@ -0,0 +1,460 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Defines the compression middleware.
4
+ *
5
+ * @description Implements HTTP response compression without external
6
+ * dependencies. Supports `br` and `gzip`, negotiates the encoding from
7
+ * `Accept-Encoding`, applies a size threshold, and skips incompressible
8
+ * media types.
9
+ *
10
+ * Configuration is expected to be validated and normalized before the
11
+ * middleware is created.
12
+ *
13
+ * @module CompressMiddleware
14
+ */
15
+
16
+ // ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17
+ // » IMPORT NATIVE NODEJS MODULES
18
+ import { constants as zlibConstants, createBrotliCompress, createGzip } from 'node:zlib';
19
+
20
+ // » IMPORT LOCAL MODULES
21
+ import { wrapResponseBody } from '#Exchange';
22
+
23
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
24
+ /**
25
+ * Represents a single HTTP transaction.
26
+ *
27
+ * @typedef {import('#Exchange').default} Exchange
28
+ */
29
+
30
+ /**
31
+ * Represents an outgoing HTTP response.
32
+ *
33
+ * @typedef {import('#Response').default} Response
34
+ */
35
+
36
+ /**
37
+ * Represents an incoming HTTP request.
38
+ *
39
+ * @typedef {import('#Request').default} Request
40
+ */
41
+
42
+ /**
43
+ * Represents a native Node.js transform stream.
44
+ *
45
+ * @typedef {import('node:stream').Transform} NodeTransform
46
+ */
47
+
48
+ /**
49
+ * Represents a function that delegates execution to the next middleware
50
+ * layer.
51
+ *
52
+ * @typedef {() => void | Promise<void>} NextFunction
53
+ */
54
+
55
+ /**
56
+ * Identifies the compression encodings supported by this middleware.
57
+ *
58
+ * @typedef {'br'|'gzip'} CompressionEncoding
59
+ */
60
+
61
+ /**
62
+ * Represents normalized compression middleware configuration.
63
+ *
64
+ * @typedef {object} CompressOptionsData
65
+ * @property {number} threshold - Minimum body size in bytes required to compress. Use `0` to compress any non-empty body.
66
+ * @property {number} level - Compression level passed to the underlying zlib implementation.
67
+ * @property {string[]|undefined} types - Compressible media types. When `undefined`, the default allow-list is used.
68
+ */
69
+
70
+ /**
71
+ * Represents a parsed `Accept-Encoding` entry.
72
+ *
73
+ * @typedef {object} EncodingPreference
74
+ * @property {string} encoding - The encoding token.
75
+ * @property {number} quality - Parsed q-value in the range `[0, 1]`.
76
+ */
77
+
78
+ /**
79
+ * Represents the outcome of negotiating an encoding.
80
+ *
81
+ * @typedef {CompressionEncoding|null} NegotiatedEncoding
82
+ */
83
+
84
+ // ━━ CONSTANTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
85
+ /**
86
+ * Media types considered compressible by default.
87
+ *
88
+ * Types that are already compressed (images, video, audio, archives) are
89
+ * intentionally excluded. Compressing them wastes CPU and often increases
90
+ * the payload size.
91
+ *
92
+ * @type {Set<string>}
93
+ */
94
+ const DEFAULT_COMPRESSIBLE_TYPES = new Set([
95
+ 'application/javascript',
96
+ 'application/json',
97
+ 'application/manifest+json',
98
+ 'application/xml',
99
+ 'application/xhtml+xml',
100
+ 'application/rss+xml',
101
+ 'application/atom+xml',
102
+ 'application/ld+json',
103
+ 'application/wasm',
104
+ 'image/svg+xml',
105
+ 'text/css',
106
+ 'text/csv',
107
+ 'text/html',
108
+ 'text/javascript',
109
+ 'text/plain',
110
+ 'text/xml',
111
+ ]);
112
+
113
+ /**
114
+ * Identifies the encodings supported by this middleware, in preference
115
+ * order.
116
+ *
117
+ * @type {CompressionEncoding[]}
118
+ */
119
+ const SUPPORTED_ENCODINGS = ['br', 'gzip'];
120
+
121
+ // ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
122
+ /**
123
+ * Reads a header value as a single string.
124
+ *
125
+ * Numeric header values are converted to strings. Array values are
126
+ * reduced to their first element.
127
+ *
128
+ * @private
129
+ * @param {string|number|(string|number)[]|undefined} value - Header value to normalize.
130
+ * @returns {string|undefined} The first header value as a string, or `undefined` when no value exists.
131
+ */
132
+ const firstHeaderValue = value => {
133
+ if (value === undefined) {
134
+ return undefined;
135
+ }
136
+
137
+ const first = Array.isArray(value) ? value[0] : value;
138
+ return typeof first === 'string' ? first : String(first);
139
+ };
140
+
141
+ /**
142
+ * Parses a single `q` parameter value.
143
+ *
144
+ * @private
145
+ * @param {string|undefined} rawValue - The raw parameter value.
146
+ * @returns {number} The parsed quality, or `0` when the value is invalid.
147
+ */
148
+ const parseQuality = rawValue => {
149
+ const parsed = Number.parseFloat((rawValue ?? '').trim());
150
+ return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0;
151
+ };
152
+
153
+ /**
154
+ * Parses a single `Accept-Encoding` entry.
155
+ *
156
+ * @private
157
+ * @param {string} part - A single comma-separated entry.
158
+ * @returns {EncodingPreference|null} The parsed preference, or `null` when the entry is empty.
159
+ */
160
+ const parsePreference = part => {
161
+ const [rawEncoding, ...params] = part.trim().split(';');
162
+ const encoding = rawEncoding.trim().toLowerCase();
163
+
164
+ const quality = params.reduce((acc, param) => {
165
+ const [key, rawValue] = param.trim().split('=');
166
+ return key.trim().toLowerCase() === 'q' ? parseQuality(rawValue) : acc;
167
+ }, 1);
168
+
169
+ return encoding.length === 0 ? null : { encoding, quality };
170
+ };
171
+
172
+ /**
173
+ * Parses an `Accept-Encoding` header value into a list of encoding
174
+ * preferences.
175
+ *
176
+ * Invalid entries are ignored. When the header is missing or contains no
177
+ * valid entry, an empty array is returned.
178
+ *
179
+ * @private
180
+ * @param {string|undefined} value - The `Accept-Encoding` header value.
181
+ * @returns {EncodingPreference[]} The parsed preferences.
182
+ */
183
+ const parseAcceptEncoding = value =>
184
+ typeof value !== 'string'
185
+ ? []
186
+ : value
187
+ .split(',')
188
+ .map(parsePreference)
189
+ .filter(preference => preference !== null);
190
+
191
+ /**
192
+ * Selects the best supported encoding from the parsed preferences.
193
+ *
194
+ * Preference order is determined by `q` value (descending). When several
195
+ * encodings share the same `q`, the order defined by `SUPPORTED_ENCODINGS`
196
+ * is preserved.
197
+ *
198
+ * @private
199
+ * @param {EncodingPreference[]} preferences - Parsed preferences.
200
+ * @returns {NegotiatedEncoding} The selected encoding, or `null` when none is acceptable.
201
+ */
202
+ const selectEncoding = preferences => {
203
+ const qualityByEncoding = new Map(
204
+ preferences.map(({ encoding, quality }) => [encoding, quality]),
205
+ );
206
+
207
+ const wildcardQuality = qualityByEncoding.get('*') ?? 0;
208
+
209
+ const candidates = SUPPORTED_ENCODINGS.map(encoding => ({
210
+ encoding,
211
+ quality: qualityByEncoding.get(encoding) ?? wildcardQuality,
212
+ })).filter(candidate => candidate.quality > 0);
213
+
214
+ return candidates.length === 0
215
+ ? null
216
+ : candidates.reduce((best, candidate) => (candidate.quality > best.quality ? candidate : best))
217
+ .encoding;
218
+ };
219
+
220
+ /**
221
+ * Determines whether a media type is compressible.
222
+ *
223
+ * The comparison ignores parameters (e.g. `; charset=utf-8`) and is
224
+ * case-insensitive. A media type is compressible when it is explicitly
225
+ * included in the configured allow-list, when it is a `text/*` type,
226
+ * or when it uses a structured `+json` / `+xml` suffix.
227
+ *
228
+ * @private
229
+ * @param {string|undefined} contentType - The response `Content-Type` header value.
230
+ * @param {Set<string>} compressibleTypes - The explicitly allowed media types.
231
+ * @returns {boolean} `true` when the media type is compressible.
232
+ */
233
+ const isCompressible = (contentType, compressibleTypes) => {
234
+ if (typeof contentType !== 'string') {
235
+ return false;
236
+ }
237
+
238
+ const [mediaType] = contentType.split(';', 1);
239
+ const normalized = mediaType.trim().toLowerCase();
240
+
241
+ return (
242
+ compressibleTypes.has(normalized) ||
243
+ normalized.startsWith('text/') ||
244
+ normalized.endsWith('+json') ||
245
+ normalized.endsWith('+xml')
246
+ );
247
+ };
248
+
249
+ /**
250
+ * Determines whether the response declares a compressible media type.
251
+ *
252
+ * @private
253
+ * @param {Response} response - The outgoing response.
254
+ * @param {Set<string>} compressibleTypes - The set of compressible media types.
255
+ * @returns {boolean} `true` when the response media type is compressible.
256
+ */
257
+ const isCompressibleResponse = (response, compressibleTypes) =>
258
+ isCompressible(firstHeaderValue(response.getHeader('Content-Type')), compressibleTypes);
259
+
260
+ /**
261
+ * Determines whether the response has a body to compress.
262
+ *
263
+ * @private
264
+ * @param {Response} response - The outgoing response.
265
+ * @returns {boolean} `true` when the response has a body.
266
+ */
267
+ const hasBody = response => response.bodyKind !== 'none';
268
+
269
+ /**
270
+ * Determines whether the response has no `Content-Encoding` header set.
271
+ *
272
+ * A response that already declares a content encoding must not be
273
+ * compressed again.
274
+ *
275
+ * @private
276
+ * @param {Response} response - The outgoing response.
277
+ * @returns {boolean} `true` when the response has no `Content-Encoding` header.
278
+ */
279
+ const hasNoContentEncoding = response => !response.hasHeader('Content-Encoding');
280
+
281
+ /**
282
+ * Determines whether the response body meets the size threshold required
283
+ * for compression.
284
+ *
285
+ * Buffered bodies are compared against the threshold. Stream bodies
286
+ * always meet the threshold because their size is unknown ahead of time.
287
+ *
288
+ * @private
289
+ * @param {Response} response - The outgoing response.
290
+ * @param {number} threshold - Minimum body size in bytes.
291
+ * @returns {boolean} `true` when the body meets the threshold.
292
+ */
293
+ const meetsThreshold = (response, threshold) =>
294
+ response.bodyKind !== 'buffer' || (response.bodyLength ?? 0) >= threshold;
295
+
296
+ /**
297
+ * Determines whether the response is in a state that permits compression.
298
+ *
299
+ * @private
300
+ * @param {Response} response - The outgoing response.
301
+ * @param {number} threshold - Minimum body size in bytes.
302
+ * @returns {boolean} `true` when the response can be compressed.
303
+ */
304
+ const canApplyCompression = (response, threshold) =>
305
+ !response.isCommitted &&
306
+ hasBody(response) &&
307
+ hasNoContentEncoding(response) &&
308
+ meetsThreshold(response, threshold);
309
+
310
+ /**
311
+ * Creates the transform stream for the selected encoding.
312
+ *
313
+ * @private
314
+ * @param {CompressionEncoding} encoding - The negotiated encoding.
315
+ * @param {number} level - The compression level.
316
+ * @returns {NodeTransform} The transform stream.
317
+ */
318
+ const createCompressionStream = (encoding, level) =>
319
+ encoding === 'br'
320
+ ? createBrotliCompress({
321
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: level },
322
+ })
323
+ : createGzip({ level });
324
+
325
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
326
+ /**
327
+ * Implements HTTP response compression.
328
+ *
329
+ * @classdesc The middleware negotiates the compression encoding from
330
+ * `Accept-Encoding`, applies a size threshold, filters incompressible
331
+ * media types, and wraps the response body with the selected transform.
332
+ *
333
+ * Compression runs after `await next()` so that the response body is
334
+ * already described by the handler. The middleware never overwrites an
335
+ * existing `Content-Encoding`.
336
+ */
337
+ class CompressMiddleware {
338
+ /**
339
+ * Normalized compression options.
340
+ *
341
+ * @type {Readonly<CompressOptionsData>}
342
+ */
343
+ #options;
344
+
345
+ /**
346
+ * Compressible media types resolved at construction time.
347
+ *
348
+ * @type {Set<string>}
349
+ */
350
+ #compressibleTypes;
351
+
352
+ /**
353
+ * Creates a compression middleware.
354
+ *
355
+ * @param {CompressOptionsData} options - Validated and normalized compression options.
356
+ *
357
+ * @example
358
+ * ```js
359
+ * const middleware = new CompressMiddleware({
360
+ * threshold: 1024,
361
+ * level: 6,
362
+ * types: undefined,
363
+ * });
364
+ * ```
365
+ */
366
+ constructor(options) {
367
+ this.#options = options;
368
+ this.#compressibleTypes =
369
+ options.types === undefined
370
+ ? DEFAULT_COMPRESSIBLE_TYPES
371
+ : new Set(options.types.map(type => type.toLowerCase()));
372
+ }
373
+
374
+ /**
375
+ * Processes an HTTP exchange and compresses the response when applicable.
376
+ *
377
+ * @param {Exchange} exchange - Exchange to process.
378
+ * @param {NextFunction} next - Function that delegates to the next middleware.
379
+ * @returns {Promise<void>} Resolves when request processing has completed.
380
+ *
381
+ * @example
382
+ * ```js
383
+ * await middleware.handle(exchange, next);
384
+ * ```
385
+ */
386
+ async handle(exchange, next) {
387
+ await next();
388
+
389
+ const { request, response } = exchange;
390
+ const encoding = this.#negotiate(request);
391
+
392
+ if (this.#shouldCompress(response, encoding)) {
393
+ this.#applyCompression(response, encoding);
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Negotiates the compression encoding for the request.
399
+ *
400
+ * @param {Request} request - The incoming request.
401
+ * @returns {NegotiatedEncoding} The negotiated encoding, or `null` when none is acceptable.
402
+ */
403
+ #negotiate(request) {
404
+ return selectEncoding(
405
+ parseAcceptEncoding(firstHeaderValue(request.getHeader('accept-encoding'))),
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Determines whether the response should be compressed.
411
+ *
412
+ * @param {Response} response - The outgoing response.
413
+ * @param {NegotiatedEncoding} encoding - The negotiated encoding.
414
+ * @returns {encoding is CompressionEncoding} `true` when the response should be compressed.
415
+ */
416
+ #shouldCompress(response, encoding) {
417
+ return (
418
+ encoding !== null &&
419
+ isCompressibleResponse(response, this.#compressibleTypes) &&
420
+ canApplyCompression(response, this.#options.threshold)
421
+ );
422
+ }
423
+
424
+ /**
425
+ * Applies the compression transform to the response.
426
+ *
427
+ * Compression is best-effort. If the response cannot be wrapped, the
428
+ * original response remains unchanged.
429
+ *
430
+ * @param {Response} response - The outgoing response.
431
+ * @param {CompressionEncoding} encoding - The negotiated encoding.
432
+ */
433
+ #applyCompression(response, encoding) {
434
+ const transform = createCompressionStream(encoding, this.#options.level);
435
+
436
+ if (this.#tryWrap(response, transform)) {
437
+ response.setHeader('Content-Encoding', encoding);
438
+ response.vary('Accept-Encoding');
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Attempts to wrap the response body with the given transform.
444
+ *
445
+ * @param {Response} response - The outgoing response.
446
+ * @param {NodeTransform} transform - The compression transform.
447
+ * @returns {boolean} `true` when the body was wrapped.
448
+ */
449
+ #tryWrap(response, transform) {
450
+ try {
451
+ wrapResponseBody(response, transform);
452
+ return true;
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+ }
458
+
459
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
460
+ export default CompressMiddleware;
@@ -0,0 +1,194 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Defines the Cookie Parser middleware.
4
+ *
5
+ * @description Parses the Cookie request header and exposes the resulting
6
+ * name/value pairs on `exchange.state.cookies`. Supports optional URI decoding
7
+ * of cookie values and quoted cookie values. Does not handle signed cookies or
8
+ * Set-Cookie generation.
9
+ *
10
+ * Configuration is expected to be validated and normalized before the
11
+ * middleware is created.
12
+ *
13
+ * @module CookieParserMiddleware
14
+ */
15
+
16
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17
+ /**
18
+ * Represents an HTTP exchange.
19
+ *
20
+ * @typedef {import('#Exchange').default} Exchange
21
+ */
22
+
23
+ /**
24
+ * Represents a function that delegates execution to the next middleware
25
+ * layer.
26
+ *
27
+ * @typedef {() => void | Promise<void>} NextFunction
28
+ */
29
+
30
+ /**
31
+ * Represents a map of cookie names to their string values.
32
+ *
33
+ * @typedef {Record<string, string>} CookieMap
34
+ */
35
+
36
+ /**
37
+ * Represents normalized Cookie Parser middleware configuration.
38
+ *
39
+ * @typedef {object} CookieParserOptionsData
40
+ * @property {boolean} decode - Whether cookie values are decoded with `decodeURIComponent`.
41
+ */
42
+
43
+ // ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
44
+ /**
45
+ * Normalizes the Cookie request header into a single string.
46
+ *
47
+ * When the header is repeated, the values are joined with `"; "` so that a
48
+ * single parse pass can process every cookie pair.
49
+ *
50
+ * @private
51
+ * @param {string|string[]|undefined} value - Cookie header value to normalize.
52
+ * @returns {string|undefined} The normalized header string, or `undefined` when no value exists.
53
+ */
54
+ function normalizeCookieHeader(value) {
55
+ if (Array.isArray(value)) {
56
+ return value.length > 0 ? value.join('; ') : undefined;
57
+ }
58
+
59
+ return value;
60
+ }
61
+
62
+ /**
63
+ * Removes surrounding double quotes from a cookie value.
64
+ *
65
+ * @private
66
+ * @param {string} value - Raw cookie value.
67
+ * @returns {string} The unquoted cookie value.
68
+ */
69
+ function unquoteCookieValue(value) {
70
+ return value.length >= 2 && value.startsWith('"') && value.endsWith('"')
71
+ ? value.slice(1, -1)
72
+ : value;
73
+ }
74
+
75
+ /**
76
+ * Decodes a cookie value with `decodeURIComponent`.
77
+ *
78
+ * Malformed percent-encoding is left unchanged so that a single bad value
79
+ * does not abort parsing of the remaining cookies.
80
+ *
81
+ * @private
82
+ * @param {string} value - Cookie value to decode.
83
+ * @returns {string} The decoded value, or the original value when decoding fails.
84
+ */
85
+ function safeDecode(value) {
86
+ try {
87
+ return decodeURIComponent(value);
88
+ } catch {
89
+ return value;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Parses a Cookie header into a name/value map.
95
+ *
96
+ * Segments without `=` and empty names are ignored. When the same name
97
+ * appears more than once, the last occurrence wins.
98
+ *
99
+ * @private
100
+ * @param {string} header - Raw Cookie header value.
101
+ * @param {boolean} decode - Whether to URI-decode values.
102
+ * @returns {CookieMap} The parsed cookie map.
103
+ */
104
+ function parseCookieHeader(header, decode) {
105
+ /** @type {CookieMap} */
106
+ const cookies = Object.create(null);
107
+
108
+ const segments = header.split(';');
109
+
110
+ for (const segment of segments) {
111
+ const separatorIndex = segment.indexOf('=');
112
+
113
+ if (separatorIndex === -1) {
114
+ continue;
115
+ }
116
+
117
+ const name = segment.slice(0, separatorIndex).trim();
118
+
119
+ if (name.length > 0) {
120
+ const rawValue = segment.slice(separatorIndex + 1).trim();
121
+ const unquotedValue = unquoteCookieValue(rawValue);
122
+
123
+ cookies[name] = decode ? safeDecode(unquotedValue) : unquotedValue;
124
+ }
125
+ }
126
+
127
+ return cookies;
128
+ }
129
+
130
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
131
+ /**
132
+ * Parses the Cookie request header and stores the result on the exchange.
133
+ *
134
+ * @classdesc Reads the `Cookie` header, builds a name/value map, and assigns
135
+ * it to `exchange.state.cookies` before calling `next()`. When the header is
136
+ * absent or empty, `exchange.state.cookies` is set to an empty cookie map.
137
+ *
138
+ * The middleware does not modify the response and does not end the pipeline.
139
+ */
140
+ class CookieParserMiddleware {
141
+ /**
142
+ * Normalized Cookie Parser middleware options.
143
+ *
144
+ * @type {Readonly<CookieParserOptionsData>}
145
+ */
146
+ #options;
147
+
148
+ /**
149
+ * Creates a Cookie Parser middleware.
150
+ *
151
+ * @param {CookieParserOptionsData} options - Validated and normalized cookie parser options.
152
+ *
153
+ * @example
154
+ * ```
155
+ * const middleware = new CookieParserMiddleware({
156
+ * decode: true,
157
+ * });
158
+ * ```
159
+ */
160
+ constructor(options) {
161
+ this.#options = options;
162
+ }
163
+
164
+ /**
165
+ * Processes an HTTP exchange and populates `exchange.state.cookies`.
166
+ *
167
+ * @param {Exchange} exchange - Exchange to process.
168
+ * @param {NextFunction} next - Function that delegates to the next middleware.
169
+ * @returns {Promise<void>} Resolves when request processing has completed.
170
+ *
171
+ * @example
172
+ * ```js
173
+ * await middleware.handle(exchange, next);
174
+ * // exchange.state.cookies → { session: 'abc', theme: 'dark' }
175
+ * ```
176
+ */
177
+ async handle(exchange, next) {
178
+ const { request, state } = exchange;
179
+ const { decode } = this.#options;
180
+
181
+ const header = normalizeCookieHeader(request.getHeader('cookie'));
182
+
183
+ if (typeof header === 'string' && header.length > 0) {
184
+ state.cookies = parseCookieHeader(header, decode);
185
+ } else {
186
+ state.cookies = Object.create(null);
187
+ }
188
+
189
+ await next();
190
+ }
191
+ }
192
+
193
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
194
+ export default CookieParserMiddleware;