@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.
- package/README.md +229 -2
- package/index.mjs +23 -0
- package/package.json +71 -4
- package/source/application/Kernel/index.mjs +1137 -0
- package/source/application/Server/index.mjs +1201 -0
- package/source/application/configurations/Http2CleartextServerConfiguration/index.mjs +168 -0
- package/source/application/configurations/Http2SecureServerConfiguration/index.mjs +172 -0
- package/source/application/configurations/HttpServerConfiguration/index.mjs +169 -0
- package/source/application/configurations/HttpsServerConfiguration/index.mjs +172 -0
- package/source/application/configurations/ServerConfiguration/Options/CORSOptions/CORSOptionsValue.mjs +158 -0
- package/source/application/configurations/ServerConfiguration/Options/CORSOptions/index.mjs +87 -0
- package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/CSRFOptionsValue.mjs +178 -0
- package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/index.mjs +80 -0
- package/source/application/configurations/ServerConfiguration/Options/CompressOptions/CompressOptionsValue.mjs +126 -0
- package/source/application/configurations/ServerConfiguration/Options/CompressOptions/index.mjs +81 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/CookieParserOptionsValue.mjs +107 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/index.mjs +82 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/CookieSessionOptionsValue.mjs +182 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/index.mjs +86 -0
- package/source/application/configurations/ServerConfiguration/Options/ListenOptions/ListenOptionsValue.mjs +95 -0
- package/source/application/configurations/ServerConfiguration/Options/ListenOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/MiddlewareOption.mjs +102 -0
- package/source/application/configurations/ServerConfiguration/Options/Options.mjs +105 -0
- package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/RateLimitOptionsValue.mjs +164 -0
- package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/index.mjs +84 -0
- package/source/application/configurations/ServerConfiguration/Options/RequestOptions/RequestOptionsValue.mjs +108 -0
- package/source/application/configurations/ServerConfiguration/Options/RequestOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/ResponseOptionsValue.mjs +101 -0
- package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/SecurityHeadersOptionsValue.mjs +272 -0
- package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/index.mjs +85 -0
- package/source/application/configurations/ServerConfiguration/Options/ServerOption.mjs +50 -0
- package/source/application/configurations/ServerConfiguration/Options/StaticOptions/StaticOptionsValue.mjs +164 -0
- package/source/application/configurations/ServerConfiguration/Options/StaticOptions/index.mjs +89 -0
- package/source/application/configurations/ServerConfiguration/index.mjs +781 -0
- package/source/application/resolvers/TransportResolver/index.mjs +119 -0
- package/source/application/use-cases/CloseServer/index.mjs +85 -0
- package/source/application/use-cases/ListenServer/index.mjs +98 -0
- package/source/core/EventSystem/index.mjs +110 -0
- package/source/core/Exchange/ExchangeContext.mjs +291 -0
- package/source/core/Exchange/Telemetry.mjs +193 -0
- package/source/core/Exchange/index.mjs +604 -0
- package/source/core/ExchangeLayerBuilder/index.mjs +133 -0
- package/source/core/IPCMain/IPCMain.mjs +507 -0
- package/source/core/IPCMain/IPCProtocol.mjs +441 -0
- package/source/core/IPCMain/IPCServerChannel.mjs +331 -0
- package/source/core/IPCMain/WebSocketAdapter.mjs +189 -0
- package/source/core/Request/BodyParser/BodyParser.mjs +197 -0
- package/source/core/Request/BodyParser/JSONParser.mjs +70 -0
- package/source/core/Request/BodyParser/MultipartParser.mjs +612 -0
- package/source/core/Request/BodyParser/URLEncodedParser.mjs +71 -0
- package/source/core/Request/RequestHeaders.mjs +177 -0
- package/source/core/Request/index.mjs +805 -0
- package/source/core/Response/ResponseHeaders.mjs +402 -0
- package/source/core/Response/index.mjs +1370 -0
- package/source/core/middlewares/CORSMiddleware/index.mjs +289 -0
- package/source/core/middlewares/CSRFMiddleware/index.mjs +284 -0
- package/source/core/middlewares/CompressMiddleware/index.mjs +460 -0
- package/source/core/middlewares/CookieParserMiddleware/index.mjs +194 -0
- package/source/core/middlewares/CookieSessionMiddleware/index.mjs +460 -0
- package/source/core/middlewares/RateLimitMiddleware/index.mjs +245 -0
- package/source/core/middlewares/SecurityHeadersMiddleware/index.mjs +151 -0
- package/source/core/middlewares/StaticMiddleware/index.mjs +628 -0
- package/source/core/routing/ExchangeLayer/ExchangeMatcher.mjs +127 -0
- package/source/core/routing/ExchangeLayer/index.mjs +254 -0
- package/source/core/routing/Pipeline/index.mjs +157 -0
- package/source/core/routing/Router/Route.mjs +184 -0
- package/source/core/routing/Router/index.mjs +523 -0
- package/source/core/routing/RouterRegister/OptionsMiddleware.mjs +147 -0
- package/source/core/routing/RouterRegister/index.mjs +150 -0
- package/source/core/routing/RouterSystem/index.mjs +225 -0
- package/source/core/value-objects/Address/index.mjs +281 -0
- package/source/core/value-objects/Endpoint/index.mjs +293 -0
- package/source/index.mjs +62 -0
- package/source/infrastructure/adapters/Http2Adapter/index.mjs +144 -0
- package/source/infrastructure/adapters/Http2CleartextAdapter/index.mjs +147 -0
- package/source/infrastructure/adapters/HttpAdapter/index.mjs +124 -0
- package/source/infrastructure/adapters/HttpsAdapter/index.mjs +124 -0
- package/source/infrastructure/adapters/TransportAdapter/createWebSocketOpenRequest.mjs +843 -0
- package/source/infrastructure/adapters/TransportAdapter/index.mjs +555 -0
- package/source/shared/Identifier/index.mjs +671 -0
- package/source/shared/constants/symbols/brands/index.mjs +45 -0
- package/source/shared/domain/UUIDV7/index.mjs +346 -0
- package/source/shared/errors/BaseError/index.mjs +157 -0
- package/source/shared/errors/IPCError/index.mjs +97 -0
- package/source/shared/errors/MiddlewareError/index.mjs +106 -0
- package/source/shared/errors/PathnamePatternError/index.mjs +103 -0
- package/source/shared/errors/RequestError/index.mjs +116 -0
- package/source/shared/errors/ResponseError/index.mjs +140 -0
- package/source/shared/errors/RouterError/index.mjs +90 -0
- package/source/shared/errors/ServerConfigurationError/index.mjs +551 -0
- package/source/shared/errors/ServerError/index.mjs +127 -0
- package/source/shared/errors/WebSocketError/index.mjs +58 -0
- package/source/shared/outcome/ErrorSchema.mjs +361 -0
- package/source/shared/outcome/Result/index.mjs +239 -0
- package/source/shared/outcome/Validation/index.mjs +208 -0
- package/source/shared/validations/Middleware/index.mjs +160 -0
- package/source/shared/validations/PathnamePattern/index.mjs +256 -0
- package/source/shared/validations/Server/NotFoundHandler/index.mjs +94 -0
- package/source/shared/validations/Server/Router/index.mjs +68 -0
- package/source/shared/validations/Server/ServerConfiguration/index.mjs +68 -0
- package/source/shared/validations/Server/SystemErrorHandler/index.mjs +94 -0
- package/source/shared/validations/ServerConfiguration/CORSOptions/index.mjs +310 -0
- package/source/shared/validations/ServerConfiguration/CSRFOptions/index.mjs +299 -0
- package/source/shared/validations/ServerConfiguration/CompressOptions/index.mjs +204 -0
- package/source/shared/validations/ServerConfiguration/CookieParserOptions/index.mjs +102 -0
- package/source/shared/validations/ServerConfiguration/CookieSessionOptions/index.mjs +332 -0
- package/source/shared/validations/ServerConfiguration/ListenOptions/index.mjs +178 -0
- package/source/shared/validations/ServerConfiguration/RateLimitOptions/index.mjs +229 -0
- package/source/shared/validations/ServerConfiguration/RequestOptions/index.mjs +132 -0
- package/source/shared/validations/ServerConfiguration/ResponseOptions/index.mjs +122 -0
- package/source/shared/validations/ServerConfiguration/SecurityHeadersOptions/index.mjs +137 -0
- package/source/shared/validations/ServerConfiguration/StaticOptions/index.mjs +248 -0
- 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;
|