@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 Cookie Session middleware.
4
+ *
5
+ * @description Stores session data entirely in a signed cookie. Supports key
6
+ * rotation, configurable cookie attributes, and automatic persistence of
7
+ * session mutations after the rest of the pipeline completes.
8
+ *
9
+ * Session state is exposed on `exchange.state.session` as a mutable object
10
+ * without a prototype. When the request carries no valid session cookie, an
11
+ * empty object is provided. Assigning `null` or `undefined` to
12
+ * `exchange.state.session` clears the cookie.
13
+ *
14
+ * Configuration is expected to be validated and normalized before the
15
+ * middleware is created.
16
+ *
17
+ * @module CookieSessionMiddleware
18
+ */
19
+
20
+ // ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21
+ // » IMPORT NODE MODULES
22
+ import { createHmac, timingSafeEqual } from 'node:crypto';
23
+
24
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
25
+ /**
26
+ * Represents an HTTP exchange.
27
+ *
28
+ * @typedef {import('#Exchange').default} Exchange
29
+ */
30
+
31
+ /**
32
+ * Represents an outgoing HTTP response.
33
+ *
34
+ * @typedef {import('#Response').default} Response
35
+ */
36
+
37
+ /**
38
+ * Represents a function that delegates execution to the next middleware
39
+ * layer.
40
+ *
41
+ * @typedef {() => void | Promise<void>} NextFunction
42
+ */
43
+
44
+ /**
45
+ * Represents session data stored in the cookie.
46
+ *
47
+ * @typedef {Record<string, unknown>} SessionData
48
+ */
49
+
50
+ /**
51
+ * Represents normalized Cookie Session middleware configuration.
52
+ *
53
+ * @typedef {object} CookieSessionOptionsData
54
+ * @property {string} name - Name of the session cookie.
55
+ * @property {string[]} keys - Signing keys. The first key is used to sign; all keys are tried when verifying.
56
+ * @property {number|undefined} maxAge - Cookie max-age in milliseconds. When `undefined`, the cookie is a session cookie.
57
+ * @property {string} path - Cookie path attribute.
58
+ * @property {string|undefined} domain - Cookie domain attribute.
59
+ * @property {boolean} secure - Whether the cookie is marked Secure.
60
+ * @property {boolean} httpOnly - Whether the cookie is marked HttpOnly.
61
+ * @property {'Strict'|'Lax'|'None'|undefined} sameSite - Cookie SameSite attribute.
62
+ * @property {boolean} overwrite - Whether an existing Set-Cookie header with the same name should be overwritten.
63
+ */
64
+
65
+ // ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
66
+ /**
67
+ * Normalizes the Cookie request header into a single string.
68
+ *
69
+ * When the header is repeated, the values are joined with `"; "` so that a
70
+ * single parse pass can process every cookie pair.
71
+ *
72
+ * @private
73
+ * @param {string|string[]|undefined} value - Cookie header value to normalize.
74
+ * @returns {string|undefined} The normalized header string, or `undefined` when no value exists.
75
+ */
76
+ function normalizeCookieHeader(value) {
77
+ if (Array.isArray(value)) {
78
+ return value.length > 0 ? value.join('; ') : undefined;
79
+ }
80
+
81
+ return value;
82
+ }
83
+
84
+ /**
85
+ * Encodes a buffer as base64url without padding.
86
+ *
87
+ * @private
88
+ * @param {Buffer} buffer - Buffer to encode.
89
+ * @returns {string} The base64url string.
90
+ */
91
+ function toBase64Url(buffer) {
92
+ return buffer.toString('base64url');
93
+ }
94
+
95
+ /**
96
+ * Decodes a base64url string into a buffer.
97
+ *
98
+ * @private
99
+ * @param {string} value - Base64url string.
100
+ * @returns {Buffer} The decoded buffer.
101
+ */
102
+ function fromBase64Url(value) {
103
+ return Buffer.from(value, 'base64url');
104
+ }
105
+
106
+ /**
107
+ * Signs a payload with HMAC-SHA256 using the given key.
108
+ *
109
+ * @private
110
+ * @param {string} payload - Base64url payload to sign.
111
+ * @param {string} key - Signing key.
112
+ * @returns {string} The base64url signature.
113
+ */
114
+ function sign(payload, key) {
115
+ return toBase64Url(createHmac('sha256', key).update(payload).digest());
116
+ }
117
+
118
+ /**
119
+ * Compares two signatures in constant time.
120
+ *
121
+ * @private
122
+ * @param {string} left - First signature.
123
+ * @param {string} right - Second signature.
124
+ * @returns {boolean} Whether the signatures are equal.
125
+ */
126
+ function signaturesEqual(left, right) {
127
+ const leftBuffer = Buffer.from(left);
128
+ const rightBuffer = Buffer.from(right);
129
+
130
+ if (leftBuffer.length !== rightBuffer.length) {
131
+ return false;
132
+ }
133
+
134
+ return timingSafeEqual(leftBuffer, rightBuffer);
135
+ }
136
+
137
+ /**
138
+ * Verifies a signed cookie value against the configured keys.
139
+ *
140
+ * @private
141
+ * @param {string} value - Raw cookie value in the form `payload.signature`.
142
+ * @param {string[]} keys - Signing keys to try.
143
+ * @returns {string|null} The verified base64url payload, or `null` when verification fails.
144
+ */
145
+ function verify(value, keys) {
146
+ const separatorIndex = value.lastIndexOf('.');
147
+
148
+ if (separatorIndex <= 0 || separatorIndex === value.length - 1) {
149
+ return null;
150
+ }
151
+
152
+ const payload = value.slice(0, separatorIndex);
153
+ const signature = value.slice(separatorIndex + 1);
154
+
155
+ for (const key of keys) {
156
+ const expected = sign(payload, key);
157
+
158
+ if (signaturesEqual(signature, expected)) {
159
+ return payload;
160
+ }
161
+ }
162
+
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * Serializes session data into a signed cookie value.
168
+ *
169
+ * @private
170
+ * @param {SessionData} session - Session object to serialize.
171
+ * @param {string} key - Signing key.
172
+ * @returns {string} The signed cookie value.
173
+ */
174
+ function encodeSession(session, key) {
175
+ const json = JSON.stringify(session);
176
+ const payload = toBase64Url(Buffer.from(json, 'utf8'));
177
+ const signature = sign(payload, key);
178
+
179
+ return `${payload}.${signature}`;
180
+ }
181
+
182
+ /**
183
+ * Deserializes a verified payload into session data.
184
+ *
185
+ * @private
186
+ * @param {string} payload - Verified base64url payload.
187
+ * @returns {SessionData|null} The session object, or `null` when the payload is invalid.
188
+ */
189
+ function decodeSession(payload) {
190
+ const buffer = fromBase64Url(payload);
191
+
192
+ try {
193
+ const parsed = JSON.parse(buffer.toString('utf8'));
194
+
195
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
196
+ return /** @type {SessionData} */ (parsed);
197
+ }
198
+
199
+ return null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Extracts the last occurrence of the session cookie from the Cookie header.
207
+ *
208
+ * @private
209
+ * @param {string|undefined} header - Raw Cookie header.
210
+ * @param {string} name - Session cookie name.
211
+ * @returns {string|undefined} The cookie value, or `undefined` when absent.
212
+ */
213
+ function readCookie(header, name) {
214
+ if (typeof header !== 'string' || header.length === 0) {
215
+ return undefined;
216
+ }
217
+
218
+ const matchedSegment = header.split(';').findLast(segment => {
219
+ const separatorIndex = segment.indexOf('=');
220
+
221
+ return separatorIndex !== -1 && segment.slice(0, separatorIndex).trim() === name;
222
+ });
223
+
224
+ if (matchedSegment === undefined) {
225
+ return undefined;
226
+ }
227
+
228
+ const separatorIndex = matchedSegment.indexOf('=');
229
+ const rawValue = matchedSegment.slice(separatorIndex + 1).trim();
230
+
231
+ return rawValue.length >= 2 && rawValue.startsWith('"') && rawValue.endsWith('"')
232
+ ? rawValue.slice(1, -1)
233
+ : rawValue;
234
+ }
235
+
236
+ /**
237
+ * Builds a Set-Cookie header value for the session cookie.
238
+ *
239
+ * @private
240
+ * @param {string} name - Cookie name.
241
+ * @param {string} value - Cookie value. An empty string clears the cookie.
242
+ * @param {CookieSessionOptionsData} options - Cookie attributes.
243
+ * @param {boolean} clear - Whether the cookie should be expired.
244
+ * @returns {string} The Set-Cookie header value.
245
+ */
246
+ function buildSetCookie(name, value, options, clear) {
247
+ const parts = [`${name}=${value}`];
248
+
249
+ if (clear) {
250
+ parts.push('Max-Age=0');
251
+ } else if (typeof options.maxAge === 'number') {
252
+ parts.push(`Max-Age=${Math.floor(options.maxAge / 1000)}`);
253
+ }
254
+
255
+ parts.push(`Path=${options.path}`);
256
+
257
+ if (typeof options.domain === 'string') {
258
+ parts.push(`Domain=${options.domain}`);
259
+ }
260
+
261
+ if (options.secure) {
262
+ parts.push('Secure');
263
+ }
264
+
265
+ if (options.httpOnly) {
266
+ parts.push('HttpOnly');
267
+ }
268
+
269
+ if (typeof options.sameSite === 'string') {
270
+ parts.push(`SameSite=${options.sameSite}`);
271
+ }
272
+
273
+ return parts.join('; ');
274
+ }
275
+
276
+ /**
277
+ * Writes or replaces the session Set-Cookie header on the response.
278
+ *
279
+ * When `overwrite` is `false` and a cookie with the same name already exists,
280
+ * the existing value is preserved and no header is written.
281
+ *
282
+ * @private
283
+ * @param {Response} response - Response to modify.
284
+ * @param {string} setCookie - Set-Cookie header value.
285
+ * @param {string} name - Session cookie name.
286
+ * @param {boolean} overwrite - Whether to replace an existing cookie with the same name.
287
+ */
288
+ function writeSetCookie(response, setCookie, name, overwrite) {
289
+ const existing = response.getHeader('Set-Cookie');
290
+
291
+ if (existing === undefined) {
292
+ response.setHeader('Set-Cookie', setCookie);
293
+ } else if (overwrite) {
294
+ const prefix = `${name}=`;
295
+ const values = Array.isArray(existing)
296
+ ? existing.map(value => String(value))
297
+ : [String(existing)];
298
+ const filtered = values.filter(value => !value.startsWith(prefix));
299
+
300
+ filtered.push(setCookie);
301
+ response.setHeader('Set-Cookie', filtered);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Produces a stable JSON snapshot of the session for change detection.
307
+ *
308
+ * @private
309
+ * @param {SessionData|null|undefined} session - Session to snapshot.
310
+ * @returns {string} Serialized snapshot.
311
+ */
312
+ function snapshot(session) {
313
+ if (session === null || session === undefined) {
314
+ return '';
315
+ }
316
+
317
+ return JSON.stringify(session);
318
+ }
319
+
320
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
321
+ /**
322
+ * Stores session data in a signed cookie.
323
+ *
324
+ * @classdesc Loads a signed session cookie into `exchange.state.session`,
325
+ * delegates to the rest of the pipeline, and persists any mutations back to
326
+ * the cookie. Invalid or missing cookies result in an empty object.
327
+ *
328
+ * Session state is exposed on `exchange.state.session` as a mutable object
329
+ * without a prototype. Assigning `null` or `undefined` to
330
+ * `exchange.state.session` clears the cookie.
331
+ *
332
+ * Signing uses HMAC-SHA256. The first configured key is used to sign; all
333
+ * keys are accepted when verifying so that key rotation is possible.
334
+ *
335
+ * @example
336
+ * ```js
337
+ * const middleware = new CookieSessionMiddleware({
338
+ * name: 'sid',
339
+ * keys: ['current-secret', 'previous-secret'],
340
+ * maxAge: 86_400_000,
341
+ * path: '/',
342
+ * domain: undefined,
343
+ * secure: true,
344
+ * httpOnly: true,
345
+ * sameSite: 'Lax',
346
+ * overwrite: true,
347
+ * });
348
+ * ```
349
+ */
350
+ class CookieSessionMiddleware {
351
+ /**
352
+ * Normalized Cookie Session middleware options.
353
+ *
354
+ * @type {Readonly<CookieSessionOptionsData>}
355
+ */
356
+ #options;
357
+
358
+ /**
359
+ * Creates a Cookie Session middleware.
360
+ *
361
+ * @param {CookieSessionOptionsData} options - Validated and normalized cookie session options.
362
+ *
363
+ * @example
364
+ * ```js
365
+ * const middleware = new CookieSessionMiddleware({
366
+ * name: 'sid',
367
+ * keys: ['current-secret', 'previous-secret'],
368
+ * maxAge: 86_400_000,
369
+ * path: '/',
370
+ * domain: undefined,
371
+ * secure: true,
372
+ * httpOnly: true,
373
+ * sameSite: 'Lax',
374
+ * overwrite: true,
375
+ * });
376
+ * ```
377
+ */
378
+ constructor(options) {
379
+ this.#options = options;
380
+ }
381
+
382
+ /**
383
+ * Processes an HTTP exchange and manages the session cookie.
384
+ *
385
+ * @param {Exchange} exchange - Exchange to process.
386
+ * @param {NextFunction} next - Function that delegates to the next middleware.
387
+ * @returns {Promise<void>} Resolves when request processing has completed.
388
+ *
389
+ * @example
390
+ * ```js
391
+ * await middleware.handle(exchange, next);
392
+ * // exchange.state.session → { userId: '42' }
393
+ * ```
394
+ */
395
+ async handle(exchange, next) {
396
+ const { request, response, state } = exchange;
397
+ const { name, keys, overwrite } = this.#options;
398
+
399
+ const header = normalizeCookieHeader(request.getHeader('cookie'));
400
+ const rawCookie = readCookie(header, name);
401
+ const { session, snapshot: initialSnapshot } = this.#loadSession(rawCookie, keys);
402
+
403
+ state.session = session;
404
+
405
+ await next();
406
+
407
+ const current = state.session;
408
+ const currentSnapshot = snapshot(
409
+ current !== null &&
410
+ current !== undefined &&
411
+ typeof current === 'object' &&
412
+ !Array.isArray(current)
413
+ ? /** @type {SessionData} */ (current)
414
+ : null,
415
+ );
416
+
417
+ if (currentSnapshot !== initialSnapshot) {
418
+ if (current === null || current === undefined) {
419
+ writeSetCookie(response, buildSetCookie(name, '', this.#options, true), name, overwrite);
420
+ } else if (typeof current === 'object' && !Array.isArray(current)) {
421
+ const encoded = encodeSession(/** @type {SessionData} */ (current), keys[0]);
422
+
423
+ writeSetCookie(
424
+ response,
425
+ buildSetCookie(name, encoded, this.#options, false),
426
+ name,
427
+ overwrite,
428
+ );
429
+ }
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Loads and verifies the session from the raw cookie value.
435
+ *
436
+ * @param {string|undefined} rawCookie - Raw cookie value.
437
+ * @param {string[]} keys - Signing keys.
438
+ * @returns {{ session: SessionData, snapshot: string }} Loaded session and its initial snapshot.
439
+ */
440
+ #loadSession(rawCookie, keys) {
441
+ if (typeof rawCookie === 'string' && rawCookie.length > 0) {
442
+ const payload = verify(rawCookie, keys);
443
+
444
+ if (payload !== null) {
445
+ const decoded = decodeSession(payload);
446
+
447
+ if (decoded !== null) {
448
+ return { session: decoded, snapshot: snapshot(decoded) };
449
+ }
450
+ }
451
+ }
452
+
453
+ const empty = /** @type {SessionData} */ (Object.create(null));
454
+
455
+ return { session: empty, snapshot: snapshot(empty) };
456
+ }
457
+ }
458
+
459
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
460
+ export default CookieSessionMiddleware;
@@ -0,0 +1,245 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Defines the Rate Limit middleware.
4
+ *
5
+ * @description Limits the number of requests a client can make within a
6
+ * fixed time window. Uses an in-memory store keyed by client IP address.
7
+ * Supports both the modern RateLimit-* headers and the legacy X-RateLimit-*
8
+ * headers.
9
+ *
10
+ * This middleware maintains rate-limit state in process memory. It is
11
+ * therefore local to the current server process and does not provide
12
+ * distributed rate limiting across multiple processes or instances.
13
+ *
14
+ * Configuration is expected to be validated and normalized before the
15
+ * middleware is created.
16
+ *
17
+ * @module RateLimitMiddleware
18
+ */
19
+
20
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21
+ /**
22
+ * Represents an HTTP exchange.
23
+ *
24
+ * @typedef {import('#Exchange').default} Exchange
25
+ */
26
+
27
+ /**
28
+ * Represents an outgoing HTTP response.
29
+ *
30
+ * @typedef {import('#Response').default} Response
31
+ */
32
+
33
+ /**
34
+ * Represents a function that delegates execution to the next middleware
35
+ * layer.
36
+ *
37
+ * @typedef {() => void | Promise<void>} NextFunction
38
+ */
39
+
40
+ /**
41
+ * Represents a single client's rate-limit record.
42
+ *
43
+ * @typedef {object} RateLimitRecord
44
+ * @property {number} count - Number of requests observed in the current window.
45
+ * @property {number} resetTime - Unix timestamp (ms) when the current window expires.
46
+ */
47
+
48
+ /**
49
+ * Represents normalized Rate Limit middleware configuration.
50
+ *
51
+ * @typedef {object} RateLimitOptionsData
52
+ * @property {number} windowMs - Duration of the rate-limit window in milliseconds.
53
+ * @property {number} max - Maximum number of requests allowed per window per client.
54
+ * @property {number} statusCode - HTTP status code returned when the limit is exceeded.
55
+ * @property {Record<string, unknown>} message - JSON body sent when the limit is exceeded.
56
+ * @property {boolean} standardHeaders - Whether to send the modern `RateLimit-*` headers.
57
+ * @property {boolean} legacyHeaders - Whether to send the legacy `X-RateLimit-*` headers.
58
+ */
59
+
60
+ // ━━ CONSTANTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
61
+
62
+ // ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
63
+ /**
64
+ * Writes the configured rate-limit headers onto the response.
65
+ *
66
+ * `RateLimit-Reset` and `X-RateLimit-Reset` contain the number of seconds
67
+ * remaining until the current window resets, not an absolute Unix timestamp.
68
+ *
69
+ * @private
70
+ * @param {Response} response - Response to which the rate-limit headers are written.
71
+ * @param {boolean} standardHeaders - Whether to send modern RateLimit headers.
72
+ * @param {boolean} legacyHeaders - Whether to send legacy X-RateLimit headers.
73
+ * @param {number} limit - Configured maximum requests.
74
+ * @param {number} remaining - Remaining requests in the current window.
75
+ * @param {number} resetTime - Unix timestamp (ms) when the window resets.
76
+ * @param {number} now - Current time in milliseconds.
77
+ */
78
+ function setRateLimitHeaders(
79
+ response,
80
+ standardHeaders,
81
+ legacyHeaders,
82
+ limit,
83
+ remaining,
84
+ resetTime,
85
+ now,
86
+ ) {
87
+ const resetDeltaSeconds = Math.max(0, Math.ceil((resetTime - now) / 1000));
88
+ const normalizedRemaining = Math.max(0, remaining);
89
+
90
+ if (standardHeaders) {
91
+ response.setHeader('RateLimit-Limit', String(limit));
92
+ response.setHeader('RateLimit-Remaining', String(normalizedRemaining));
93
+ response.setHeader('RateLimit-Reset', String(resetDeltaSeconds));
94
+ }
95
+
96
+ if (legacyHeaders) {
97
+ response.setHeader('X-RateLimit-Limit', String(limit));
98
+ response.setHeader('X-RateLimit-Remaining', String(normalizedRemaining));
99
+ response.setHeader('X-RateLimit-Reset', String(resetDeltaSeconds));
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Removes expired entries from the rate-limit store.
105
+ *
106
+ * @private
107
+ * @param {Map<string, RateLimitRecord>} store - In-memory store.
108
+ * @param {number} now - Current time in milliseconds.
109
+ */
110
+ function purgeExpired(store, now) {
111
+ for (const [key, record] of store) {
112
+ if (now >= record.resetTime) {
113
+ store.delete(key);
114
+ }
115
+ }
116
+ }
117
+
118
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
119
+ /**
120
+ * Limits the number of requests a client can make within a fixed time
121
+ * window.
122
+ *
123
+ * @classdesc Maintains an in-memory store of request counts keyed by client
124
+ * IP. When a client exceeds the configured maximum, the middleware responds
125
+ * with the configured status code and message and does not call `next()`.
126
+ *
127
+ * Rate-limit state is local to the current server process. When the request
128
+ * IP is unavailable, rate limiting is skipped and control is delegated to
129
+ * `next()`.
130
+ */
131
+ class RateLimitMiddleware {
132
+ /**
133
+ * Normalized Rate Limit middleware options.
134
+ *
135
+ * @type {Readonly<RateLimitOptionsData>}
136
+ */
137
+ #options;
138
+
139
+ /**
140
+ * In-memory store of rate-limit records keyed by client IP.
141
+ *
142
+ * @type {Map<string, RateLimitRecord>}
143
+ */
144
+ #store = new Map();
145
+
146
+ /**
147
+ * Timestamp of the last store cleanup.
148
+ *
149
+ * @type {number}
150
+ */
151
+ #lastCleanup = Date.now();
152
+
153
+ /**
154
+ * Creates a Rate Limit middleware.
155
+ *
156
+ * @param {RateLimitOptionsData} options - Validated and normalized rate limit options.
157
+ *
158
+ * @example
159
+ * ```js
160
+ * const middleware = new RateLimitMiddleware({
161
+ * windowMs: 60_000,
162
+ * max: 100,
163
+ * statusCode: 429,
164
+ * message: { error: 'Too Many Requests' },
165
+ * standardHeaders: true,
166
+ * legacyHeaders: false,
167
+ * });
168
+ * ```
169
+ */
170
+ constructor(options) {
171
+ this.#options = options;
172
+ }
173
+
174
+ /**
175
+ * Processes an HTTP exchange and enforces the configured rate limit.
176
+ *
177
+ * When the client is under the limit the middleware sets the appropriate
178
+ * rate-limit headers and calls `next()`. When the limit is exceeded it
179
+ * writes the error response, including `Retry-After`, and ends the
180
+ * pipeline.
181
+ *
182
+ * If the request has no resolvable IP address, rate limiting is skipped
183
+ * and control is passed to the next middleware.
184
+ *
185
+ * @param {Exchange} exchange - Exchange to process.
186
+ * @param {NextFunction} next - Function that delegates to the next middleware.
187
+ * @returns {Promise<void>} Resolves when request processing has completed.
188
+ *
189
+ * @example
190
+ * ```js
191
+ * await middleware.handle(exchange, next);
192
+ * ```
193
+ */
194
+ async handle(exchange, next) {
195
+ const { request, response } = exchange;
196
+ const { windowMs, max, statusCode, message, standardHeaders, legacyHeaders } = this.#options;
197
+
198
+ const ip = request.ip;
199
+
200
+ if (typeof ip !== 'string') {
201
+ await next();
202
+ } else {
203
+ const now = Date.now();
204
+ const existing = this.#store.get(ip);
205
+
206
+ const record =
207
+ existing === undefined || now >= existing.resetTime
208
+ ? { count: 0, resetTime: now + windowMs }
209
+ : existing;
210
+
211
+ record.count += 1;
212
+ this.#store.set(ip, record);
213
+
214
+ // Time-based cleanup prevents unbounded growth on low-traffic servers.
215
+ if (now - this.#lastCleanup >= windowMs) {
216
+ purgeExpired(this.#store, now);
217
+ this.#lastCleanup = now;
218
+ }
219
+
220
+ const remaining = max - record.count;
221
+
222
+ setRateLimitHeaders(
223
+ response,
224
+ standardHeaders,
225
+ legacyHeaders,
226
+ max,
227
+ remaining,
228
+ record.resetTime,
229
+ now,
230
+ );
231
+
232
+ if (record.count > max) {
233
+ const retryAfterSeconds = Math.max(1, Math.ceil((record.resetTime - now) / 1000));
234
+
235
+ response.setHeader('Retry-After', String(retryAfterSeconds));
236
+ response.status(statusCode).json(message);
237
+ } else {
238
+ await next();
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
245
+ export default RateLimitMiddleware;