expediate 1.0.5 → 1.0.6
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/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/README.md +278 -779
- package/dist/apis.d.ts +372 -12
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +483 -65
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +2290 -807
- package/dist/git.d.ts +1 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +5 -5
- package/dist/git.js.map +1 -1
- package/dist/http-objects.d.ts +26 -0
- package/dist/http-objects.d.ts.map +1 -0
- package/dist/http-objects.js +588 -0
- package/dist/http-objects.js.map +1 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +11 -0
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +9 -9
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.js +2 -2
- package/dist/middleware.js.map +1 -1
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +161 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +228 -80
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +156 -13
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +214 -71
- package/dist/openapi.js.map +1 -1
- package/dist/router-types.d.ts +760 -0
- package/dist/router-types.d.ts.map +1 -0
- package/dist/router-types.js +23 -0
- package/dist/router-types.js.map +1 -0
- package/dist/router.d.ts +7 -530
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +128 -375
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +2 -2
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +77 -22
- package/dist/static.js.map +1 -1
- package/docs/THREAT_MODEL.md +52 -0
- package/docs/api-builder-v2-design.md +644 -0
- package/docs/api-builder-v3-design.md +397 -0
- package/docs/api-builder.md +454 -0
- package/docs/benchmark.md +27 -0
- package/docs/body-parsing.md +223 -0
- package/docs/errors.md +359 -0
- package/docs/expediate.png +0 -0
- package/docs/git.md +139 -0
- package/docs/jwt-auth.md +251 -0
- package/docs/logo.svg +12 -0
- package/docs/middleware.md +264 -0
- package/docs/openapi.md +180 -0
- package/docs/router.md +356 -0
- package/docs/static.md +128 -0
- package/docs/wiki.json +123 -0
- package/package.json +30 -8
- package/dist/cjs/apis.js +0 -327
- package/dist/cjs/git.js +0 -293
- package/dist/cjs/jwt-auth.js +0 -532
- package/dist/cjs/middleware.js +0 -511
- package/dist/cjs/mimetypes.json +0 -1
- package/dist/cjs/misc.js +0 -787
- package/dist/cjs/openapi.js +0 -485
- package/dist/cjs/router.js +0 -898
- package/dist/cjs/static.js +0 -669
package/dist/cjs/static.js
DELETED
|
@@ -1,669 +0,0 @@
|
|
|
1
|
-
/* Copyright 2021 Fabien Bavent
|
|
2
|
-
*
|
|
3
|
-
* Permission is hereby granted, free of charge, to any person obtaining a
|
|
4
|
-
* copy of this software and associated documentation files (the "Software"),
|
|
5
|
-
* to deal in the Software without restriction, including without limitation
|
|
6
|
-
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
7
|
-
* and/or sell copies of the Software, and to permit persons to whom the
|
|
8
|
-
* Software is furnished to do so, subject to the following conditions:
|
|
9
|
-
*
|
|
10
|
-
* The above copyright notice and this permission notice shall be included
|
|
11
|
-
* in all copies or substantial portions of the Software.
|
|
12
|
-
*
|
|
13
|
-
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
14
|
-
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
-
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
16
|
-
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
-
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
18
|
-
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
19
|
-
* DEALINGS IN THE SOFTWARE.
|
|
20
|
-
*/
|
|
21
|
-
'use strict';
|
|
22
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
-
exports.mime = void 0;
|
|
24
|
-
exports.sendFile = sendFile;
|
|
25
|
-
exports.serveStatic = serveStatic;
|
|
26
|
-
exports.serveFile = serveFile;
|
|
27
|
-
const fs_1 = require("fs");
|
|
28
|
-
const path_1 = require("path");
|
|
29
|
-
const mimetypes_json_1 = require("./mimetypes.json");
|
|
30
|
-
const mime_types = new Map();
|
|
31
|
-
const mime_extensions = new Map();
|
|
32
|
-
function mime_define(map) {
|
|
33
|
-
for (var type in map) {
|
|
34
|
-
var exts = map[type];
|
|
35
|
-
for (var i = 0; i < exts.length; i++)
|
|
36
|
-
mime_types.set(exts[i], type);
|
|
37
|
-
if (!mime_extensions.has(type))
|
|
38
|
-
mime_extensions.set(type, exts[0]);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
;
|
|
42
|
-
exports.mime = {
|
|
43
|
-
lookup: (path, fallback = null) => mime_types.get(path.replace(/^.*[\.\/\\]/, '').toLowerCase()) ?? fallback ?? 'application/octet-stream',
|
|
44
|
-
charsets: (mimeType) => (/^text\/|^application\/(javascript|json)/).test(mimeType) ? 'UTF-8' : null,
|
|
45
|
-
};
|
|
46
|
-
mime_define(mimetypes_json_1.default);
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
// Constants
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
/**
|
|
51
|
-
* Regular expression that detects a directory-traversal component (`..`) in
|
|
52
|
-
* any position of a URL path, including encoded forms using back-slashes.
|
|
53
|
-
*/
|
|
54
|
-
const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
|
|
55
|
-
/** Default security and caching options applied to every response. */
|
|
56
|
-
const DEFAULT_OPTIONS = {
|
|
57
|
-
headers: {
|
|
58
|
-
'Content-Security-Policy': "default-src 'self'",
|
|
59
|
-
'X-Content-Type-Options': 'nosniff',
|
|
60
|
-
},
|
|
61
|
-
fallthrough: false,
|
|
62
|
-
maxage: 0,
|
|
63
|
-
immutable: false,
|
|
64
|
-
etag: true,
|
|
65
|
-
lastModified: true,
|
|
66
|
-
contentType: null,
|
|
67
|
-
dotfiles: 'hide',
|
|
68
|
-
redirect: true,
|
|
69
|
-
indexOf: false,
|
|
70
|
-
};
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
// HTTP response helpers
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
/**
|
|
75
|
-
* Collection of pre-built HTTP error/success response helpers.
|
|
76
|
-
* Each helper sets the appropriate status code, merges the caller's custom
|
|
77
|
-
* headers, and terminates the response.
|
|
78
|
-
*/
|
|
79
|
-
const HTTP = {
|
|
80
|
-
/** 304 Not Modified — sent for conditional GET cache hits. */
|
|
81
|
-
NOT_MODIFIED: (res, opts) => { res.status(304, opts.headers).end(); },
|
|
82
|
-
/** 403 Forbidden — sent for denied dot-files or path traversal attempts. */
|
|
83
|
-
FORBIDDEN: (res, opts) => res.status(403, opts.headers).send('Forbidden'),
|
|
84
|
-
/** 404 Not Found — sent when the requested file does not exist. */
|
|
85
|
-
NOT_FOUND: (res, opts) => res.status(404, opts.headers).send('Not Found'),
|
|
86
|
-
/**
|
|
87
|
-
* 405 Method Not Allowed — sent when the HTTP method is neither GET nor
|
|
88
|
-
* HEAD and {@link ResolvedOptions.fallthrough} is `false`.
|
|
89
|
-
* Always includes an `Allow: GET, HEAD` header per RFC 7231 §6.5.5.
|
|
90
|
-
*/
|
|
91
|
-
NOT_ALLOWED: (res, opts) => { res.status(405, { ...opts.headers, Allow: 'GET, HEAD' }).end(); },
|
|
92
|
-
/** 412 Precondition Failed — sent when `If-Match` / `If-Unmodified-Since` fails. */
|
|
93
|
-
PRECONDITION_FAILS: (res, opts) => res.status(412, opts.headers).send('Precondition Failed'),
|
|
94
|
-
/** 500 Internal Server Error — sent on unexpected filesystem or stream errors. */
|
|
95
|
-
INTERNAL_ERROR: (res, opts, err) => res.status(500, opts.headers).send(`Internal error: ${err}`),
|
|
96
|
-
};
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
// Internal utilities
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
/**
|
|
101
|
-
* Safely destroy a `fs.ReadStream`, working around a Node.js core bug where
|
|
102
|
-
* streams that have not yet emitted `'open'` do not close their file
|
|
103
|
-
* descriptor when `destroy()` is called.
|
|
104
|
-
*
|
|
105
|
-
* @param stream - The readable stream to destroy.
|
|
106
|
-
*/
|
|
107
|
-
function destroyReadStream(stream) {
|
|
108
|
-
stream.destroy();
|
|
109
|
-
if (typeof stream.close === 'function') {
|
|
110
|
-
// Node.js core bug work-around: if the stream has not yet opened the file,
|
|
111
|
-
// `destroy()` will not close the fd. Listening for 'open' ensures we close
|
|
112
|
-
// it as soon as the fd becomes available.
|
|
113
|
-
stream.on('open', () => {
|
|
114
|
-
if (typeof stream.fd === 'number')
|
|
115
|
-
stream.close();
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Remove all `Content-*` headers from the response, **except**
|
|
121
|
-
* `Content-Location`.
|
|
122
|
-
*
|
|
123
|
-
* Called before sending a 304 Not Modified response, as RFC 7232 §4.1
|
|
124
|
-
* requires that the response body and most content metadata headers be
|
|
125
|
-
* omitted in that case.
|
|
126
|
-
*
|
|
127
|
-
* @param res - The server response whose headers will be mutated.
|
|
128
|
-
*/
|
|
129
|
-
function removeContentHeaders(res) {
|
|
130
|
-
const keys = Object.keys(res.getHeaders() ?? {});
|
|
131
|
-
for (const key of keys) {
|
|
132
|
-
if (key.startsWith('content-') && key !== 'content-location')
|
|
133
|
-
res.removeHeader(key);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
/**
|
|
137
|
-
* Build a weak ETag string from a file's `stat` metadata.
|
|
138
|
-
*
|
|
139
|
-
* The format is `W/"<size_hex>-<mtime_hex>"`, matching the convention used by
|
|
140
|
-
* Node's `serve-static` and compatible with all major HTTP clients.
|
|
141
|
-
*
|
|
142
|
-
* @param stat - The `fs.Stats` object for the file.
|
|
143
|
-
* @returns A weak ETag string.
|
|
144
|
-
*/
|
|
145
|
-
function createETag(stat) {
|
|
146
|
-
const mtime = stat.mtime.getTime().toString(16);
|
|
147
|
-
const size = stat.size.toString(16);
|
|
148
|
-
return `W/"${size}-${mtime}"`;
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Parse a comma-separated HTTP token list (e.g. the value of an `ETag` or
|
|
152
|
-
* `Accept` header) into an array of trimmed token strings.
|
|
153
|
-
*
|
|
154
|
-
* Follows the token-list grammar from RFC 7230 §3.2.6: tokens are separated
|
|
155
|
-
* by commas, leading/trailing spaces around each token are ignored.
|
|
156
|
-
*
|
|
157
|
-
* @param str - The raw header value string.
|
|
158
|
-
* @returns An array of individual token strings (may be empty).
|
|
159
|
-
*/
|
|
160
|
-
function parseTokenList(str) {
|
|
161
|
-
const list = [];
|
|
162
|
-
let start = 0;
|
|
163
|
-
let end = 0;
|
|
164
|
-
for (let i = 0, len = str.length; i < len; i++) {
|
|
165
|
-
const ch = str.charCodeAt(i);
|
|
166
|
-
if (ch === 0x20 /* space */) {
|
|
167
|
-
if (start === end)
|
|
168
|
-
start = end = i + 1;
|
|
169
|
-
}
|
|
170
|
-
else if (ch === 0x2c /* comma */) {
|
|
171
|
-
list.push(str.substring(start, end));
|
|
172
|
-
start = end = i + 1;
|
|
173
|
-
}
|
|
174
|
-
else {
|
|
175
|
-
end = i + 1;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// Push the final token (there is always at least one).
|
|
179
|
-
list.push(str.substring(start, end));
|
|
180
|
-
return list;
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Parse an HTTP-date string (e.g. `Thu, 01 Jan 1970 00:00:00 GMT`) into a
|
|
184
|
-
* Unix timestamp in milliseconds.
|
|
185
|
-
*
|
|
186
|
-
* @param date - The raw date string from an HTTP header.
|
|
187
|
-
* @returns A numeric timestamp, or `NaN` if parsing fails.
|
|
188
|
-
*/
|
|
189
|
-
function parseHttpDate(date) {
|
|
190
|
-
const timestamp = date ? Date.parse(date) : NaN;
|
|
191
|
-
return typeof timestamp === 'number' ? timestamp : NaN;
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Return `true` when the request carries at least one conditional header
|
|
195
|
-
* (`If-Match`, `If-Unmodified-Since`, `If-None-Match`, or
|
|
196
|
-
* `If-Modified-Since`).
|
|
197
|
-
*
|
|
198
|
-
* @param headers - The incoming request's header map.
|
|
199
|
-
*/
|
|
200
|
-
function hasCondition(headers) {
|
|
201
|
-
return !!(headers['if-match'] ||
|
|
202
|
-
headers['if-unmodified-since'] ||
|
|
203
|
-
headers['if-none-match'] ||
|
|
204
|
-
headers['if-modified-since']);
|
|
205
|
-
}
|
|
206
|
-
/**
|
|
207
|
-
* Evaluate precondition headers (`If-Match` / `If-Unmodified-Since`) against
|
|
208
|
-
* the current response headers and return whether the precondition is
|
|
209
|
-
* satisfied.
|
|
210
|
-
*
|
|
211
|
-
* Used to decide whether to proceed with the response (`true`) or send
|
|
212
|
-
* 412 Precondition Failed (`false`).
|
|
213
|
-
*
|
|
214
|
-
* Implements the subset defined in RFC 7232 §6 (step 3 and step 5):
|
|
215
|
-
* - **`If-Match`** — matches when the response ETag equals the request ETag,
|
|
216
|
-
* or when the request value is `*`.
|
|
217
|
-
* - **`If-Unmodified-Since`** — matches when the file has not been modified
|
|
218
|
-
* since the given date.
|
|
219
|
-
*
|
|
220
|
-
* @param reqHeaders - Normalised (lowercase) request headers.
|
|
221
|
-
* @param resHeaders - Current response headers (as returned by
|
|
222
|
-
* `res.getHeaders()`).
|
|
223
|
-
* @returns `true` if the precondition is satisfied.
|
|
224
|
-
*/
|
|
225
|
-
function conditionMatch(reqHeaders, resHeaders) {
|
|
226
|
-
// --- If-Match ---
|
|
227
|
-
const match = reqHeaders['if-match'];
|
|
228
|
-
if (match) {
|
|
229
|
-
const etag = resHeaders['etag'];
|
|
230
|
-
if (match === '*' || match === etag)
|
|
231
|
-
return true;
|
|
232
|
-
for (const tag of parseTokenList(match)) {
|
|
233
|
-
if (tag === etag || `W/${tag}` === etag || tag === `W/${etag}`)
|
|
234
|
-
return true;
|
|
235
|
-
}
|
|
236
|
-
// If-Match was present but none of the tags matched → precondition failed.
|
|
237
|
-
return false;
|
|
238
|
-
}
|
|
239
|
-
// --- If-Unmodified-Since ---
|
|
240
|
-
const lastModified = parseHttpDate(resHeaders['last-modified']);
|
|
241
|
-
const unmodifiedSince = parseHttpDate(reqHeaders['if-unmodified-since']);
|
|
242
|
-
if (!isNaN(unmodifiedSince) && !isNaN(lastModified))
|
|
243
|
-
return lastModified <= unmodifiedSince;
|
|
244
|
-
return false;
|
|
245
|
-
}
|
|
246
|
-
/**
|
|
247
|
-
* Determine whether a cached response is still fresh by evaluating the
|
|
248
|
-
* `If-None-Match` and `If-Modified-Since` conditional headers.
|
|
249
|
-
*
|
|
250
|
-
* Returns `true` only when the response can safely be replaced by a
|
|
251
|
-
* 304 Not Modified. Returns `false` (stale) when:
|
|
252
|
-
* - No conditional header is present (unconditional request).
|
|
253
|
-
* - `Cache-Control: no-cache` is set.
|
|
254
|
-
* - The ETag or modification time no longer matches.
|
|
255
|
-
*
|
|
256
|
-
* Implements RFC 7232 §6 (steps 4 and 5) and RFC 7234 §5.2.
|
|
257
|
-
*
|
|
258
|
-
* @param reqHeaders - Normalised (lowercase) request headers.
|
|
259
|
-
* @param resHeaders - Current response headers.
|
|
260
|
-
* @returns `true` if the cached response is fresh and a 304 should be sent.
|
|
261
|
-
*/
|
|
262
|
-
function isCacheFresh(reqHeaders, resHeaders) {
|
|
263
|
-
const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/;
|
|
264
|
-
const modifiedSince = reqHeaders['if-modified-since'];
|
|
265
|
-
const noneMatch = reqHeaders['if-none-match'];
|
|
266
|
-
// Unconditional request — always treat as stale.
|
|
267
|
-
if (!modifiedSince && !noneMatch)
|
|
268
|
-
return false;
|
|
269
|
-
// Explicit no-cache directive forces revalidation even if ETags match.
|
|
270
|
-
const cacheControl = reqHeaders['cache-control'];
|
|
271
|
-
if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl))
|
|
272
|
-
return false;
|
|
273
|
-
// --- If-None-Match ---
|
|
274
|
-
if (noneMatch && noneMatch !== '*') {
|
|
275
|
-
const etag = resHeaders['etag'];
|
|
276
|
-
if (!etag)
|
|
277
|
-
return false;
|
|
278
|
-
let etagStale = true;
|
|
279
|
-
for (const match of parseTokenList(noneMatch)) {
|
|
280
|
-
if (match === etag || `W/${match}` === etag || match === `W/${etag}`) {
|
|
281
|
-
etagStale = false;
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
if (etagStale)
|
|
286
|
-
return false;
|
|
287
|
-
}
|
|
288
|
-
// --- If-Modified-Since ---
|
|
289
|
-
if (modifiedSince) {
|
|
290
|
-
const lastModified = resHeaders['last-modified'];
|
|
291
|
-
if (!lastModified)
|
|
292
|
-
return false;
|
|
293
|
-
if (!(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince)))
|
|
294
|
-
return false;
|
|
295
|
-
}
|
|
296
|
-
return true;
|
|
297
|
-
}
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
// Options resolution
|
|
300
|
-
// ---------------------------------------------------------------------------
|
|
301
|
-
/**
|
|
302
|
-
* Validate and resolve the `root` path and caller-supplied `options` into a
|
|
303
|
-
* fully populated {@link ResolvedOptions} object.
|
|
304
|
-
*
|
|
305
|
-
* Applies the following normalisation steps:
|
|
306
|
-
* - `root` is resolved to an absolute path via `path.resolve()`.
|
|
307
|
-
* - `fallthrough` and `redirect` default to `false` only when explicitly set
|
|
308
|
-
* to `false`; any other value (including `undefined`) is treated as `true`.
|
|
309
|
-
* - `maxage` falls back to `maxAge` (camelCase alias) and then to `0`.
|
|
310
|
-
*
|
|
311
|
-
* @param root - The root directory or file path to serve from.
|
|
312
|
-
* @param options - Caller-supplied options (partial, merged with defaults).
|
|
313
|
-
* @returns A fully resolved options object.
|
|
314
|
-
* @throws {TypeError} When `root` is falsy or not a string.
|
|
315
|
-
*/
|
|
316
|
-
function resolveOptions(root, options) {
|
|
317
|
-
if (!root)
|
|
318
|
-
throw new TypeError('root path required');
|
|
319
|
-
if (typeof root !== 'string')
|
|
320
|
-
throw new TypeError('root path must be a string');
|
|
321
|
-
// Deep-merge headers so caller-supplied headers *extend* the defaults
|
|
322
|
-
// rather than replacing them entirely.
|
|
323
|
-
const mergedHeaders = { ...DEFAULT_OPTIONS.headers, ...(options?.headers ?? {}) };
|
|
324
|
-
const opts = { ...DEFAULT_OPTIONS, ...options, headers: mergedHeaders };
|
|
325
|
-
opts.fallthrough = options?.fallthrough !== false;
|
|
326
|
-
opts.redirect = options?.redirect !== false;
|
|
327
|
-
opts.maxage = options?.maxage ?? options?.maxAge ?? 0;
|
|
328
|
-
opts.root = path_1.default.resolve(root);
|
|
329
|
-
return opts;
|
|
330
|
-
}
|
|
331
|
-
/**
|
|
332
|
-
* Asynchronously render an Apache-style directory-listing HTML page for the
|
|
333
|
-
* given directory and invoke `callback` with the result.
|
|
334
|
-
*
|
|
335
|
-
* The generated page lists every entry in `directoryPath`, with icons, names,
|
|
336
|
-
* last-modification timestamps, and file sizes. An optional parent-directory
|
|
337
|
-
* link is shown when `parentUrlPath` is provided.
|
|
338
|
-
*
|
|
339
|
-
* > **TODO** — Implement column sorting via `?C=N`, `?C=M`, `?C=S`, `?C=D`
|
|
340
|
-
* > (name / modified / size / description) combined with `?O=A` / `?O=D`
|
|
341
|
-
* > (ascending / descending). The current implementation always sorts by name
|
|
342
|
-
* > ascending.
|
|
343
|
-
*
|
|
344
|
-
* @param urlPath - The URL path displayed in the page title and heading.
|
|
345
|
-
* @param directoryPath - Absolute filesystem path of the directory to list.
|
|
346
|
-
* @param parentUrlPath - URL of the parent directory, or `null` when at root.
|
|
347
|
-
* @param callback - Called with `(html, null)` on success or
|
|
348
|
-
* `(null, err)` on failure.
|
|
349
|
-
*/
|
|
350
|
-
function writeIndexOf(urlPath, directoryPath, parentUrlPath, callback) {
|
|
351
|
-
fs_1.default.readdir(directoryPath, (err, files) => {
|
|
352
|
-
if (err)
|
|
353
|
-
return callback(null, err);
|
|
354
|
-
let html = '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">\n';
|
|
355
|
-
html += '<html>\n';
|
|
356
|
-
html += `<head><title>Index of ${urlPath}</title></head>\n`;
|
|
357
|
-
html += `<body><h1>Index of ${urlPath}</h1><table>\n`;
|
|
358
|
-
html += '<tr>'
|
|
359
|
-
+ '<th valign="top"><img src="/icons/blank.gif" alt="[ICO]"></th>'
|
|
360
|
-
+ '<th><a href="?C=N;O=D">Name</a></th>'
|
|
361
|
-
+ '<th><a href="?C=M;O=A">Last modified</a></th>'
|
|
362
|
-
+ '<th><a href="?C=S;O=A">Size</a></th>'
|
|
363
|
-
+ '<th><a href="?C=D;O=A">Description</a></th>'
|
|
364
|
-
+ '</tr>\n';
|
|
365
|
-
html += '<tr><th colspan="5"><hr></th></tr>\n';
|
|
366
|
-
if (parentUrlPath)
|
|
367
|
-
html += `<tr>`
|
|
368
|
-
+ `<td valign="top"><img src="/icons/back.gif" alt="[PARENTDIR]"></td>`
|
|
369
|
-
+ `<td><a href="${parentUrlPath}">Parent Directory</a></td>`
|
|
370
|
-
+ `<td> </td><td align="right"> - </td><td> </td>`
|
|
371
|
-
+ `</tr>\n`;
|
|
372
|
-
for (const file of files) {
|
|
373
|
-
const fullPath = path_1.default.join(directoryPath, file);
|
|
374
|
-
let stat;
|
|
375
|
-
try {
|
|
376
|
-
stat = fs_1.default.statSync(fullPath);
|
|
377
|
-
}
|
|
378
|
-
catch {
|
|
379
|
-
continue; // skip entries that disappeared between readdir and stat
|
|
380
|
-
}
|
|
381
|
-
const mimeType = exports.mime.lookup(fullPath, '');
|
|
382
|
-
const mediaType = mimeType.includes('/') ? mimeType.split('/')[0] : '';
|
|
383
|
-
const alt = stat.isDirectory() ? 'folder' : (mediaType || 'unknown');
|
|
384
|
-
const icon = `/icons/${alt}.gif`;
|
|
385
|
-
const name = file + (stat.isDirectory() ? '/' : '');
|
|
386
|
-
const modified = stat.mtime.toUTCString();
|
|
387
|
-
const size = stat.isDirectory() ? '-' : String(stat.size);
|
|
388
|
-
html += `<tr>`
|
|
389
|
-
+ `<td valign="top"><img src="${icon}" alt="[${alt.toUpperCase()}]"></td>`
|
|
390
|
-
+ `<td><a href="${name}">${name}</a></td>`
|
|
391
|
-
+ `<td align="right">${modified}</td>`
|
|
392
|
-
+ `<td align="right">${size}</td>`
|
|
393
|
-
+ `<td> </td>`
|
|
394
|
-
+ `</tr>\n`;
|
|
395
|
-
}
|
|
396
|
-
html += '<tr><th colspan="5"><hr></th></tr>\n';
|
|
397
|
-
html += '</table><address>Expediate/1.0.0</address></body></html>\n';
|
|
398
|
-
return callback(html, null);
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
// ---------------------------------------------------------------------------
|
|
402
|
-
// Core send logic
|
|
403
|
-
// ---------------------------------------------------------------------------
|
|
404
|
-
/**
|
|
405
|
-
* Set response headers and stream a file to the client.
|
|
406
|
-
*
|
|
407
|
-
* This is the inner send function shared by {@link sendFile} and the
|
|
408
|
-
* {@link serveStatic} middleware. It handles:
|
|
409
|
-
* - Custom and security headers from `opts`.
|
|
410
|
-
* - `Cache-Control` with optional `immutable` directive.
|
|
411
|
-
* - `Last-Modified` and `ETag` generation.
|
|
412
|
-
* - MIME-type detection via the `mime` package.
|
|
413
|
-
* - Conditional GET evaluation (`If-None-Match`, `If-Modified-Since`).
|
|
414
|
-
* - `HEAD` requests (headers sent, no body).
|
|
415
|
-
* - Streaming the file body with proper error handling and stream cleanup.
|
|
416
|
-
*
|
|
417
|
-
* @param req - The incoming router request.
|
|
418
|
-
* @param res - The outgoing router response.
|
|
419
|
-
* @param pathname - Absolute filesystem path of the file to send.
|
|
420
|
-
* @param stat - Pre-fetched `fs.Stats` for `pathname`.
|
|
421
|
-
* @param opts - Fully resolved static-serving options.
|
|
422
|
-
*/
|
|
423
|
-
function sendIt(req, res, pathname, stat, opts) {
|
|
424
|
-
const len = stat.size;
|
|
425
|
-
const etag = createETag(stat);
|
|
426
|
-
// Apply caller-supplied headers first so they can be overridden by the
|
|
427
|
-
// cache/content headers below if necessary.
|
|
428
|
-
if (opts.headers) {
|
|
429
|
-
for (const [key, value] of Object.entries(opts.headers))
|
|
430
|
-
res.setHeader(key, value);
|
|
431
|
-
}
|
|
432
|
-
// Cache-Control
|
|
433
|
-
if (!res.getHeader('Cache-Control') && opts.maxage) {
|
|
434
|
-
let cacheControl = `public, max-age=${Math.floor(opts.maxage / 1000)}`;
|
|
435
|
-
if (opts.immutable)
|
|
436
|
-
cacheControl += ', immutable';
|
|
437
|
-
res.setHeader('Cache-Control', cacheControl);
|
|
438
|
-
}
|
|
439
|
-
// Last-Modified
|
|
440
|
-
if (!res.getHeader('Last-Modified') && opts.lastModified !== false)
|
|
441
|
-
res.setHeader('Last-Modified', stat.mtime.toUTCString());
|
|
442
|
-
// ETag
|
|
443
|
-
if (!res.getHeader('ETag') && opts.etag !== false)
|
|
444
|
-
res.setHeader('ETag', etag);
|
|
445
|
-
// Content-Type
|
|
446
|
-
if (!res.getHeader('Content-Type')) {
|
|
447
|
-
if (opts.contentType) {
|
|
448
|
-
res.setHeader('Content-Type', opts.contentType);
|
|
449
|
-
}
|
|
450
|
-
else {
|
|
451
|
-
const type = exports.mime.lookup(pathname, '');
|
|
452
|
-
if (type) {
|
|
453
|
-
const charset = exports.mime.charsets(type);
|
|
454
|
-
res.setHeader('Content-Type', charset ? `${type}; charset=${charset}` : type);
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
// Conditional GET — two independent RFC 7232 mechanisms evaluated in order.
|
|
459
|
-
// 1. Cache-validation headers (If-None-Match / If-Modified-Since).
|
|
460
|
-
// When the cached response is still fresh, respond with 304 Not Modified.
|
|
461
|
-
// This must be tested BEFORE the precondition headers so that a browser
|
|
462
|
-
// performing a normal ETag revalidation is not incorrectly rejected.
|
|
463
|
-
if (isCacheFresh(req.headers, res.getHeaders())) {
|
|
464
|
-
removeContentHeaders(res);
|
|
465
|
-
HTTP.NOT_MODIFIED(res, opts);
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
// 2. Precondition headers (If-Match / If-Unmodified-Since).
|
|
469
|
-
// Only evaluate when these specific headers are present; If-None-Match and
|
|
470
|
-
// If-Modified-Since are handled above by isCacheFresh and must NOT trigger
|
|
471
|
-
// a 412 when conditionMatch returns false (they are cache-validation headers,
|
|
472
|
-
// not write-precondition headers per RFC 7232).
|
|
473
|
-
const hasPrecondition = !!(req.headers['if-match'] ||
|
|
474
|
-
req.headers['if-unmodified-since']);
|
|
475
|
-
if (hasPrecondition && !conditionMatch(req.headers, res.getHeaders())) {
|
|
476
|
-
HTTP.PRECONDITION_FAILS(res, opts);
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
// --- Send the body ---
|
|
480
|
-
res.setHeader('Content-Length', len);
|
|
481
|
-
// HEAD requests: headers only, no body.
|
|
482
|
-
if (req.method === 'HEAD') {
|
|
483
|
-
res.end();
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
let finished = false;
|
|
487
|
-
const stream = fs_1.default.createReadStream(pathname);
|
|
488
|
-
res.on('finish', () => {
|
|
489
|
-
finished = true;
|
|
490
|
-
destroyReadStream(stream);
|
|
491
|
-
});
|
|
492
|
-
stream.on('error', (err) => {
|
|
493
|
-
if (finished)
|
|
494
|
-
return;
|
|
495
|
-
console.warn('static stream error:', pathname, err);
|
|
496
|
-
HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
|
|
497
|
-
finished = true;
|
|
498
|
-
destroyReadStream(stream);
|
|
499
|
-
});
|
|
500
|
-
stream.on('end', () => res.end());
|
|
501
|
-
stream.pipe(res);
|
|
502
|
-
}
|
|
503
|
-
// ---------------------------------------------------------------------------
|
|
504
|
-
// Public API
|
|
505
|
-
// ---------------------------------------------------------------------------
|
|
506
|
-
/**
|
|
507
|
-
* Send a single file from the filesystem as an HTTP response.
|
|
508
|
-
*
|
|
509
|
-
* Unlike the {@link serveStatic} middleware, this function resolves the
|
|
510
|
-
* response for a **specific** `pathname` that has already been determined by
|
|
511
|
-
* the caller. It is useful for custom routing logic where the file path is
|
|
512
|
-
* computed dynamically.
|
|
513
|
-
*
|
|
514
|
-
* Behaviour:
|
|
515
|
-
* - When `pathname` refers to a **regular file**, it is served via
|
|
516
|
-
* {@link sendIt} (including ETag, Last-Modified, conditional GET, and
|
|
517
|
-
* MIME-type detection).
|
|
518
|
-
* - When `pathname` refers to a **directory** and `opts.indexOf` is `true`,
|
|
519
|
-
* an Apache-style directory listing is rendered.
|
|
520
|
-
* - Otherwise, a 404 Not Found response is sent.
|
|
521
|
-
*
|
|
522
|
-
* @param req - The incoming router request.
|
|
523
|
-
* @param res - The outgoing router response.
|
|
524
|
-
* @param pathname - Absolute filesystem path of the file to send.
|
|
525
|
-
* @param opts - Fully resolved static-serving options.
|
|
526
|
-
*/
|
|
527
|
-
function sendFile(req, res, pathname, opts) {
|
|
528
|
-
fs_1.default.stat(pathname, (err, stat) => {
|
|
529
|
-
if (err) {
|
|
530
|
-
if (err.code === 'ENOENT' ||
|
|
531
|
-
err.code === 'ENAMETOOLONG' ||
|
|
532
|
-
err.code === 'ENOTDIR')
|
|
533
|
-
return HTTP.NOT_FOUND(res, opts);
|
|
534
|
-
return HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
|
|
535
|
-
}
|
|
536
|
-
if (stat.isDirectory()) {
|
|
537
|
-
if (opts.indexOf) {
|
|
538
|
-
const parentUrl = req.path !== '/' ? path_1.default.dirname(req.path) : null;
|
|
539
|
-
return writeIndexOf(req.path, pathname, parentUrl, (html, indexErr) => {
|
|
540
|
-
if (indexErr)
|
|
541
|
-
return HTTP.INTERNAL_ERROR(res, opts, indexErr.code ?? 'UNKNOWN');
|
|
542
|
-
return res.status(200, opts.headers).send(html);
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
// Directory listing is disabled — treat as not found.
|
|
546
|
-
return HTTP.NOT_FOUND(res, opts);
|
|
547
|
-
}
|
|
548
|
-
sendIt(req, res, pathname, stat, opts);
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
/**
|
|
552
|
-
* Middleware factory that serves files from a directory on the filesystem.
|
|
553
|
-
*
|
|
554
|
-
* Mount this middleware with a path prefix to expose a public directory:
|
|
555
|
-
* ```ts
|
|
556
|
-
* app.use('/public', serveStatic('./dist'));
|
|
557
|
-
* ```
|
|
558
|
-
*
|
|
559
|
-
* Features:
|
|
560
|
-
* - **Method filtering** — only `GET` and `HEAD` are served; other methods
|
|
561
|
-
* receive 405 Method Not Allowed (or are forwarded to `next()` when
|
|
562
|
-
* `fallthrough` is `true`).
|
|
563
|
-
* - **Directory traversal protection** — any path containing `..` is
|
|
564
|
-
* rejected with 403 Forbidden.
|
|
565
|
-
* - **Dot-file handling** — configurable via the `dotfiles` option
|
|
566
|
-
* (`'allow'`, `'deny'`, or `'hide'`).
|
|
567
|
-
* - **Directory redirect** — a path resolving to a directory is automatically
|
|
568
|
-
* redirected to its `index.html` when `redirect` is `true`.
|
|
569
|
-
* - **Conditional GET** — ETag and Last-Modified headers enable browser and
|
|
570
|
-
* proxy caching with proper revalidation.
|
|
571
|
-
* - **Cache-Control** — configurable via `maxage` / `immutable` options.
|
|
572
|
-
* - **MIME-type detection** — via the `mime` npm package.
|
|
573
|
-
*
|
|
574
|
-
* @param root - Path to the directory containing the files to serve.
|
|
575
|
-
* Resolved to an absolute path internally.
|
|
576
|
-
* @param options - Optional configuration (see {@link StaticOptions}).
|
|
577
|
-
* @returns An Express-compatible middleware function.
|
|
578
|
-
* @throws {TypeError} When `root` is missing or not a string.
|
|
579
|
-
*/
|
|
580
|
-
function serveStatic(root, options) {
|
|
581
|
-
const opts = resolveOptions(root, options);
|
|
582
|
-
return function (req, res, next) {
|
|
583
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
584
|
-
if (opts.fallthrough)
|
|
585
|
-
return next();
|
|
586
|
-
return HTTP.NOT_ALLOWED(res, opts);
|
|
587
|
-
}
|
|
588
|
-
// Decode the current path (after any prefix stripping by parent routers).
|
|
589
|
-
const originalUrl = decodeURIComponent(req.path ?? req.url ?? '/');
|
|
590
|
-
let pathname = originalUrl;
|
|
591
|
-
// When the URL ends without a trailing slash but the bare mount point was
|
|
592
|
-
// requested, clear the pathname so the root directory is considered.
|
|
593
|
-
if (pathname === '/' && !originalUrl.endsWith('/'))
|
|
594
|
-
pathname = '';
|
|
595
|
-
// Reject any path containing a directory-traversal component.
|
|
596
|
-
if (UP_PATH_REGEXP.test(pathname))
|
|
597
|
-
return HTTP.FORBIDDEN(res, opts);
|
|
598
|
-
// Resolve to an absolute filesystem path.
|
|
599
|
-
pathname = path_1.default.resolve(path_1.default.normalize(`${opts.root}/${pathname}`));
|
|
600
|
-
// Dot-file handling.
|
|
601
|
-
if (opts.dotfiles !== 'allow' && pathname.includes('/.')) {
|
|
602
|
-
if (opts.dotfiles === 'deny')
|
|
603
|
-
return HTTP.FORBIDDEN(res, opts);
|
|
604
|
-
return HTTP.NOT_FOUND(res, opts);
|
|
605
|
-
}
|
|
606
|
-
fs_1.default.stat(pathname, (err, stat) => {
|
|
607
|
-
if (err) {
|
|
608
|
-
if (err.code === 'ENOENT' ||
|
|
609
|
-
err.code === 'ENAMETOOLONG' ||
|
|
610
|
-
err.code === 'ENOTDIR') {
|
|
611
|
-
if (opts.fallthrough)
|
|
612
|
-
return next();
|
|
613
|
-
return HTTP.NOT_FOUND(res, opts);
|
|
614
|
-
}
|
|
615
|
-
return HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
|
|
616
|
-
}
|
|
617
|
-
// When the resolved path is a directory, redirect to its index.html.
|
|
618
|
-
if (stat.isDirectory()) {
|
|
619
|
-
if (!opts.redirect) {
|
|
620
|
-
if (opts.fallthrough)
|
|
621
|
-
return next();
|
|
622
|
-
return HTTP.NOT_FOUND(res, opts);
|
|
623
|
-
}
|
|
624
|
-
return sendFile(req, res, path_1.default.join(pathname, 'index.html'), opts);
|
|
625
|
-
}
|
|
626
|
-
sendIt(req, res, pathname, stat, opts);
|
|
627
|
-
});
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
/**
|
|
631
|
-
* Middleware factory that serves a **single, fixed file** as the response to
|
|
632
|
-
* every request, regardless of the request path.
|
|
633
|
-
*
|
|
634
|
-
* Use this when you want every route to return the same file — for example,
|
|
635
|
-
* serving a compiled single-page application's `index.html` as a catch-all:
|
|
636
|
-
* ```ts
|
|
637
|
-
* app.get('/**', serveFile('./dist/index.html'));
|
|
638
|
-
* ```
|
|
639
|
-
*
|
|
640
|
-
* Features:
|
|
641
|
-
* - **Method filtering** — only `GET` and `HEAD` are served.
|
|
642
|
-
* - **Conditional GET** — ETag and Last-Modified are generated from the file
|
|
643
|
-
* metadata on every request.
|
|
644
|
-
* - **MIME-type detection** — via the `mime` npm package.
|
|
645
|
-
*
|
|
646
|
-
* @param filePath - Path to the file to serve. Resolved to an absolute path.
|
|
647
|
-
* @param options - Optional configuration (see {@link StaticOptions}).
|
|
648
|
-
* @returns An Express-compatible middleware function.
|
|
649
|
-
* @throws {TypeError} When `filePath` is missing or not a string.
|
|
650
|
-
*/
|
|
651
|
-
function serveFile(filePath, options) {
|
|
652
|
-
const opts = resolveOptions(filePath, options);
|
|
653
|
-
return function (req, res, next) {
|
|
654
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
655
|
-
if (opts.fallthrough)
|
|
656
|
-
return next();
|
|
657
|
-
return HTTP.NOT_ALLOWED(res, opts);
|
|
658
|
-
}
|
|
659
|
-
const pathname = opts.root;
|
|
660
|
-
fs_1.default.stat(pathname, (err, stat) => {
|
|
661
|
-
if (err)
|
|
662
|
-
return HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
|
|
663
|
-
if (stat.isDirectory())
|
|
664
|
-
return HTTP.INTERNAL_ERROR(res, opts, 'EISDIR');
|
|
665
|
-
sendIt(req, res, pathname, stat, opts);
|
|
666
|
-
});
|
|
667
|
-
};
|
|
668
|
-
}
|
|
669
|
-
exports.default = { serveStatic, serveFile, sendFile };
|