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 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 one more `stat` per request and sends a fraction of the bytes: on a 4KB script with a brotli twin, 12 times fewer. `Vary: Accept-Encoding` is sent whether or not a variant is found, the content type stays the one the requested name implies, and each variant carries its own ETag.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.6.0",
3
+ "version": "5.7.0",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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 || !compressible(String(type))) {
84
+ if (type === undefined) {
65
85
  return false;
66
86
  }
67
- return true;
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.vary("Accept-Encoding");
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
- const accept = req.headers["accept-encoding"];
265
- let method = negotiateEncoding(accept === undefined ? "" : accept, ENCODING_ANY);
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 method;
321
+ return chosen;
277
322
  }
278
323
 
279
324
  /**
@@ -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
- * At most two stats, and usually one: negotiation picks the best of the two encodings first, and
279
- * only looks at the other when the client takes it too and the first file is missing.
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
- try {
298
- const stat = fs.statSync(filePath + variant.suffix);
299
- if (!stat.isDirectory()) {
300
- return { suffix: variant.suffix, encoding: variant.encoding, stat };
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
- } catch {
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 = fs.statSync(statTarget);
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
- const variant = pickPrecompressed(filePath, req.headers["accept-encoding"]);
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 next
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
- regexPattern += `(?<${splatGroup}>${wildcardClass()})`;
343
- lastTokenWasParam = false;
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 += `(?:${groupRegex})?`;
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;