fulmine.js 5.0.0-rc.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/src/utils.js ADDED
@@ -0,0 +1,993 @@
1
+ /*
2
+ Copyright 2024 dimden.dev
3
+ Copyright 2026 Nigro Simone
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+ */
17
+
18
+ const mime = require("mime-types");
19
+ const path = require("path");
20
+ const proxyaddr = require("proxy-addr");
21
+ const qs = require("qs");
22
+ const querystring = require("fast-querystring");
23
+ const crypto = require("crypto");
24
+ const statuses = require("statuses");
25
+ const { Stats } = require("fs");
26
+
27
+ const EMPTY_REGEX = new RegExp(``);
28
+
29
+ /**
30
+ * Parses a query string the way the "extended" parser is expected to, which means qs and its
31
+ * support for nested keys, but without paying for qs on the strings that cannot need it. A short
32
+ * query with no bracket and no dot goes through fast-querystring instead, which is several times
33
+ * quicker and produces the same answer for that shape.
34
+ *
35
+ * @param {string} query the query string, without the leading "?"
36
+ * @param {object} [options] passed through to qs when it is used
37
+ * @returns {Record<string, any>} null-prototype, so a key from the query cannot reach Object.prototype
38
+ */
39
+ function fastQueryParse(query, options) {
40
+ // the result keeps a null prototype, which is why req.query prints as "[Object: null
41
+ // prototype] {}". Spreading it into a plain object would put Object.prototype keys back
42
+ // within reach of a query string.
43
+ const len = query.length;
44
+ if (len === 0) {
45
+ return Object.create(null);
46
+ }
47
+ if (len <= 128) {
48
+ if (!query.includes("[") && !query.includes("%5B") && !query.includes(".") && !query.includes("%2E")) {
49
+ return Object.assign(Object.create(null), querystring.parse(query));
50
+ }
51
+ }
52
+ return Object.assign(Object.create(null), qs.parse(query, options));
53
+ }
54
+
55
+ /**
56
+ * Collapses runs of slashes, so //a///b reads as /a/b. Express does the same before matching, and
57
+ * without it a path could dodge a route by being written with an extra slash.
58
+ *
59
+ * @param {string} path
60
+ * @returns {string}
61
+ */
62
+ function removeDuplicateSlashes(path) {
63
+ return path.replace(/\/{2,}/g, "/");
64
+ }
65
+
66
+ // the opening of a named capture group, which is how the parameter names are read back out of a
67
+ // finished pattern
68
+ const NAMED_GROUP = /\(\?<([^>]+)>/g;
69
+
70
+ /**
71
+ * What a compiled pattern captures: the names in order, and which of them are wildcards to split
72
+ * into an array. Here and not on the regex itself, because one own property takes a RegExp off V8's
73
+ * fast path: replace() went from 37ns to 808ns and test() from 22ns to 78ns.
74
+ *
75
+ * @typedef {{wildcardNames: string[], paramNames: string[], isWildcard: boolean[]}} PatternMeta
76
+ */
77
+ const patternMeta = new WeakMap();
78
+
79
+ /**
80
+ * What patternToRegex worked out about a pattern, or undefined for a RegExp the application wrote
81
+ * itself.
82
+ * @param {RegExp} pattern
83
+ * @returns {PatternMeta|undefined}
84
+ */
85
+ function getPatternMeta(pattern) {
86
+ return patternMeta.get(pattern);
87
+ }
88
+
89
+ /**
90
+ * A compiled path pattern. A plain RegExp, deliberately.
91
+ * @typedef {RegExp} PathRegExp
92
+ */
93
+
94
+ /**
95
+ * Compiles a path into a regex, following path-to-regexp v8:
96
+ * - :param a named parameter, one segment
97
+ * - /*splat a named wildcard, one or more segments, captured as an array
98
+ * - {...} an optional group
99
+ * - \x an escaped literal
100
+ *
101
+ * A bare `*`, an unnamed parameter, an inline regex like :id(\\d+) and the `+`, `?`, `()` operators
102
+ * throw: a route that quietly stops matching is worse than one that fails at startup. The names it
103
+ * captures go in a WeakMap beside the regex, see PatternMeta.
104
+ */
105
+ function patternToRegex(pattern, isPrefix = false) {
106
+ if (pattern instanceof RegExp) {
107
+ return pattern;
108
+ }
109
+ if (isPrefix && pattern === "") {
110
+ return EMPTY_REGEX;
111
+ }
112
+
113
+ let regexPattern = "";
114
+ let i = 0;
115
+ const len = pattern.length;
116
+ const wildcardNames = [];
117
+ // whether the token just emitted was a :parameter, which decides how greedy the next
118
+ // optional group is allowed to be. see the comment where it is read
119
+ let lastTokenWasParam = false;
120
+
121
+ while (i < len) {
122
+ const ch = pattern[i];
123
+
124
+ if (ch === "\\" && i + 1 < len) {
125
+ regexPattern += "\\" + pattern[i + 1];
126
+ i += 2;
127
+ continue;
128
+ }
129
+
130
+ // *splat: one or more characters, slashes included. It is not anchored to a segment
131
+ // boundary, so /te*st is literal "/te" followed by a wildcard named "st"
132
+ if (ch === "*") {
133
+ const at = i;
134
+ i++;
135
+ let name = "";
136
+ while (i < len && /\w/.test(pattern[i])) {
137
+ name += pattern[i++];
138
+ }
139
+ if (!name) {
140
+ throw new Error(`Missing parameter name at index ${at}: ${pattern}`);
141
+ }
142
+ wildcardNames.push(name);
143
+ regexPattern += `(?<${name}>[^]+)`;
144
+ lastTokenWasParam = false;
145
+ continue;
146
+ }
147
+
148
+ if (ch === "{") {
149
+ // {*splat}: zero or more segments, so it also matches the mount point
150
+ if (pattern[i + 1] === "*") {
151
+ i += 2;
152
+ let name = "";
153
+ while (i < len && pattern[i] !== "}") {
154
+ name += pattern[i++];
155
+ }
156
+ i++;
157
+ if (!name) {
158
+ throw new Error(`Wildcard must be named in Express 5: use {*splat} (in "${pattern}")`);
159
+ }
160
+ wildcardNames.push(name);
161
+ if (regexPattern.endsWith("/") || regexPattern.endsWith("\\/")) {
162
+ // the slash belongs to the optional part, otherwise /{*splat} would not match /
163
+ regexPattern = regexPattern.slice(0, regexPattern.endsWith("\\/") ? -2 : -1);
164
+ regexPattern += `(?:/(?<${name}>.+))?/?`;
165
+ } else {
166
+ regexPattern += `(?<${name}>.*)`;
167
+ }
168
+ continue;
169
+ }
170
+
171
+ // optional group, which may itself contain a parameter: {.:ext}, {/:page}
172
+ i++;
173
+ let groupContent = "";
174
+ let braceDepth = 1;
175
+ while (i < len && braceDepth > 0) {
176
+ if (pattern[i] === "{") braceDepth++;
177
+ else if (pattern[i] === "}") {
178
+ braceDepth--;
179
+ if (braceDepth === 0) break;
180
+ }
181
+ groupContent += pattern[i++];
182
+ }
183
+ i++;
184
+
185
+ // When a :parameter precedes this group, that parameter is the one that gives ground
186
+ // while backtracking, so this one must not swallow the separator as well. Express
187
+ // splits /a.b.c against /:file{.:ext} as file=a.b, ext=c, which only works if ext
188
+ // cannot contain a dot. After static text there is nothing to give ground, so the
189
+ // parameter takes everything: /file{.:ext} against /file.tar.gz gives ext=tar.gz.
190
+ const separator = lastTokenWasParam && groupContent[0] && groupContent[0] !== ":" ? groupContent[0] : "";
191
+ const groupParamClass = `[^/${separator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}]+`;
192
+
193
+ let groupRegex = "";
194
+ let gi = 0;
195
+ while (gi < groupContent.length) {
196
+ if (groupContent[gi] === ":") {
197
+ gi++;
198
+ let paramName = "";
199
+ while (gi < groupContent.length && /\w/.test(groupContent[gi])) {
200
+ paramName += groupContent[gi++];
201
+ }
202
+ groupRegex += `(?<${paramName}>${groupParamClass})`;
203
+ } else if (groupContent[gi] === ".") {
204
+ groupRegex += "\\.";
205
+ gi++;
206
+ } else if (groupContent[gi] === "/") {
207
+ groupRegex += "/";
208
+ gi++;
209
+ } else {
210
+ groupRegex += groupContent[gi].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
211
+ gi++;
212
+ }
213
+ }
214
+ regexPattern += `(?:${groupRegex})?`;
215
+ lastTokenWasParam = false;
216
+ continue;
217
+ }
218
+
219
+ if (ch === ":") {
220
+ i++;
221
+ let name = "";
222
+ while (i < len && /\w/.test(pattern[i])) {
223
+ name += pattern[i++];
224
+ }
225
+ if (!name) {
226
+ throw new Error(`Missing parameter name at index ${i - 1}: ${pattern}`);
227
+ }
228
+ // a following optional group needs room to match, so the parameter gives ground
229
+ regexPattern += i < len && pattern[i] === "{" ? `(?<${name}>[^/]+?)` : `(?<${name}>[^/]+)`;
230
+ lastTokenWasParam = true;
231
+ continue;
232
+ }
233
+
234
+ // these have no meaning in a path, and the route is refused rather than matching them
235
+ // literally: a path that quietly stops matching is worse than one that fails at startup.
236
+ // Escape them to use them as literals.
237
+ if ("?+()[]!".includes(ch)) {
238
+ throw new Error(`Unexpected ${ch} at index ${i}: ${pattern}`);
239
+ }
240
+
241
+ if (".^$|}".includes(ch)) {
242
+ regexPattern += "\\" + ch;
243
+ } else {
244
+ regexPattern += ch;
245
+ }
246
+ lastTokenWasParam = false;
247
+ i++;
248
+ }
249
+
250
+ const regex = /** @type {PathRegExp} */ (new RegExp(`^${regexPattern}${isPrefix ? "(?=$|/)" : "$"}`));
251
+ // read back out of the finished pattern, so the list cannot disagree with the regex. Asking for
252
+ // each name in turn beats walking match.groups with for-in: 176ns against 349
253
+ const paramNames = [...regexPattern.matchAll(NAMED_GROUP)].map((m) => m[1]);
254
+ patternMeta.set(regex, {
255
+ wildcardNames,
256
+ paramNames,
257
+ isWildcard: paramNames.map((name) => wildcardNames.includes(name))
258
+ });
259
+ return regex;
260
+ }
261
+
262
+ /**
263
+ * Whether a path has anything in it that a string comparison cannot answer, which is a parameter,
264
+ * a wildcard or an optional group. A regular expression is already compiled, so it needs nothing.
265
+ *
266
+ * @param {string|RegExp} pattern
267
+ * @returns {boolean}
268
+ */
269
+ function needsConversionToRegex(pattern) {
270
+ if (pattern instanceof RegExp) {
271
+ return false;
272
+ }
273
+
274
+ return pattern.includes("*") || pattern.includes(":") || pattern.includes("{");
275
+ }
276
+
277
+ /**
278
+ * Whether a path is a plain literal, which is what makes a route eligible for the native uWS
279
+ * router. Not simply the opposite of needsConversionToRegex: a RegExp answers false to both, since
280
+ * it needs no conversion and cannot be optimized either.
281
+ *
282
+ * @param {string|RegExp} pattern
283
+ * @returns {boolean}
284
+ */
285
+ function canBeOptimized(pattern) {
286
+ if (pattern instanceof RegExp) {
287
+ return false;
288
+ }
289
+ return !pattern.includes("*") && !pattern.includes("{") && !pattern.includes(":");
290
+ }
291
+
292
+ // a parameter that is the whole segment, which is the only shape µWS matches the same way Express
293
+ // does. "/flights/:from-:to" is one segment to µWS and two parameters to Express.
294
+ const WHOLE_SEGMENT_PARAM = /^:\w+$/;
295
+
296
+ /**
297
+ * Whether µWS's own router matches this path exactly as Express would, parameters included.
298
+ *
299
+ * µWS matches `:name` against one non-empty segment, as Express does, and has nothing for a v5
300
+ * wildcard or an optional group. A parameter counts only when it is the whole segment: Express
301
+ * matches `/flights/:from-:to` inside a segment and µWS does not.
302
+ *
303
+ * @param {string|RegExp} pattern
304
+ * @returns {boolean}
305
+ */
306
+ function canBeOptimizedWithParams(pattern) {
307
+ if (pattern instanceof RegExp) {
308
+ return false;
309
+ }
310
+ if (/[*{}()[\]?+\\]/.test(pattern)) {
311
+ return false;
312
+ }
313
+ if (!pattern.includes(":")) {
314
+ return true;
315
+ }
316
+ for (const segment of pattern.split("/")) {
317
+ if (segment.includes(":") && !WHOLE_SEGMENT_PARAM.test(segment)) {
318
+ return false;
319
+ }
320
+ }
321
+ return true;
322
+ }
323
+
324
+ /**
325
+ * Whether two paths could both match the same request.
326
+ *
327
+ * Only asked about paths µWS could match itself, so the answer is structural: the same number of
328
+ * segments, and no position where two different literals meet. `/orders/:id` and `/invoices/:id`
329
+ * cannot both match, `/users/:id` and `/users/me` can. Anything else is not asked, and the caller
330
+ * reads "do not know" as yes.
331
+ *
332
+ * @param {string} a
333
+ * @param {string} b
334
+ * @returns {boolean}
335
+ */
336
+ function pathsCanOverlap(a, b) {
337
+ const left = a.split("/");
338
+ const right = b.split("/");
339
+ if (left.length !== right.length) {
340
+ return false;
341
+ }
342
+ for (let i = 0; i < left.length; i++) {
343
+ if (left[i] === right[i]) {
344
+ continue;
345
+ }
346
+ // a parameter matches whatever is in that segment, so only two different literals settle it
347
+ if (left[i].charCodeAt(0) === 0x3a || right[i].charCodeAt(0) === 0x3a) {
348
+ continue;
349
+ }
350
+ return false;
351
+ }
352
+ return true;
353
+ }
354
+
355
+ /**
356
+ * Splits one entry of an Accept-style header into its value and its parameters, with q pulled out
357
+ * as the quality since that is the one every caller wants.
358
+ *
359
+ * @param {string} str a single entry, such as "text/html;q=0.8;level=1"
360
+ * @returns {{value: string, quality: number, params: Record<string, string>}}
361
+ */
362
+ function acceptParams(str) {
363
+ const length = str.length;
364
+ const colonIndex = str.indexOf(";");
365
+ let index = colonIndex === -1 ? length : colonIndex;
366
+ const ret = { value: str.slice(0, index).trim(), quality: 1, params: {} };
367
+
368
+ while (index < length) {
369
+ const splitIndex = str.indexOf("=", index);
370
+ if (splitIndex === -1) break;
371
+
372
+ const colonIndex = str.indexOf(";", index);
373
+ const endIndex = colonIndex === -1 ? length : colonIndex;
374
+
375
+ if (splitIndex > endIndex) {
376
+ index = str.lastIndexOf(";", splitIndex - 1) + 1;
377
+ continue;
378
+ }
379
+
380
+ const key = str.slice(index, splitIndex).trim();
381
+ const value = str.slice(splitIndex + 1, endIndex).trim();
382
+
383
+ if (key === "q") {
384
+ ret.quality = parseFloat(value);
385
+ } else {
386
+ ret.params[key] = value;
387
+ }
388
+
389
+ index = endIndex + 1;
390
+ }
391
+
392
+ return ret;
393
+ }
394
+
395
+ // How many answers a memo keeps before it starts over.
396
+ //
397
+ // The keys are media types, so an application uses a handful and the ceiling is never approached.
398
+ // It is here because application code is free to hand res.type() something a client sent, and an
399
+ // unbounded map keyed on that is a leak the client controls.
400
+ //
401
+ // Clearing beats evicting one entry at a time: the few types an application really uses are back
402
+ // within a few requests, whereas refusing new entries once full would let a flood of invented
403
+ // values lock the real ones out for the life of the process.
404
+ const MEMO_LIMIT = 512;
405
+
406
+ /**
407
+ * A pure function of one string, with its answers kept.
408
+ *
409
+ * The wrapped function must never answer undefined, since that is what the cache reads as a miss.
410
+ * The mime lookups here answer false for something they do not know, which caches correctly.
411
+ *
412
+ * @param {(key: string) => any} fn
413
+ * @returns {(key: string) => any}
414
+ */
415
+ function memoizeByString(fn) {
416
+ const cache = new Map();
417
+ return function memoized(key) {
418
+ let hit = cache.get(key);
419
+ if (hit === undefined) {
420
+ hit = fn(key);
421
+ if (cache.size >= MEMO_LIMIT) {
422
+ cache.clear();
423
+ }
424
+ cache.set(key, hit);
425
+ }
426
+ return hit;
427
+ };
428
+ }
429
+
430
+ // mime.lookup walks the extension and searches the database for it, which for the same "json" on
431
+ // every response is 273 ns to reach the same answer. Kept, it is 6.
432
+ const lookupType = memoizeByString((type) => mime.lookup(type) || "application/octet-stream");
433
+
434
+ /**
435
+ * The full content-type an extension stands for, charset included, as res.type() writes it.
436
+ * @param {string} type an extension, or a media type, which is returned as given
437
+ * @returns {string}
438
+ */
439
+ const contentTypeFor = memoizeByString((type) => mime.contentType(type) || "application/octet-stream");
440
+
441
+ /**
442
+ * A media type from either spelling: "html" is looked up in the mime database, while anything
443
+ * containing a slash is already one and is parsed for its parameters.
444
+ *
445
+ * @param {string} type an extension or a full media type
446
+ * @returns {{value: string, params: Record<string, string>}}
447
+ */
448
+ function normalizeType(type) {
449
+ // a fresh object every time on purpose: the caller owns params and may write to it
450
+ return ~type.indexOf("/") ? acceptParams(type) : { value: lookupType(type), params: {} };
451
+ }
452
+
453
+ /**
454
+ * JSON.stringify, plus the escaping the "json escape" setting asks for: <, > and & become their
455
+ * unicode escapes, so a string in the body cannot close a script tag in an HTML page that embeds
456
+ * the response.
457
+ *
458
+ * @param {any} value
459
+ * @param {any} [replacer] the "json replacer" setting
460
+ * @param {string|number} [spaces] the "json spaces" setting
461
+ * @param {boolean} [escape] the "json escape" setting
462
+ * @returns {string}
463
+ */
464
+ function stringify(value, replacer, spaces, escape) {
465
+ let json = replacer || spaces ? JSON.stringify(value, replacer, spaces) : JSON.stringify(value);
466
+
467
+ if (escape && typeof json === "string") {
468
+ json = json.replace(/[<>&]/g, function (c) {
469
+ switch (c.charCodeAt(0)) {
470
+ case 0x3c:
471
+ return "\\u003c";
472
+ case 0x3e:
473
+ return "\\u003e";
474
+ case 0x26:
475
+ return "\\u0026";
476
+ default:
477
+ return c;
478
+ }
479
+ });
480
+ }
481
+
482
+ return json;
483
+ }
484
+
485
+ const defaultSettings = {
486
+ "jsonp callback name": "callback",
487
+ env: () => process.env.NODE_ENV ?? "development",
488
+ etag: "weak",
489
+ "etag fn": () => createETagGenerator({ weak: true }),
490
+ "query parser": "simple",
491
+ "query parser fn": () => querystring.parse,
492
+ "subdomain offset": 2,
493
+ "trust proxy": false,
494
+ views: () => path.join(process.cwd(), "views"),
495
+ "view cache": () => process.env.NODE_ENV === "production",
496
+ // off by default, unlike Express, which keeps it for historical reasons. It only tells anyone
497
+ // asking which framework is running, and every hardening guide says to remove it. Set it back
498
+ // to true if something depends on it.
499
+ "x-powered-by": false,
500
+ "case sensitive routing": true,
501
+ "declarative responses": true
502
+ };
503
+
504
+ /**
505
+ * Turns whatever "trust proxy" was set to into the function proxy-addr wants: a predicate saying
506
+ * whether the address at hop i is trusted. true trusts everything, a number trusts that many hops,
507
+ * and a string or a list is read as addresses and subnet names.
508
+ *
509
+ * @param {boolean|number|string|string[]|Function} val
510
+ * @returns {Function}
511
+ */
512
+ function compileTrust(val) {
513
+ if (typeof val === "function") return val;
514
+
515
+ if (val === true) {
516
+ // Support plain true/false
517
+ return function () {
518
+ return true;
519
+ };
520
+ }
521
+
522
+ if (typeof val === "number") {
523
+ // Support trusting hop count
524
+ return function (a, i) {
525
+ return i < val;
526
+ };
527
+ }
528
+
529
+ if (typeof val === "string") {
530
+ // Support comma-separated values
531
+ val = val.split(",").map(function (v) {
532
+ return v.trim();
533
+ });
534
+ }
535
+
536
+ return proxyaddr.compile(val || []);
537
+ }
538
+
539
+ const shownWarnings = new Set();
540
+ /**
541
+ * Warns once per call site that a method has a newer name, in the format the deprecate package
542
+ * uses, so the output sits alongside the warnings Express's own dependencies produce. Once per
543
+ * site rather than once per call: the same line warning on every request would be a flood.
544
+ *
545
+ * @param {string} oldMethod
546
+ * @param {string} newMethod
547
+ * @param {boolean} [full] print the whole stack rather than the one frame that called it
548
+ */
549
+ function deprecated(oldMethod, newMethod, full = false) {
550
+ const err = new Error();
551
+ // V8 always fills this in for an Error made right here
552
+ const stack = err.stack ?? "";
553
+ const pos = full
554
+ ? stack.split("\n").slice(1).join("\n")
555
+ : stack.split("\n")[3].trim().split("(").slice(1).join("(").split(")").slice(0, -1).join(")");
556
+ if (shownWarnings.has(pos)) return;
557
+ shownWarnings.add(pos);
558
+ console.warn(
559
+ `${new Date().toLocaleString("en-UK", {
560
+ weekday: "short",
561
+ year: "numeric",
562
+ month: "short",
563
+ day: "numeric",
564
+ hour: "numeric",
565
+ minute: "numeric",
566
+ second: "numeric",
567
+ timeZone: "GMT",
568
+ timeZoneName: "short"
569
+ })} fulmine.js deprecated ${oldMethod}: Use ${newMethod} instead at ${pos}`
570
+ );
571
+ }
572
+
573
+ /**
574
+ * findIndex, but resuming from a position. The router walks the same route list many times per
575
+ * request, each time picking up after the route it just ran, and Array.findIndex has no way to
576
+ * start anywhere but the beginning.
577
+ *
578
+ * @param {any[]} arr
579
+ * @param {(item: any, index: number, arr: any[]) => boolean} fn
580
+ * @param {number} [index] where to start
581
+ * @returns {number} the index, or -1
582
+ */
583
+ function findIndexStartingFrom(arr, fn, index = 0) {
584
+ for (let i = index, end = arr.length; i < end; i++) {
585
+ if (fn(arr[i], i, arr)) {
586
+ return i;
587
+ }
588
+ }
589
+ return -1;
590
+ }
591
+
592
+ /**
593
+ * decodeURIComponent that answers rather than throwing. A malformed escape in a URL is a bad
594
+ * request and not an exception, so callers check for the sentinel and answer 400.
595
+ *
596
+ * @param {string} path
597
+ * @returns {string|-1} -1 when the path cannot be decoded
598
+ */
599
+ function decode(path) {
600
+ try {
601
+ return decodeURIComponent(path);
602
+ } catch (err) {
603
+ return -1;
604
+ }
605
+ }
606
+
607
+ /**
608
+ * A route parameter as the application should see it, which means decoded: `/users/caff%C3%A8`
609
+ * arrives as "caffè". A percent sequence that will not decode is the client's mistake and becomes a
610
+ * 400, with the message Express uses.
611
+ *
612
+ * @param {string} value
613
+ * @returns {string}
614
+ * @throws {any} carrying status 400 when the value cannot be decoded
615
+ */
616
+ function decodeParam(value) {
617
+ // the common case, and worth the check: a parameter is usually a number or a word, and
618
+ // decodeURIComponent is not free
619
+ if (value.indexOf("%") === -1) {
620
+ return value;
621
+ }
622
+ try {
623
+ return decodeURIComponent(value);
624
+ } catch {
625
+ const err = /** @type {any} */ (new Error(`Failed to decode param '${value}'`));
626
+ err.status = 400;
627
+ err.statusCode = 400;
628
+ err.expose = true;
629
+ throw err;
630
+ }
631
+ }
632
+
633
+ const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
634
+
635
+ /**
636
+ * Whether any segment is a dotfile. A single "." is not one, being the current directory, which is
637
+ * why the length is checked before the first character.
638
+ *
639
+ * @param {string[]} parts the path split on slashes
640
+ * @returns {boolean}
641
+ */
642
+ function containsDotFile(parts) {
643
+ for (let i = 0, len = parts.length; i < len; i++) {
644
+ const part = parts[i];
645
+ if (part.length > 1 && part[0] === ".") {
646
+ return true;
647
+ }
648
+ }
649
+
650
+ return false;
651
+ }
652
+
653
+ /**
654
+ * Splits a comma-separated header value into its tokens, trimming the spaces around each. Written
655
+ * out by hand rather than with split and trim because it runs for every conditional request.
656
+ *
657
+ * @param {string} str
658
+ * @returns {string[]}
659
+ */
660
+ function parseTokenList(str) {
661
+ let end = 0;
662
+ const list = [];
663
+ let start = 0;
664
+
665
+ // gather tokens
666
+ for (let i = 0, len = str.length; i < len; i++) {
667
+ switch (str.charCodeAt(i)) {
668
+ case 0x20 /* */:
669
+ if (start === end) {
670
+ start = end = i + 1;
671
+ }
672
+ break;
673
+ case 0x2c /* , */:
674
+ if (start !== end) {
675
+ list.push(str.substring(start, end));
676
+ }
677
+ start = end = i + 1;
678
+ break;
679
+ default:
680
+ end = i + 1;
681
+ break;
682
+ }
683
+ }
684
+
685
+ // final token
686
+ if (start !== end) {
687
+ list.push(str.substring(start, end));
688
+ }
689
+
690
+ return list;
691
+ }
692
+
693
+ /**
694
+ * An HTTP date as a timestamp, or NaN when it is missing or unreadable. NaN rather than a throw
695
+ * because every comparison against it is false, which is the answer a bad date should give.
696
+ *
697
+ * @param {string|undefined} date
698
+ * @returns {number}
699
+ */
700
+ function parseHttpDate(date) {
701
+ const timestamp = date && Date.parse(date);
702
+ return typeof timestamp === "number" ? timestamp : NaN;
703
+ }
704
+
705
+ /**
706
+ * Whether the request's If-Match or If-Unmodified-Since says the copy the client is acting on is
707
+ * no longer the current one, which is a 412 rather than a 304: the client asked to be stopped if
708
+ * anything had changed.
709
+ *
710
+ * @param {any} req
711
+ * @param {any} res
712
+ * @returns {boolean}
713
+ */
714
+ function isPreconditionFailure(req, res) {
715
+ const match = req.headers["if-match"];
716
+
717
+ // if-match
718
+ if (match) {
719
+ const etag = res.get("etag");
720
+ return (
721
+ !etag ||
722
+ (match !== "*" &&
723
+ parseTokenList(match).every((match) => {
724
+ return match !== etag && match !== "W/" + etag && "W/" + match !== etag;
725
+ }))
726
+ );
727
+ }
728
+
729
+ // if-unmodified-since
730
+ const unmodifiedSince = parseHttpDate(req.headers["if-unmodified-since"]);
731
+ if (!isNaN(unmodifiedSince)) {
732
+ const lastModified = parseHttpDate(res.get("Last-Modified"));
733
+ return isNaN(lastModified) || lastModified > unmodifiedSince;
734
+ }
735
+
736
+ return false;
737
+ }
738
+
739
+ // the sha1 of nothing, which the etag package answers with without hashing
740
+ const EMPTY_ENTITY_TAG = '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"';
741
+
742
+ /**
743
+ * The ETag of a body: its length in hex, a dash, and the first 27 characters of the base64 sha1.
744
+ * The same string the etag package produces, and tests/unit/utils.test.js holds it to that.
745
+ *
746
+ * crypto.hash and not crypto.createHash: the one-shot form allocates no hash object, and on a 500
747
+ * byte body it is twice as fast for the same answer, 924ns against 1963.
748
+ *
749
+ * @param {Buffer|string} entity
750
+ * @param {boolean} weak
751
+ * @returns {string}
752
+ */
753
+ function entityTag(entity, weak) {
754
+ if (entity.length === 0) {
755
+ return weak ? "W/" + EMPTY_ENTITY_TAG : EMPTY_ENTITY_TAG;
756
+ }
757
+ // the byte length, which for a string is not its character count
758
+ const len = typeof entity === "string" ? Buffer.byteLength(entity, "utf8") : entity.length;
759
+ const tag = `"${len.toString(16)}-${crypto.hash("sha1", entity, "base64").substring(0, 27)}"`;
760
+ return weak ? "W/" + tag : tag;
761
+ }
762
+
763
+ /**
764
+ * The ETag of a file, which is its size and mtime rather than its contents: send computes it this
765
+ * way so that serving a large file does not mean reading it twice.
766
+ *
767
+ * @param {import("fs").Stats} stat
768
+ * @param {boolean} weak
769
+ * @returns {string}
770
+ */
771
+ function statTag(stat, weak) {
772
+ const tag = `"${stat.size.toString(16)}-${stat.mtime.getTime().toString(16)}"`;
773
+ return weak ? "W/" + tag : tag;
774
+ }
775
+
776
+ /**
777
+ * The function the "etag" setting installs. It takes either a body or an fs.Stats, since a file's
778
+ * ETag comes from its size and mtime while a body's comes from its contents.
779
+ *
780
+ * @param {{weak: boolean}} options
781
+ * @returns {(body: any, encoding?: BufferEncoding) => string}
782
+ */
783
+ function createETagGenerator(options) {
784
+ return function generateETag(body, encoding) {
785
+ if (body instanceof Stats) {
786
+ return statTag(body, options.weak);
787
+ }
788
+ const buf = !Buffer.isBuffer(body) ? Buffer.from(body, encoding) : body;
789
+ return entityTag(buf, options.weak);
790
+ };
791
+ }
792
+
793
+ /**
794
+ * Whether an If-Range still holds, which decides between answering the range that was asked for
795
+ * and sending the whole file. It may carry either an ETag or a date, and a date only counts when
796
+ * it matches Last-Modified exactly.
797
+ *
798
+ * @param {any} req
799
+ * @param {any} res
800
+ * @returns {boolean}
801
+ */
802
+ function isRangeFresh(req, res) {
803
+ const ifRange = req.headers["if-range"];
804
+ if (!ifRange) {
805
+ return true;
806
+ }
807
+
808
+ // if-range as etag
809
+ if (ifRange.indexOf('"') !== -1) {
810
+ const etag = res.get("etag");
811
+ return Boolean(etag && ifRange.indexOf(etag) !== -1);
812
+ }
813
+
814
+ // if-range as modified date
815
+ const lastModified = res.get("Last-Modified");
816
+ return parseHttpDate(lastModified) <= parseHttpDate(ifRange);
817
+ }
818
+
819
+ /**
820
+ * Escapes the five characters that would otherwise be markup. Written as a scan rather than a
821
+ * chain of replaces because it runs on every error page and every redirect body.
822
+ *
823
+ * @param {string} str
824
+ * @returns {string}
825
+ */
826
+ function escapeHtml(str) {
827
+ const s = String(str);
828
+ const len = s.length;
829
+ let i = 0;
830
+
831
+ // Fast scan: find first char that needs escaping
832
+ for (; i < len; i++) {
833
+ const ch = s.charCodeAt(i);
834
+ if (ch === 0x26 || ch === 0x3c || ch === 0x3e || ch === 0x22 || ch === 0x27) {
835
+ break;
836
+ }
837
+ }
838
+
839
+ // No escaping needed
840
+ if (i === len) return s;
841
+
842
+ // Build escaped string from the first match onward
843
+ let escaped = s.substring(0, i);
844
+
845
+ for (; i < len; i++) {
846
+ const ch = s.charCodeAt(i);
847
+ switch (ch) {
848
+ case 0x26: // &
849
+ escaped += "&amp;";
850
+ break;
851
+ case 0x3c: // <
852
+ escaped += "&lt;";
853
+ break;
854
+ case 0x3e: // >
855
+ escaped += "&gt;";
856
+ break;
857
+ case 0x22: // "
858
+ escaped += "&quot;";
859
+ break;
860
+ case 0x27: // '
861
+ escaped += "&#39;";
862
+ break;
863
+ default:
864
+ escaped += s.charAt(i);
865
+ break;
866
+ }
867
+ }
868
+
869
+ return escaped;
870
+ }
871
+
872
+ // a charset parameter that is already there, and the whole parameter so it can be replaced
873
+ const CHARSET_PRESENT = /;\s*charset\s*=/i;
874
+ const CHARSET_PARAM = /;\s*charset\s*=\s*[^;]*/i;
875
+ const UTF8_CHARSET = "; charset=utf-8";
876
+
877
+ /**
878
+ * The value plus the charset its media type implies: text/* gets one, and so does any type whose
879
+ * mime database entry names one. Memoized, since an application sends two or three content-types
880
+ * and working it out again costs 159ns against 7.
881
+ *
882
+ * @param {string} value
883
+ * @returns {string}
884
+ */
885
+ const withDefaultCharset = memoizeByString((value) => {
886
+ if (CHARSET_PRESENT.test(value)) {
887
+ return value;
888
+ }
889
+ const charset = mime.charset(value.split(";")[0]);
890
+ return charset ? `${value}; charset=${charset.toLowerCase()}` : value;
891
+ });
892
+
893
+ /**
894
+ * The same content-type, saying utf-8. A string body is written as utf-8 whatever the header
895
+ * claimed, so a header claiming otherwise is wrong on the wire, and Express replaces it too.
896
+ *
897
+ * @param {string} value
898
+ * @returns {string}
899
+ */
900
+ function withUtf8Charset(value) {
901
+ // almost every string body already carries the header in this exact form, and the pair of
902
+ // regular expressions below was 2% of the time spent serving a request
903
+ if (value.endsWith(UTF8_CHARSET)) {
904
+ return value;
905
+ }
906
+ return CHARSET_PARAM.test(value) ? value.replace(CHARSET_PARAM, UTF8_CHARSET) : `${value}${UTF8_CHARSET}`;
907
+ }
908
+
909
+ // The status send picks for a failed stat. Anything else is the file being there but unreadable,
910
+ // which is the server's problem and not the request's.
911
+ const STAT_ERROR_STATUS = { ENAMETOOLONG: 404, ENOTDIR: 404, ENOENT: 404 };
912
+
913
+ /**
914
+ * The error send and serve-static refuse with, so that the file serving here refuses the same way.
915
+ *
916
+ * They build these with http-errors, so they carry `status`, `statusCode` and `expose`, which is
917
+ * what `res.status(err.status || 500)` reads. Without a status a 403, a 404 and a 416 all came out
918
+ * of that handler as 500. The message is the status's own name, as http-errors writes it.
919
+ *
920
+ * @param {number} status
921
+ * @returns {any}
922
+ */
923
+ function httpError(status) {
924
+ const err = /** @type {any} */ (new Error(statuses.message[status]));
925
+ err.expose = status < 500;
926
+ err.statusCode = status;
927
+ err.status = status;
928
+ return err;
929
+ }
930
+
931
+ /**
932
+ * Marks an fs error the way send does before it is handed on, so an error handler reading
933
+ * err.status or err.statusCode finds what it would find behind Express. The three properties are
934
+ * assigned in this order because they are serialised in insertion order, and an error handler that
935
+ * answers with res.send(err) sends them.
936
+ *
937
+ * The error itself is returned rather than a new one, so its errno, code, syscall and path survive.
938
+ *
939
+ * @param {any} err
940
+ * @returns {any} the same error
941
+ */
942
+ function asStatError(err) {
943
+ err.expose = false;
944
+ err.statusCode = STAT_ERROR_STATUS[err.code] ?? 500;
945
+ err.status = err.statusCode;
946
+ return err;
947
+ }
948
+
949
+ // fast null object
950
+ // A constructor whose instances have no prototype, so a key from a request body or a query string
951
+ // cannot reach Object.prototype. Typed as returning a plain record: without that, assigning one
952
+ // reads as assigning `any`, which resets narrowing instead of removing undefined from it.
953
+ /** @type {new () => Record<string, any>} */
954
+ const NullObject = /** @type {any} */ (function () {});
955
+ NullObject.prototype = Object.create(null);
956
+
957
+ module.exports = {
958
+ removeDuplicateSlashes,
959
+ patternToRegex,
960
+ getPatternMeta,
961
+ needsConversionToRegex,
962
+ acceptParams,
963
+ normalizeType,
964
+ stringify,
965
+ defaultSettings,
966
+ compileTrust,
967
+ deprecated,
968
+ UP_PATH_REGEXP,
969
+ NullObject,
970
+ decode,
971
+ decodeParam,
972
+ containsDotFile,
973
+ parseTokenList,
974
+ parseHttpDate,
975
+ isPreconditionFailure,
976
+ createETagGenerator,
977
+ entityTag,
978
+ statTag,
979
+ contentTypeFor,
980
+ memoizeByString,
981
+ isRangeFresh,
982
+ findIndexStartingFrom,
983
+ fastQueryParse,
984
+ canBeOptimized,
985
+ canBeOptimizedWithParams,
986
+ pathsCanOverlap,
987
+ escapeHtml,
988
+ withDefaultCharset,
989
+ withUtf8Charset,
990
+ asStatError,
991
+ httpError,
992
+ EMPTY_REGEX
993
+ };