@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,628 @@
1
+ // @ts-check
2
+ /**
3
+ * @file Defines the static files middleware.
4
+ *
5
+ * @description Serves static files from a configured root directory without
6
+ * external dependencies. Supports index files, extension fallback,
7
+ * Cache-Control, ETag, Last-Modified, path-traversal protection, symlink
8
+ * confinement, dotfile filtering, request cancellation, and graceful
9
+ * fallthrough.
10
+ *
11
+ * Options are expected to be already validated and normalized by the
12
+ * corresponding Value Object. This middleware does not validate its input.
13
+ *
14
+ * @module StaticMiddleware
15
+ */
16
+
17
+ // ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18
+ // » IMPORT NODE MODULES
19
+ import { createReadStream } from 'node:fs';
20
+ import { stat, realpath } from 'node:fs/promises';
21
+ import { createHash } from 'node:crypto';
22
+ import { extname, join, relative, resolve, sep } from 'node:path';
23
+
24
+ // ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
25
+ /**
26
+ * Represents an HTTP exchange.
27
+ *
28
+ * @typedef {import('#Exchange').default} Exchange
29
+ */
30
+
31
+ /**
32
+ * Represents a function that delegates execution to the next middleware
33
+ * layer.
34
+ *
35
+ * @typedef {() => void | Promise<void>} NextFunction
36
+ */
37
+
38
+ /**
39
+ * Represents normalized static middleware configuration.
40
+ *
41
+ * All defaults have already been applied and all values have already been
42
+ * validated by the corresponding Value Object.
43
+ *
44
+ * @typedef {object} StaticOptionsData
45
+ * @property {string} root - Absolute resolved path to the static files root directory.
46
+ * @property {string[]} index - Ordered list of index file names to try when the requested path resolves to a directory.
47
+ * @property {number} maxAge - Cache-Control max-age value in seconds.
48
+ * @property {boolean} etag - Whether to generate and send an ETag header.
49
+ * @property {boolean} lastModified - Whether to generate and send a Last-Modified header.
50
+ * @property {boolean} fallthrough - Whether to delegate to the next middleware when no file is found.
51
+ * @property {string[]|undefined} extensions - Optional extensions to try when the requested pathname has no extension.
52
+ * @property {boolean} dotfiles - Whether files and directories whose path contains dot-prefixed segments may be served.
53
+ */
54
+
55
+ /**
56
+ * Represents filesystem statistics for a file.
57
+ *
58
+ * @typedef {import('node:fs').Stats} Stats
59
+ */
60
+
61
+ /**
62
+ * Represents a successfully located static file.
63
+ *
64
+ * @typedef {object} LocatedFile
65
+ * @property {string} filePath - Absolute real path of the file.
66
+ * @property {Stats} stats - File statistics obtained from the filesystem.
67
+ */
68
+
69
+ // ━━ CONSTANTS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
70
+ /**
71
+ * Maps supported file extensions to their MIME types.
72
+ *
73
+ * @type {Readonly<Record<string, string>>}
74
+ */
75
+ const MIME_TYPES = Object.freeze({
76
+ '.html': 'text/html; charset=utf-8',
77
+ '.htm': 'text/html; charset=utf-8',
78
+ '.css': 'text/css; charset=utf-8',
79
+ '.js': 'text/javascript; charset=utf-8',
80
+ '.mjs': 'text/javascript; charset=utf-8',
81
+ '.json': 'application/json; charset=utf-8',
82
+ '.txt': 'text/plain; charset=utf-8',
83
+ '.xml': 'application/xml; charset=utf-8',
84
+ '.svg': 'image/svg+xml',
85
+ '.png': 'image/png',
86
+ '.jpg': 'image/jpeg',
87
+ '.jpeg': 'image/jpeg',
88
+ '.gif': 'image/gif',
89
+ '.webp': 'image/webp',
90
+ '.ico': 'image/x-icon',
91
+ '.woff': 'font/woff',
92
+ '.woff2': 'font/woff2',
93
+ '.ttf': 'font/ttf',
94
+ '.otf': 'font/otf',
95
+ '.mp4': 'video/mp4',
96
+ '.webm': 'video/webm',
97
+ '.mp3': 'audio/mpeg',
98
+ '.wav': 'audio/wav',
99
+ '.pdf': 'application/pdf',
100
+ '.zip': 'application/zip',
101
+ '.gz': 'application/gzip',
102
+ '.wasm': 'application/wasm',
103
+ '.map': 'application/json',
104
+ });
105
+
106
+ // ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
107
+ /**
108
+ * Returns the MIME type associated with a file path.
109
+ *
110
+ * The extension lookup is case-insensitive. Unknown extensions fall back to
111
+ * `application/octet-stream`.
112
+ *
113
+ * @private
114
+ * @param {string} filePath - Absolute or relative file path.
115
+ * @returns {string} The resolved MIME type.
116
+ */
117
+ function getContentType(filePath) {
118
+ const extension = extname(filePath).toLowerCase();
119
+
120
+ return MIME_TYPES[extension] ?? 'application/octet-stream';
121
+ }
122
+
123
+ /**
124
+ * Generates a weak ETag from the file size and modification time.
125
+ *
126
+ * The generated value is intentionally weak because the representation
127
+ * identity is derived from filesystem metadata rather than from the complete
128
+ * file contents.
129
+ *
130
+ * @private
131
+ * @param {Stats} stats - File statistics.
132
+ * @returns {string} The generated weak ETag.
133
+ */
134
+ function generateETag(stats) {
135
+ const hash = createHash('sha256')
136
+ .update(`${stats.size}-${stats.mtimeMs}`)
137
+ .digest('base64url')
138
+ .slice(0, 22);
139
+
140
+ return `W/"${hash}"`;
141
+ }
142
+
143
+ /**
144
+ * Determines whether an If-None-Match header matches a generated ETag.
145
+ *
146
+ * Multiple comma-separated entity tags are supported, as is the wildcard
147
+ * value `*`. Weak comparison is used because If-None-Match permits weak
148
+ * comparison.
149
+ *
150
+ * @private
151
+ * @param {string} header - Raw If-None-Match header value.
152
+ * @param {string} tag - Generated ETag.
153
+ * @returns {boolean} Whether the header matches the generated ETag.
154
+ */
155
+ function matchesETag(header, tag) {
156
+ /**
157
+ * Normalizes an entity tag by trimming surrounding whitespace and removing
158
+ * the weak validator prefix.
159
+ *
160
+ * @param {string} value - Entity tag to normalize.
161
+ * @returns {string} The normalized entity tag.
162
+ */
163
+ const normalize = value => value.trim().replace(/^W\//i, '').trim();
164
+
165
+ if (header.trim() === '*') {
166
+ return true;
167
+ } else {
168
+ const normalizedTag = normalize(tag);
169
+
170
+ return header
171
+ .split(',')
172
+ .map(normalize)
173
+ .some(value => value === normalizedTag);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Determines whether a resource has not been modified since a given date.
179
+ *
180
+ * HTTP date comparison uses second-level precision, so the resource
181
+ * modification time is truncated to whole seconds before comparison.
182
+ *
183
+ * @private
184
+ * @param {string} header - Raw If-Modified-Since header value.
185
+ * @param {number} mtimeMs - Resource modification time in milliseconds.
186
+ * @returns {boolean} Whether the resource has not been modified.
187
+ */
188
+ function isNotModifiedSince(header, mtimeMs) {
189
+ const modifiedSince = Date.parse(header);
190
+
191
+ if (Number.isNaN(modifiedSince)) {
192
+ return false;
193
+ } else {
194
+ const modifiedAt = Math.floor(mtimeMs / 1000) * 1000;
195
+
196
+ return modifiedAt <= modifiedSince;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Resolves a request pathname against the configured root directory.
202
+ *
203
+ * The returned path is rejected when lexical path resolution would escape the
204
+ * configured root. Physical symlink confinement is performed separately.
205
+ *
206
+ * @private
207
+ * @param {string} root - Absolute configured root directory.
208
+ * @param {string} requestPath - Request pathname.
209
+ * @returns {string|null} The resolved candidate path, or `null` when it escapes the root.
210
+ */
211
+ function safeResolve(root, requestPath) {
212
+ const relativePath = requestPath.replace(/^\/+/, '');
213
+ const resolved = resolve(root, relativePath);
214
+
215
+ if (!resolved.startsWith(root + sep) && resolved !== root) {
216
+ return null;
217
+ } else {
218
+ return resolved;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Determines whether a path contains a dot-prefixed segment relative to the
224
+ * configured root.
225
+ *
226
+ * The root itself is not considered a dotfile path.
227
+ *
228
+ * @private
229
+ * @param {string} root - Absolute configured root directory.
230
+ * @param {string} resolvedPath - Absolute candidate path.
231
+ * @returns {boolean} Whether the path contains a dot-prefixed segment.
232
+ */
233
+ function hasDotSegment(root, resolvedPath) {
234
+ const relativePath = relative(root, resolvedPath);
235
+
236
+ if (relativePath === '') {
237
+ return false;
238
+ } else {
239
+ return relativePath
240
+ .split(sep)
241
+ .some(segment => segment.startsWith('.') && segment !== '.' && segment !== '..');
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Reads filesystem statistics for a path.
247
+ *
248
+ * Only ENOENT and ENOTDIR are interpreted as a missing resource. Other
249
+ * filesystem errors are propagated to the Kernel.
250
+ *
251
+ * @private
252
+ * @param {string} filePath - Absolute file path.
253
+ * @returns {Promise<Stats|null>} The file statistics, or `null` when the path does not exist.
254
+ */
255
+ async function safeStat(filePath) {
256
+ try {
257
+ return await stat(filePath);
258
+ } catch (error) {
259
+ if (
260
+ error !== null &&
261
+ typeof error === 'object' &&
262
+ 'code' in error &&
263
+ (error.code === 'ENOENT' || error.code === 'ENOTDIR')
264
+ ) {
265
+ return null;
266
+ } else {
267
+ throw error;
268
+ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Resolves the physical filesystem path of a candidate.
274
+ *
275
+ * Only ENOENT and ENOTDIR are interpreted as a missing resource. Other
276
+ * filesystem errors are propagated to the Kernel.
277
+ *
278
+ * @private
279
+ * @param {string} filePath - Absolute candidate path.
280
+ * @returns {Promise<string|null>} The real path, or `null` when the path does not exist.
281
+ */
282
+ async function safeRealpath(filePath) {
283
+ try {
284
+ return await realpath(filePath);
285
+ } catch (error) {
286
+ if (
287
+ error !== null &&
288
+ typeof error === 'object' &&
289
+ 'code' in error &&
290
+ (error.code === 'ENOENT' || error.code === 'ENOTDIR')
291
+ ) {
292
+ return null;
293
+ } else {
294
+ throw error;
295
+ }
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Resolves and verifies a candidate against the physical static root.
301
+ *
302
+ * The candidate is rejected when its real filesystem path escapes the real
303
+ * root directory. This prevents symlinks inside the configured root from
304
+ * exposing files outside that root.
305
+ *
306
+ * @private
307
+ * @param {string} realRoot - Physical root directory.
308
+ * @param {string} candidate - Absolute lexical candidate path.
309
+ * @returns {Promise<string|null>} The confined real path, or `null` when the candidate is unavailable or escapes the root.
310
+ */
311
+ async function confineToRoot(realRoot, candidate) {
312
+ const realCandidate = await safeRealpath(candidate);
313
+
314
+ if (realCandidate === null) {
315
+ return null;
316
+ } else if (!realCandidate.startsWith(realRoot + sep) && realCandidate !== realRoot) {
317
+ return null;
318
+ } else {
319
+ return realCandidate;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Locates a static file for a request pathname.
325
+ *
326
+ * Resolution proceeds in the following order:
327
+ *
328
+ * 1. Resolve the requested pathname against the configured root.
329
+ * 2. Reject dotfile segments when dotfiles are disabled.
330
+ * 3. Resolve the candidate through the real filesystem root.
331
+ * 4. Serve the candidate when it is a regular file.
332
+ * 5. Try configured index files when the candidate is a directory.
333
+ * 6. Try configured extensions when the request has no extension.
334
+ *
335
+ * Every candidate is physically confined to the real root before it can be
336
+ * served.
337
+ *
338
+ * @private
339
+ * @param {string} realRoot - Physical root directory.
340
+ * @param {string} root - Absolute configured root directory.
341
+ * @param {string} pathname - Request pathname.
342
+ * @param {string[]} indexes - Ordered index file names.
343
+ * @param {string[]|undefined} extensions - Optional extensions to try.
344
+ * @param {boolean} allowDotfiles - Whether dotfile paths may be served.
345
+ * @returns {Promise<LocatedFile|null>} The located file, or `null` when no file can be served.
346
+ */
347
+ async function locateFile(realRoot, root, pathname, indexes, extensions, allowDotfiles) {
348
+ const candidate = safeResolve(root, pathname);
349
+
350
+ if (candidate === null) {
351
+ return null;
352
+ } else if (!allowDotfiles && hasDotSegment(root, candidate)) {
353
+ return null;
354
+ } else {
355
+ const realCandidate = await confineToRoot(realRoot, candidate);
356
+
357
+ if (realCandidate !== null) {
358
+ const stats = await safeStat(realCandidate);
359
+
360
+ if (stats !== null && stats.isFile()) {
361
+ return { filePath: realCandidate, stats };
362
+ } else if (stats !== null && stats.isDirectory()) {
363
+ for (const index of indexes) {
364
+ const indexPath = join(realCandidate, index);
365
+
366
+ if (!allowDotfiles && hasDotSegment(realRoot, indexPath)) {
367
+ continue;
368
+ }
369
+
370
+ const realIndexPath = await confineToRoot(realRoot, indexPath);
371
+
372
+ if (realIndexPath === null) {
373
+ continue;
374
+ }
375
+
376
+ const indexStats = await safeStat(realIndexPath);
377
+
378
+ if (indexStats !== null && indexStats.isFile()) {
379
+ return { filePath: realIndexPath, stats: indexStats };
380
+ }
381
+ }
382
+ }
383
+ }
384
+
385
+ if (extensions !== undefined && extensions.length > 0 && extname(pathname) === '') {
386
+ for (const extension of extensions) {
387
+ const normalizedExtension = extension.startsWith('.') ? extension : `.${extension}`;
388
+ const withExtension = safeResolve(root, `${pathname}${normalizedExtension}`);
389
+
390
+ if (withExtension === null) {
391
+ continue;
392
+ }
393
+
394
+ if (!allowDotfiles && hasDotSegment(root, withExtension)) {
395
+ continue;
396
+ }
397
+
398
+ const realWithExtension = await confineToRoot(realRoot, withExtension);
399
+
400
+ if (realWithExtension === null) {
401
+ continue;
402
+ }
403
+
404
+ const extensionStats = await safeStat(realWithExtension);
405
+
406
+ if (extensionStats !== null && extensionStats.isFile()) {
407
+ return {
408
+ filePath: realWithExtension,
409
+ stats: extensionStats,
410
+ };
411
+ }
412
+ }
413
+ }
414
+
415
+ return null;
416
+ }
417
+ }
418
+
419
+ // ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
420
+ /**
421
+ * Serves static files from a configured filesystem root.
422
+ *
423
+ * The middleware supports conditional requests, cache headers, index files,
424
+ * extension fallback, dotfile filtering, path-traversal protection, physical
425
+ * symlink confinement, request cancellation, and configurable fallthrough.
426
+ *
427
+ * The received options must already be validated and normalized by the
428
+ * corresponding Value Object. The middleware does not perform configuration
429
+ * validation.
430
+ *
431
+ * @class
432
+ *
433
+ * @example
434
+ * const middleware = new StaticMiddleware({
435
+ * root: '/var/www/public',
436
+ * index: ['index.html'],
437
+ * maxAge: 3600,
438
+ * etag: true,
439
+ * lastModified: true,
440
+ * fallthrough: true,
441
+ * extensions: ['html'],
442
+ * dotfiles: false,
443
+ * });
444
+ */
445
+ class StaticMiddleware {
446
+ /**
447
+ * Normalized static middleware options.
448
+ *
449
+ * @type {Readonly<StaticOptionsData>}
450
+ */
451
+ #options;
452
+
453
+ /**
454
+ * Cached promise for the physical root directory.
455
+ *
456
+ * The promise is cached rather than only its resolved value so concurrent
457
+ * requests share the same filesystem resolution.
458
+ *
459
+ * @type {Promise<string>|null}
460
+ */
461
+ #realRoot = null;
462
+
463
+ /**
464
+ * Creates a static files middleware.
465
+ *
466
+ * @param {StaticOptionsData} options - Validated and normalized static options.
467
+ *
468
+ * @example
469
+ * const middleware = new StaticMiddleware({
470
+ * root: '/var/www/public',
471
+ * index: ['index.html'],
472
+ * maxAge: 3600,
473
+ * etag: true,
474
+ * lastModified: true,
475
+ * fallthrough: true,
476
+ * extensions: ['html'],
477
+ * dotfiles: false,
478
+ * });
479
+ */
480
+ constructor(options) {
481
+ this.#options = options;
482
+ }
483
+
484
+ /**
485
+ * Returns the physical filesystem path of the configured root.
486
+ *
487
+ * The resolution is performed at most once for the lifetime of the
488
+ * middleware instance. Concurrent requests share the same promise.
489
+ *
490
+ * @returns {Promise<string>} The physical root directory.
491
+ */
492
+ async #getRealRoot() {
493
+ if (this.#realRoot === null) {
494
+ this.#realRoot = realpath(this.#options.root);
495
+ }
496
+
497
+ return this.#realRoot;
498
+ }
499
+
500
+ /**
501
+ * Processes an HTTP exchange and serves a matching static file.
502
+ *
503
+ * GET requests are streamed from the filesystem. HEAD requests receive the
504
+ * same representation metadata without a response body. Requests for
505
+ * unsupported methods are delegated to the next middleware. When no file is
506
+ * found, behavior depends on the configured `fallthrough` option.
507
+ *
508
+ * Conditional requests are evaluated using ETag first and
509
+ * Last-Modified second. When an If-None-Match header is present, the
510
+ * If-Modified-Since validator is not evaluated.
511
+ *
512
+ * @param {Exchange} exchange - Exchange to process.
513
+ * @param {NextFunction} next - Function that delegates to the next middleware.
514
+ * @returns {Promise<void>} Resolves when request processing has completed.
515
+ *
516
+ * @example
517
+ * await middleware.handle(exchange, next);
518
+ */
519
+ async handle(exchange, next) {
520
+ const { request, response, signal } = exchange;
521
+
522
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
523
+ await next();
524
+ } else if (!signal.aborted) {
525
+ const { root, index, maxAge, etag, lastModified, fallthrough, extensions, dotfiles } =
526
+ this.#options;
527
+
528
+ const realRoot = await this.#getRealRoot();
529
+
530
+ const located = await locateFile(
531
+ realRoot,
532
+ root,
533
+ request.pathname,
534
+ index,
535
+ extensions,
536
+ dotfiles,
537
+ );
538
+
539
+ if (located === null) {
540
+ if (fallthrough) {
541
+ await next();
542
+ } else {
543
+ response.status(404).json({ error: 'Not Found' });
544
+ }
545
+ } else {
546
+ const { filePath, stats } = located;
547
+
548
+ // ── Representation headers ───────────────────────────────────────
549
+ const tag = etag ? generateETag(stats) : null;
550
+
551
+ if (tag !== null) {
552
+ response.setHeader('ETag', tag);
553
+ }
554
+
555
+ if (lastModified) {
556
+ response.setHeader('Last-Modified', new Date(stats.mtimeMs).toUTCString());
557
+ }
558
+
559
+ if (maxAge > 0) {
560
+ response.cache(maxAge);
561
+ } else {
562
+ response.noCache();
563
+ }
564
+
565
+ // ── Conditional requests ─────────────────────────────────────────
566
+ const ifNoneMatch = request.getHeader('if-none-match');
567
+ const ifModifiedSince = request.getHeader('if-modified-since');
568
+
569
+ const ifNoneMatchValue = Array.isArray(ifNoneMatch) ? ifNoneMatch[0] : ifNoneMatch;
570
+
571
+ const ifModifiedSinceValue = Array.isArray(ifModifiedSince)
572
+ ? ifModifiedSince[0]
573
+ : ifModifiedSince;
574
+
575
+ const etagMatches =
576
+ typeof ifNoneMatchValue === 'string' && tag !== null
577
+ ? matchesETag(ifNoneMatchValue, tag)
578
+ : false;
579
+
580
+ const shouldCheckModifiedSince =
581
+ !etagMatches &&
582
+ lastModified &&
583
+ ifNoneMatchValue === undefined &&
584
+ typeof ifModifiedSinceValue === 'string';
585
+
586
+ const modifiedSinceMatches = shouldCheckModifiedSince
587
+ ? isNotModifiedSince(ifModifiedSinceValue, stats.mtimeMs)
588
+ : false;
589
+
590
+ if (etagMatches || modifiedSinceMatches) {
591
+ response.status(304).end();
592
+ } else {
593
+ // ── Content headers ───────────────────────────────────────────
594
+ response.setHeader('Content-Type', getContentType(filePath));
595
+ response.setHeader('Content-Length', String(stats.size));
596
+
597
+ // ── HEAD ──────────────────────────────────────────────────────
598
+ if (request.method === 'HEAD') {
599
+ response.status(200).end();
600
+ } else {
601
+ // ── Stream ──────────────────────────────────────────────────
602
+ const stream = createReadStream(filePath);
603
+
604
+ const onAbort = () => {
605
+ stream.destroy();
606
+ };
607
+
608
+ signal.addEventListener('abort', onAbort, { once: true });
609
+
610
+ if (signal.aborted) {
611
+ signal.removeEventListener('abort', onAbort);
612
+ stream.destroy();
613
+ } else {
614
+ stream.on('close', () => {
615
+ signal.removeEventListener('abort', onAbort);
616
+ });
617
+
618
+ response.status(200).stream(stream);
619
+ }
620
+ }
621
+ }
622
+ }
623
+ }
624
+ }
625
+ }
626
+
627
+ // ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
628
+ export default StaticMiddleware;