@boo-labs/server 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +287 -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,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;
|