fulmine.js 5.6.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/compression.js +62 -17
- package/src/middlewares.js +101 -12
- package/src/options.d.ts +5 -1
- package/src/utils.js +33 -5
package/README.md
CHANGED
|
@@ -374,7 +374,7 @@ Worth changing, if these are routes that carry traffic
|
|
|
374
374
|
|
|
375
375
|
It loads the application with `listen()` replaced by the half that compiles the routes, so nothing binds a port and the listen callback does not run: profiling a running service does not start a second copy of it. There is no score, on purpose. A percentage of routes is not a percentage of traffic, and an application with a thousand cold routes and one hot one that fell back would score well and serve badly.
|
|
376
376
|
|
|
377
|
-
2. Do not use external `serve-static` module. Instead use built-in `express.static()` middleware, which is optimized for Fulmine. If your build already writes `.br` and `.gz` files next to the originals, `express.static(dir, { preCompressed: true })` serves those to the clients that accept them, so nothing is compressed at request time. It costs
|
|
377
|
+
2. Do not use external `serve-static` module. Instead use built-in `express.static()` middleware, which is optimized for Fulmine. If your build already writes `.br` and `.gz` files next to the originals, `express.static(dir, { preCompressed: true })` serves those to the clients that accept them, so nothing is compressed at request time and a fraction of the bytes goes out: on a 4KB script with a brotli twin, 12 times fewer. It costs no more than serving the file itself, one `stat` per request, because the twin is looked for before the file and its own `stat` is the only one the request needs. A type that is already compressed, a woff2 or a webp, is not looked up at all, and which twins a path has is remembered for a second: `{ cache: false }` asks the disk every time, `{ cache: "5s" }` sets the window. Only their presence is remembered, never their size or mtime, so nothing is ever described by a stale number. `Vary: Accept-Encoding` is sent whether or not a twin is found, the content type stays the one the requested name implies, and each variant carries its own ETag.
|
|
378
378
|
|
|
379
379
|
3. Do not use `body-parser` module. Instead use built-in `express.text()`, `express.json()` etc.
|
|
380
380
|
|
package/package.json
CHANGED
package/src/compression.js
CHANGED
|
@@ -36,11 +36,31 @@ limitations under the License.
|
|
|
36
36
|
const zlib = require("zlib");
|
|
37
37
|
const bytes = require("bytes");
|
|
38
38
|
const compressible = require("compressible");
|
|
39
|
-
const { negotiateEncoding, ENCODING_ANY } = require("./utils.js");
|
|
39
|
+
const { negotiateEncoding, ENCODING_ANY, memoizeByString } = require("./utils.js");
|
|
40
40
|
|
|
41
41
|
// Cache-Control: no-transform forbids recoding the body, which is what this does
|
|
42
42
|
const NO_TRANSFORM = /(?:^|,)\s*?no-transform\s*?(?:,|$)/;
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Says the answer depends on Accept-Encoding. res.vary() parses what is there and merges, which on
|
|
46
|
+
* the usual response is parsing an absent header: only a response that already varies pays for it.
|
|
47
|
+
*
|
|
48
|
+
* @param {any} res
|
|
49
|
+
*/
|
|
50
|
+
function addVary(res) {
|
|
51
|
+
if (res.getHeader("Vary") === undefined) {
|
|
52
|
+
res.setHeader("Vary", "Accept-Encoding");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
res.vary("Accept-Encoding");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* res.flush for a response that is not being compressed. The compression module puts a function
|
|
60
|
+
* there on every response it sees, and code written against it calls one without asking first.
|
|
61
|
+
*/
|
|
62
|
+
function noFlush() {}
|
|
63
|
+
|
|
44
64
|
// what enforceEncoding is allowed to name, the compression module's list
|
|
45
65
|
const ENFORCEABLE = new Set(["gzip", "deflate", "identity", "br"]);
|
|
46
66
|
|
|
@@ -61,12 +81,16 @@ const SYNC_LIMIT = 24 * 1024;
|
|
|
61
81
|
*/
|
|
62
82
|
function shouldCompress(req, res) {
|
|
63
83
|
const type = res.getHeader("Content-Type");
|
|
64
|
-
if (type === undefined
|
|
84
|
+
if (type === undefined) {
|
|
65
85
|
return false;
|
|
66
86
|
}
|
|
67
|
-
|
|
87
|
+
// memoized, because an application answers with two or three content-types and compressible
|
|
88
|
+
// splits the parameters off and searches the mime database to reach the same answer each time
|
|
89
|
+
return isCompressible(typeof type === "string" ? type : String(type));
|
|
68
90
|
}
|
|
69
91
|
|
|
92
|
+
const isCompressible = memoizeByString((type) => compressible(type) === true);
|
|
93
|
+
|
|
70
94
|
/**
|
|
71
95
|
* How many bytes a chunk is, which is what the threshold is compared against.
|
|
72
96
|
*
|
|
@@ -184,6 +208,37 @@ function compression(options) {
|
|
|
184
208
|
}
|
|
185
209
|
|
|
186
210
|
return function compression(req, res, next) {
|
|
211
|
+
// Negotiated here rather than when the body arrives, because the answer to "could this
|
|
212
|
+
// request take a compressed body at all" decides how much of this middleware the response
|
|
213
|
+
// has to carry. Most requests to most routes cannot: a client that sent no Accept-Encoding,
|
|
214
|
+
// one that refused everything, a HEAD. Those get the Vary and nothing else, since the
|
|
215
|
+
// answer still depends on the header even when this particular client did not ask.
|
|
216
|
+
const accept = req.headers["accept-encoding"];
|
|
217
|
+
let chosen = negotiateEncoding(accept === undefined ? "" : accept, ENCODING_ANY);
|
|
218
|
+
if (accept === undefined && ENFORCEABLE.has(enforceEncoding)) {
|
|
219
|
+
chosen = enforceEncoding;
|
|
220
|
+
}
|
|
221
|
+
if (!chosen || chosen === "identity" || req.method === "HEAD") {
|
|
222
|
+
res.flush = noFlush;
|
|
223
|
+
const _plainEnd = res.end;
|
|
224
|
+
let varied = false;
|
|
225
|
+
res.end = function end(chunk, encoding, callback) {
|
|
226
|
+
if (!varied) {
|
|
227
|
+
varied = true;
|
|
228
|
+
const cacheControl = res.headersSent ? undefined : res.getHeader("Cache-Control");
|
|
229
|
+
if (
|
|
230
|
+
!res.headersSent &&
|
|
231
|
+
filter(req, res) &&
|
|
232
|
+
!(cacheControl && NO_TRANSFORM.test(String(cacheControl)))
|
|
233
|
+
) {
|
|
234
|
+
addVary(res);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return _plainEnd.call(this, chunk, encoding, callback);
|
|
238
|
+
};
|
|
239
|
+
return next();
|
|
240
|
+
}
|
|
241
|
+
|
|
187
242
|
const _write = res.write;
|
|
188
243
|
const _end = res.end;
|
|
189
244
|
const _on = res.on;
|
|
@@ -243,7 +298,7 @@ function compression(options) {
|
|
|
243
298
|
if (cacheControl && NO_TRANSFORM.test(String(cacheControl))) {
|
|
244
299
|
return noCompress();
|
|
245
300
|
}
|
|
246
|
-
res
|
|
301
|
+
addVary(res);
|
|
247
302
|
// NaN when there is no Content-Length, and a comparison against NaN is false: a body
|
|
248
303
|
// whose size is not known yet is compressed whatever the threshold says
|
|
249
304
|
if (Number(res.getHeader("Content-Length")) < threshold || Number(length) < threshold) {
|
|
@@ -253,27 +308,17 @@ function compression(options) {
|
|
|
253
308
|
if (already && already !== "identity") {
|
|
254
309
|
return noCompress();
|
|
255
310
|
}
|
|
256
|
-
if (req.method === "HEAD") {
|
|
257
|
-
return noCompress();
|
|
258
|
-
}
|
|
259
311
|
// a range is a window into the bytes on disk, and a client that asked for one cannot
|
|
260
312
|
// decode a compressed answer to it
|
|
261
313
|
if (res.statusCode === 206 || res.getHeader("Content-Range") !== undefined) {
|
|
262
314
|
return noCompress();
|
|
263
315
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (accept === undefined && ENFORCEABLE.has(enforceEncoding)) {
|
|
267
|
-
method = enforceEncoding;
|
|
268
|
-
}
|
|
269
|
-
if (!method || method === "identity") {
|
|
270
|
-
return noCompress();
|
|
271
|
-
}
|
|
272
|
-
res.setHeader("Content-Encoding", method);
|
|
316
|
+
// HEAD never reaches here: it took the Vary-only path above
|
|
317
|
+
res.setHeader("Content-Encoding", chosen);
|
|
273
318
|
// what it says is the size of the body before this middleware saw it. The whole-body
|
|
274
319
|
// path below puts the right one back; the streaming one cannot know it in advance
|
|
275
320
|
res.removeHeader("Content-Length");
|
|
276
|
-
return
|
|
321
|
+
return chosen;
|
|
277
322
|
}
|
|
278
323
|
|
|
279
324
|
/**
|
package/src/middlewares.js
CHANGED
|
@@ -23,6 +23,8 @@ const bytes = require("bytes");
|
|
|
23
23
|
const zlib = require("fast-zlib");
|
|
24
24
|
const typeis = require("type-is");
|
|
25
25
|
const mime = require("mime-types");
|
|
26
|
+
const compressible = require("compressible");
|
|
27
|
+
const ms = require("ms");
|
|
26
28
|
const qs = require("qs");
|
|
27
29
|
const parseQuery = require("./parse-query.js");
|
|
28
30
|
const { kGetSafe } = require("./usage.js");
|
|
@@ -267,6 +269,52 @@ function bodyError(message, status, type, extra) {
|
|
|
267
269
|
return Object.assign(err, extra);
|
|
268
270
|
}
|
|
269
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Whether a file of this extension is one anybody writes a `.br` or a `.gz` next to. A webp or a
|
|
274
|
+
* woff2 is already compressed and never has a twin, and looking for one costs two stats on a
|
|
275
|
+
* request that could not have used it: on a mixed directory that is most of the stat time. An
|
|
276
|
+
* extension nothing knows is looked up anyway, since it might well be text.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} extension including the dot, or "" for a name without one
|
|
279
|
+
* @returns {boolean}
|
|
280
|
+
*/
|
|
281
|
+
const hasTwins = memoizeByString((extension) => {
|
|
282
|
+
const type = mime.lookup(extension);
|
|
283
|
+
return type ? compressible(type) === true : true;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Which twins a path has, remembered for a moment. What is cached is only whether they are there,
|
|
287
|
+
// never their size or their mtime: those decide the ETag, the Last-Modified and the length, so they
|
|
288
|
+
// are read fresh on every request and a file that changed is never described by a stale number.
|
|
289
|
+
// The worst a stale entry can do is serve the file where it could have served the twin, or look for
|
|
290
|
+
// a twin that has just been deleted and fall back. nginx's open_file_cache is the same trade.
|
|
291
|
+
const twinCache = new Map();
|
|
292
|
+
const TWIN_CACHE_LIMIT = 4096;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* What is known about a path's twins right now, as a record to fill in.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} filePath
|
|
298
|
+
* @param {number} ttl how long an answer stays good, in milliseconds
|
|
299
|
+
* @returns {{br: boolean|undefined, gz: boolean|undefined, until: number}}
|
|
300
|
+
*/
|
|
301
|
+
function twinsOf(filePath, ttl) {
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
const known = twinCache.get(filePath);
|
|
304
|
+
if (known !== undefined && known.until > now) {
|
|
305
|
+
return known;
|
|
306
|
+
}
|
|
307
|
+
const entry = { br: undefined, gz: undefined, until: now + ttl };
|
|
308
|
+
// cleared rather than evicted one by one, as memoizeByString does: this holds one small object
|
|
309
|
+
// per path served, and a directory big enough to reach the limit is being served by something
|
|
310
|
+
// other than an application server anyway
|
|
311
|
+
if (twinCache.size >= TWIN_CACHE_LIMIT) {
|
|
312
|
+
twinCache.clear();
|
|
313
|
+
}
|
|
314
|
+
twinCache.set(filePath, entry);
|
|
315
|
+
return entry;
|
|
316
|
+
}
|
|
317
|
+
|
|
270
318
|
/**
|
|
271
319
|
* The compressed twin of a file to serve in its place, or undefined when the client would rather
|
|
272
320
|
* have the file itself or the twin is not there.
|
|
@@ -275,17 +323,19 @@ function bodyError(message, status, type, extra) {
|
|
|
275
323
|
* Last-Modified of a variant are its own, which is the whole point. Two bodies sharing one ETag is
|
|
276
324
|
* how a shared cache ends up handing brotli to a client that cannot read it.
|
|
277
325
|
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
326
|
+
* One stat when the answer is a twin, and none at all when the last request already found there is
|
|
327
|
+
* no twin to have. See twinCache above for what is remembered and what is not.
|
|
280
328
|
*
|
|
281
329
|
* @param {string} filePath absolute path of the file that was asked for
|
|
282
330
|
* @param {string|undefined} accept the request's Accept-Encoding
|
|
331
|
+
* @param {number} ttl how long the twin cache holds an answer, 0 to ask the disk every time
|
|
283
332
|
* @returns {{suffix: string, encoding: string, stat: import("fs").Stats}|undefined}
|
|
284
333
|
*/
|
|
285
|
-
function pickPrecompressed(filePath, accept) {
|
|
286
|
-
if (!accept) {
|
|
334
|
+
function pickPrecompressed(filePath, accept, ttl) {
|
|
335
|
+
if (!accept || !hasTwins(filePath.slice(filePath.lastIndexOf(".")))) {
|
|
287
336
|
return undefined;
|
|
288
337
|
}
|
|
338
|
+
const known = ttl > 0 ? twinsOf(filePath, ttl) : undefined;
|
|
289
339
|
let allowed = ENCODING_BR | ENCODING_GZIP;
|
|
290
340
|
// twice at most: the second pass is the case where brotli won and there is no .br on disk
|
|
291
341
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -294,13 +344,17 @@ function pickPrecompressed(filePath, accept) {
|
|
|
294
344
|
if (!variant) {
|
|
295
345
|
return undefined;
|
|
296
346
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
347
|
+
if (known === undefined || known[variant.encoding === "br" ? "br" : "gz"] !== false) {
|
|
348
|
+
try {
|
|
349
|
+
const stat = fs.statSync(filePath + variant.suffix);
|
|
350
|
+
if (!stat.isDirectory()) {
|
|
351
|
+
if (known !== undefined) known[variant.encoding === "br" ? "br" : "gz"] = true;
|
|
352
|
+
return { suffix: variant.suffix, encoding: variant.encoding, stat };
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
// not on disk, which is the ordinary case for a file nobody precompressed
|
|
301
356
|
}
|
|
302
|
-
|
|
303
|
-
// not on disk, which is the ordinary case for a file nobody precompressed
|
|
357
|
+
if (known !== undefined) known[variant.encoding === "br" ? "br" : "gz"] = false;
|
|
304
358
|
}
|
|
305
359
|
allowed &= ~variant.flag;
|
|
306
360
|
}
|
|
@@ -343,6 +397,26 @@ function serveStatic(root, options) {
|
|
|
343
397
|
if (options.setHeaders !== undefined && typeof options.setHeaders !== "function") {
|
|
344
398
|
throw new TypeError("option setHeaders must be function");
|
|
345
399
|
}
|
|
400
|
+
// How long express.static remembers which twins a path has. A second is short enough that a
|
|
401
|
+
// deploy is picked up while it is still going out, and long enough that the lookup costs
|
|
402
|
+
// nothing under any traffic at all. { cache: false } asks the disk on every request.
|
|
403
|
+
let twinTtl = 0;
|
|
404
|
+
if (options.preCompressed) {
|
|
405
|
+
const cache = /** @type {any} */ (
|
|
406
|
+
typeof options.preCompressed === "object" ? options.preCompressed.cache : undefined
|
|
407
|
+
);
|
|
408
|
+
twinTtl =
|
|
409
|
+
cache === undefined
|
|
410
|
+
? 1000
|
|
411
|
+
: cache === false
|
|
412
|
+
? 0
|
|
413
|
+
: typeof cache === "string"
|
|
414
|
+
? ms(/** @type {any} */ (cache))
|
|
415
|
+
: cache;
|
|
416
|
+
if (typeof twinTtl !== "number" || !(twinTtl >= 0)) {
|
|
417
|
+
throw new TypeError("option preCompressed.cache must be a duration");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
346
420
|
options.root = root;
|
|
347
421
|
// serve-static decides this for itself and never asks the app, so a static file keeps its
|
|
348
422
|
// ETag under app.set("etag", false) and only { etag: false } here turns it off. res.sendFile
|
|
@@ -434,8 +508,22 @@ function serveStatic(root, options) {
|
|
|
434
508
|
}
|
|
435
509
|
|
|
436
510
|
let stat;
|
|
511
|
+
// The twin, looked for before the file itself rather than after it. When there is one, it
|
|
512
|
+
// is the file being served and its stat is the only one this request needs: the request
|
|
513
|
+
// that asks for /app.js and gets /app.js.br has no use for /app.js's size or mtime. A
|
|
514
|
+
// directory, or a path written with a trailing slash, keeps the ordinary order, since what
|
|
515
|
+
// decides those is the stat of the thing that was asked for.
|
|
516
|
+
let twin;
|
|
517
|
+
if (options.preCompressed && !rawPath.endsWith("/") && !req.endsWithSlash) {
|
|
518
|
+
twin = pickPrecompressed(filePath, req.headers["accept-encoding"], twinTtl);
|
|
519
|
+
if (twin) {
|
|
520
|
+
stat = twin.stat;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
437
523
|
try {
|
|
438
|
-
stat
|
|
524
|
+
if (stat === undefined) {
|
|
525
|
+
stat = fs.statSync(statTarget);
|
|
526
|
+
}
|
|
439
527
|
} catch (err) {
|
|
440
528
|
// the one to report when nothing is found: send hands each failed attempt to the next
|
|
441
529
|
// one and reports whichever came last, so an extensions option that also missed names
|
|
@@ -541,7 +629,8 @@ function serveStatic(root, options) {
|
|
|
541
629
|
// whatever is served, the answer depended on the header, so a shared cache has to be
|
|
542
630
|
// told. Said before the lookup, because it is true even when there is no variant
|
|
543
631
|
res.vary("Accept-Encoding");
|
|
544
|
-
|
|
632
|
+
// already found before the stat below, on the ordinary path
|
|
633
|
+
const variant = twin ?? pickPrecompressed(filePath, req.headers["accept-encoding"], twinTtl);
|
|
545
634
|
if (variant) {
|
|
546
635
|
_path += variant.suffix;
|
|
547
636
|
stat = variant.stat;
|
package/src/options.d.ts
CHANGED
|
@@ -72,8 +72,12 @@ export interface StaticOptions extends SendFileOptions {
|
|
|
72
72
|
* Serve `file.br` or `file.gz` in place of `file` when one is on disk and the client takes it.
|
|
73
73
|
* Off by default. Vary: Accept-Encoding is sent whether or not a variant is found, and the
|
|
74
74
|
* content type stays the one the requested name implies.
|
|
75
|
+
*
|
|
76
|
+
* Which twins a path has is remembered for a second, since asking the disk costs a stat per
|
|
77
|
+
* request; `{ cache: false }` asks every time, and a duration sets how long. Only their
|
|
78
|
+
* presence is cached, never their size or mtime.
|
|
75
79
|
*/
|
|
76
|
-
preCompressed?: boolean;
|
|
80
|
+
preCompressed?: boolean | { cache?: number | string | false };
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
/** A body parser's options once its factory has filled in every default it needs. */
|
package/src/utils.js
CHANGED
|
@@ -220,9 +220,13 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict
|
|
|
220
220
|
groupOutputName.set(group, name);
|
|
221
221
|
return group;
|
|
222
222
|
};
|
|
223
|
-
// whether the token just emitted was a :parameter, which decides how greedy the
|
|
224
|
-
// optional group is allowed to be. see the comment where it is read
|
|
223
|
+
// whether the token just emitted was a :parameter or a wildcard, which decides how greedy the
|
|
224
|
+
// next optional group is allowed to be. see the comment where it is read
|
|
225
225
|
let lastTokenWasParam = false;
|
|
226
|
+
// the wildcard just emitted, and where it ends, so an optional group written right after it
|
|
227
|
+
// can rewrite the two into one alternation. See the { branch
|
|
228
|
+
let lastWildcard = /** @type {{start: number, body: string, name: string}|null} */ (null);
|
|
229
|
+
let lastWildcardEnd = -1;
|
|
226
230
|
// What path-to-regexp calls the wildcard backtrack: the literal text written since the last
|
|
227
231
|
// wildcard. Once a wildcard has eaten slashes, a later one in the same path is held to a single
|
|
228
232
|
// segment, or the two would divide the path between them in more than one way and the regex
|
|
@@ -339,8 +343,16 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict
|
|
|
339
343
|
}
|
|
340
344
|
const splatGroup = uniqueGroupName(name);
|
|
341
345
|
wildcardNames.push(splatGroup);
|
|
342
|
-
|
|
343
|
-
|
|
346
|
+
const body = wildcardClass();
|
|
347
|
+
// where this capture starts and what it is made of, so an optional group written right
|
|
348
|
+
// after it can rewrite the pair into the alternation path-to-regexp compiles. See the
|
|
349
|
+
// { branch below
|
|
350
|
+
lastWildcard = { start: regexPattern.length, body, name };
|
|
351
|
+
regexPattern += `(?<${splatGroup}>${body})`;
|
|
352
|
+
lastWildcardEnd = regexPattern.length;
|
|
353
|
+
// the group that follows is held to one segment of its own, the way it is after a
|
|
354
|
+
// parameter: without it ext could take the separator back and swallow the dots
|
|
355
|
+
lastTokenWasParam = true;
|
|
344
356
|
continue;
|
|
345
357
|
}
|
|
346
358
|
|
|
@@ -417,7 +429,23 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict
|
|
|
417
429
|
gi++;
|
|
418
430
|
}
|
|
419
431
|
}
|
|
420
|
-
regexPattern
|
|
432
|
+
if (lastWildcard && lastWildcardEnd === regexPattern.length) {
|
|
433
|
+
// A wildcard immediately before the group. `(?<w>[^]+)(?:group)?` can never let
|
|
434
|
+
// the group match, because the wildcard is greedy and the group may be empty, and
|
|
435
|
+
// making the wildcard lazy is not the same thing either: it gives the trailing
|
|
436
|
+
// slash away, and /*path{.:ext} against /a/b/ then loses the empty last segment.
|
|
437
|
+
// path-to-regexp writes the two branches out instead, group first and the
|
|
438
|
+
// wildcard greedy in both, so that is what goes here. The second branch captures
|
|
439
|
+
// the same parameter under a name of its own, which is what uniqueGroupName is for.
|
|
440
|
+
const second = uniqueGroupName(lastWildcard.name);
|
|
441
|
+
wildcardNames.push(second);
|
|
442
|
+
const withWildcard = regexPattern.slice(lastWildcard.start);
|
|
443
|
+
regexPattern =
|
|
444
|
+
regexPattern.slice(0, lastWildcard.start) +
|
|
445
|
+
`(?:${withWildcard}${groupRegex}|(?<${second}>${lastWildcard.body}))`;
|
|
446
|
+
} else {
|
|
447
|
+
regexPattern += `(?:${groupRegex})?`;
|
|
448
|
+
}
|
|
421
449
|
literal(groupContent);
|
|
422
450
|
lastTokenWasParam = false;
|
|
423
451
|
continue;
|