fulmine.js 5.6.0 → 5.8.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
@@ -18,10 +18,12 @@ import type { Request, Response } from "fulmine.js";
18
18
  There is a command that does that replacing for you, across a whole project, and then tells you the handful of things that behave differently:
19
19
 
20
20
  ```sh
21
+ npx fulmine verify # can this machine and this image even run it
21
22
  npx fulmine migrate --dry-run # say what it would change, change nothing
22
23
  npx fulmine migrate # do it
23
24
  npx fulmine differences # just the list of what to check by hand
24
25
  npx fulmine profile # what listen() decided about each route
26
+ npx fulmine explain /api/items # what happens when a request for that route arrives
25
27
  ```
26
28
 
27
29
  See [Migrating](#migrating) for what it handles and what it deliberately does not.
@@ -128,6 +130,23 @@ It also names the middlewares it found that have a faster one built in here, `co
128
130
  `body-parser` and `serve-static`, and leaves them to you: the replacement is reached through the
129
131
  `express` import, and no rewrite can know that it is in scope where they are required.
130
132
 
133
+ `npx fulmine verify` is the question that comes before all of that: whether this machine, and the
134
+ image this will be deployed in, can run it at all. There is a µWebSockets.js binary underneath, and
135
+ a binary is built per platform, per architecture and per node ABI, and linked against glibc. An
136
+ Alpine base, a node version the pinned build has no binary for, a `FROM node:20-alpine` written
137
+ years ago: each one fails at require time, in a container, with a message about a missing module.
138
+ This says so in thirty seconds, and exits non-zero when something would stop the start.
139
+
140
+ ```text
141
+ ok Node 22.15.0
142
+ ok glibc 2.39
143
+ ok µWebSockets.js binary for linux x64, node ABI 127
144
+ NO Dockerfile: node:20-alpine
145
+ musl, and there is no musl build: node:22-trixie-slim is the closest swap.
146
+ note socket.io needs a different API here
147
+ attach it with io.attachApp(app.uwsApp), not io.attach(server)
148
+ ```
149
+
131
150
  ### Angular SSR
132
151
 
133
152
  The `server.ts` that `ng add @angular/ssr` generates is an ordinary Express application, so the same
@@ -229,7 +248,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
229
248
 
230
249
  ## Differences from Express
231
250
 
232
- - `app.listen()` returns the app, not an `http.Server`. There is no node server underneath, so `server.close()`, `server.address()` and anything that attaches itself to a real `http.Server` need a look. `app.close()`, `app.address()` and `app.listening` are there and do what you would expect.
251
+ - `app.listen()` returns the app, not an `http.Server`, because there is no node server underneath: the socket belongs to µWS. The app answers as one anyway, which is what the graceful shutdown wrappers and the connection trackers look for. `app instanceof http.Server` is true, and `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family are all there. What is not there is the plumbing that carries node sockets: nothing emits `connection`, `request` or `upgrade`, `getConnections()` counts the requests in flight rather than sockets, and the timeouts belong to µWS and are set through `uwsOptions.idleTimeout`. Anything that wants to serve its own protocol on the socket, socket.io being the usual case, still wants `app.uwsApp`.
233
252
  - `x-powered-by` is disabled by default. Express sends `X-Powered-By: Express` unless you turn it off; Fulmine does not send it unless you turn it on with `app.set("x-powered-by", true)`. The header only tells anyone asking which framework is running.
234
253
  - request body is only read for POST, PUT, PATCH and QUERY requests by default. You can add additional methods by setting `body methods` to array with uppercased methods.
235
254
  - **Informational responses go nowhere.** `res.writeEarlyHints()`, `res.writeContinue()` and `res.writeProcessing()` are all there, take what node's take and throw what node's throw once the head has gone out, but nothing reaches the wire: µWebSockets.js has no API for a `1xx`. They exist so that code written for Express keeps running rather than dying on "is not a function", which is the only thing a drop-in can honestly promise here. `res.addTrailers()` is the same story, and `res.setTimeout()` and `req.setTimeout()` register the listener without changing anything, since µWS runs its own idle timeout through `uwsOptions.idleTimeout`.
@@ -374,7 +393,41 @@ Worth changing, if these are routes that carry traffic
374
393
 
375
394
  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
395
 
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.
396
+ The same verdicts are readable from a test, which is where they belong for the routes that carry the traffic. A route stays on the fast path only while it stays eligible, and nothing complains when it stops: the answer is still correct, only slower, and the commit that did it is found weeks later.
397
+
398
+ ```js
399
+ const { expectNative, expectDeclarative, routeReport } = require("fulmine.js").testing;
400
+
401
+ expectNative(app, ["/api/*", "GET /health"]); // throws, naming the route and the reason
402
+ expectDeclarative(app, "/health"); // the step past native: no javascript at all
403
+ routeReport(app); // the whole list, to assert on however you like
404
+ ```
405
+
406
+ A path is written as it was registered, `"/users/:id"` and not `"/users/7"`, and a trailing `*` names everything under a prefix. A pattern that matches no route throws too, so a misspelled path fails instead of passing quietly. The application does not need to be listening.
407
+
408
+ `npx fulmine explain /api/items/:id` answers the other question, the one about a single endpoint rather than about the table: how it is matched, what is copied out of the request, what runs and what each layer costs the route.
409
+
410
+ ```text
411
+ GET /api/items/:id
412
+
413
+ route native (µWS matched /api/items/:x and dispatched by method)
414
+ headers copied out of µWS (something in the chain reads them)
415
+ query parsed when something asks for it
416
+ chain 2 layer(s), 1 mounted layer(s) in front of it
417
+ logger readable at registration, reads the query
418
+ (anonymous) readable at registration
419
+ body read for POST, PUT, PATCH and QUERY, when one is declared
420
+ ```
421
+
422
+ The same verdict reaches the browser, per request, with `express.serverTiming()`:
423
+
424
+ ```text
425
+ Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;dur=4.66
426
+ ```
427
+
428
+ `route;desc="native"` means µWS matched the path in C++ and the chain was worked out at startup; `route;desc="router"` means this one was matched here, layer by layer. `res.timing(name, ms, desc)` and `res.time(name, fn)` add marks of your own, and `fn` may return a promise. The duration ends where the header does, since Server-Timing goes out with the head. A route compiled into a response never enters JavaScript, so nothing times it: `npx fulmine profile` is where those are counted.
429
+
430
+ 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
431
 
379
432
  3. Do not use `body-parser` module. Instead use built-in `express.text()`, `express.json()` etc.
380
433
 
@@ -519,8 +572,15 @@ In general, basically all features and options are supported. Use the [Express 5
519
572
  - ✅ express.json()
520
573
  - ✅ express.urlencoded()
521
574
  - ✅ express.static()
575
+ - - ✅ options.index, options.redirect, options.fallthrough, options.extensions
576
+ - - ✅ options.dotfiles, plus `"ignore_files"`, which is Fulmine's own: it hides a dotfile that is the last segment while letting a dotted directory through
577
+ - - ✅ options.setHeaders, options.headers
578
+ - - ✅ options.etag, options.lastModified, options.maxAge, options.immutable, options.cacheControl, options.acceptRanges
579
+ - - ✅ options.preCompressed, Fulmine's own: serve the `.br` or `.gz` twin on disk, described under [Performance tips](#performance-tips)
522
580
  - ✅ express.text()
523
581
  - ✅ express.raw()
582
+ - ✅ express.serverTiming(). Fulmine's own: Server-Timing carrying how the request was routed, described under [Performance tips](#performance-tips).
583
+ - ✅ express.testing. Fulmine's own: `expectNative`, `expectDeclarative` and `routeReport`, described under [Performance tips](#performance-tips).
524
584
  - ✅ express.compression(). Fulmine's own, since Express has none: it is the [compression](https://npmjs.com/package/compression) module's options and behaviour built in, described under [Performance tips](#performance-tips).
525
585
  - 🚧 express.request (this is not a constructor but a prototype for replacing methods)
526
586
  - 🚧 express.response (this is not a constructor but a prototype for replacing methods)
@@ -554,6 +614,11 @@ In general, basically all features and options are supported. Use the [Express 5
554
614
  - ✅ OPTIONS method
555
615
  - ✅ QUERY method
556
616
 
617
+ What `listen()` hands back is the app, and it answers as an `http.Server` so the shutdown wrappers
618
+ recognise it: `app.close()`, `app.address()`, `app.listening`, `app.getConnections()`, `app.ref()`,
619
+ `app.unref()`, `app.setTimeout()` and the `keepAliveTimeout` family. See
620
+ [Differences from Express](#differences-from-express) for what is behind them and what is not.
621
+
557
622
  ### Application settings
558
623
 
559
624
  - ✅ case sensitive routing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.6.0",
3
+ "version": "5.8.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": {
@@ -28,7 +28,7 @@
28
28
  "benchmark:profile": "node benchmark/profile.js",
29
29
  "release:local": "node tools/release-local.js",
30
30
  "cover:full": "nyc --silent npm run test && nyc --silent --no-clean npm run test:unit && nyc --silent --no-clean npm run test:express && nyc report",
31
- "cover:check": "nyc check-coverage --statements 93 --branches 88 --functions 92 --lines 93",
31
+ "cover:check": "nyc check-coverage --statements 94.5 --branches 90 --functions 94 --lines 94.5",
32
32
  "demo:start": "npm --prefix demo install && npm --prefix demo start",
33
33
  "demo:deploy": "cd demo && fly deploy",
34
34
  "demo:logs": "fly logs --app fulmine-demo"
@@ -36,6 +36,7 @@ const os = require("os");
36
36
  const { Worker } = require("worker_threads");
37
37
  const cluster = require("cluster");
38
38
  const { registerWebSocketRoutes } = require("./websocket.js");
39
+ const { addServerMembers } = require("./server-shape.js");
39
40
 
40
41
  const cpuCount = os.cpus().length;
41
42
 
@@ -871,3 +872,7 @@ module.exports = function (options) {
871
872
  // the class itself, so index.js can expose its prototype as express.application does. Adding a
872
873
  // method to that prototype adds it to every app, which is what the property is for.
873
874
  module.exports.Application = Application;
875
+
876
+ // and what makes an application answer as an http.Server, since that is what a library handed the
877
+ // result of listen() looks for. See server-shape.js for what is answered and what is not.
878
+ addServerMembers(Application.prototype);
package/src/cli.js CHANGED
@@ -26,10 +26,18 @@ limitations under the License.
26
26
  // Prints what listen() worked out about each route and normally keeps to itself: which ones µWS
27
27
  // answers on its own, which ones fell back to the ordinary router and why, and which ones were
28
28
  // compiled all the way down to a response written at startup.
29
+ //
30
+ // npx fulmine verify [dir]
31
+ //
32
+ // Whether this machine and this project can run it at all: the node version, the C library, the
33
+ // µWebSockets.js binary, the base image a Dockerfile names. See src/verify.js.
29
34
 
30
35
  const fs = require("fs");
31
36
  const path = require("path");
32
37
  const acorn = require("acorn");
38
+ // the same walk express.testing asserts on, so the command and the assertions cannot drift
39
+ const { collectRoutes } = require("./testing.js");
40
+ const { verify } = require("./verify.js");
33
41
 
34
42
  const FROM = "express";
35
43
  const TO = "fulmine.js";
@@ -53,9 +61,10 @@ const TYPESCRIPT_EXTENSIONS = new Set([".ts", ".mts", ".cts", ".tsx"]);
53
61
  const DIFFERENCES = [
54
62
  [
55
63
  "app.listen() returns the app, not an http.Server",
56
- "There is no node http server underneath, so anything doing `const server = app.listen(...)` and then\n" +
57
- "reaching for server.close(), server.address() or attaching a websocket library to it needs a look.\n" +
58
- "app.close(), app.address() and app.listening exist and do what you would expect."
64
+ "The app answers as one: instanceof http.Server is true, and close(), address(), listening,\n" +
65
+ "getConnections(), ref(), unref() and setTimeout() are all there. What is missing is the plumbing\n" +
66
+ "that carries node sockets, so nothing emits connection, request or upgrade, and a library that\n" +
67
+ "serves its own protocol on the socket, socket.io being the usual one, wants app.uwsApp instead."
59
68
  ],
60
69
  [
61
70
  "an HTTPS server is configured through express(), not https.createServer()",
@@ -323,45 +332,25 @@ function findEntry(given) {
323
332
  }
324
333
 
325
334
  /**
326
- * Every route of an application and of the routers under it, each with the router it belongs to
327
- * and the path it answers from the outside.
328
- *
329
- * @param {any} router
330
- * @param {string} prefix
331
- * @param {any[]} [into]
332
- * @returns {any[]}
333
- */
334
- function collectRoutes(router, prefix, into = []) {
335
- for (const route of router._routes ?? []) {
336
- const full = prefix + (typeof route.path === "string" ? route.path : String(route.pattern)) || "/";
337
- into.push({ route, full });
338
- const mounted = route.callbacks?.[0];
339
- if (mounted && Array.isArray(mounted._routes)) {
340
- collectRoutes(mounted, typeof route.path === "string" ? prefix + route.path : prefix, into);
341
- }
342
- }
343
- return into;
344
- }
345
-
346
- /**
347
- * Loads an application without letting it listen, and prints what compiling its routes decided.
335
+ * The applications a file builds, compiled but not listening.
348
336
  *
349
337
  * listen() is where the routes are compiled and also where the port is bound, and only the first
350
- * of those is wanted here: an application that answered on its port while being profiled would be
351
- * a surprise, and a second copy of a running service is worse than a surprise. So listen is
352
- * replaced by the half that matters. The callback it was given is not run, for the same reason.
338
+ * of those is wanted here: an application that answered on its port while being read would be a
339
+ * surprise, and a second copy of a running service is worse than a surprise. So listen is replaced
340
+ * by the half that matters, and the callback it was given is not run for the same reason.
353
341
  *
354
342
  * @param {string[]} argv
355
- * @returns {number} exit code
343
+ * @param {string} command the word for the message when there is nothing to load
344
+ * @returns {{apps: any[], entry: string}|null} null once the reason has been printed
356
345
  */
357
- function profile(argv) {
346
+ function loadApps(argv, command) {
358
347
  const entry = findEntry(argv.find((arg) => !arg.startsWith("--")));
359
348
  if (!entry) {
360
349
  console.error(
361
- "Nothing to profile: name the file that builds the application, or run this from a\n" +
362
- "directory whose package.json main points at it."
350
+ `Nothing to ${command}: name the file that builds the application, or run this from a
351
+ ` + "directory whose package.json main points at it."
363
352
  );
364
- return 1;
353
+ return null;
365
354
  }
366
355
 
367
356
  // the same module instance the application will load, so patching this prototype patches the
@@ -374,7 +363,7 @@ function profile(argv) {
374
363
  }
375
364
  if (!proto) {
376
365
  console.error("This build of fulmine has no listen() to stand in for, which should not happen.");
377
- return 1;
366
+ return null;
378
367
  }
379
368
 
380
369
  const listened = [];
@@ -390,8 +379,9 @@ function profile(argv) {
390
379
  } catch (e) {
391
380
  const error = /** @type {any} */ (e);
392
381
  proto.listen = realListen;
393
- console.error(`${path.relative(process.cwd(), entry)} could not be loaded:\n${error.stack ?? error}`);
394
- return 1;
382
+ console.error(`${path.relative(process.cwd(), entry)} could not be loaded:
383
+ ${error.stack ?? error}`);
384
+ return null;
395
385
  }
396
386
  proto.listen = realListen;
397
387
 
@@ -409,14 +399,32 @@ function profile(argv) {
409
399
 
410
400
  if (apps.length === 0) {
411
401
  console.error(
412
- `${path.relative(process.cwd(), entry)} built no application: it neither called listen() nor\n` +
413
- "exported one. Point this at the file that does."
402
+ `${path.relative(process.cwd(), entry)} built no application: it neither called listen() nor
403
+ ` + "exported one. Point this at the file that does."
414
404
  );
415
- return 1;
405
+ return null;
416
406
  }
407
+ return { apps, entry };
408
+ }
417
409
 
418
- for (const app of apps) {
419
- printProfile(app, apps.length > 1);
410
+ /**
411
+ * Loads an application without letting it listen, and prints what compiling its routes decided.
412
+ *
413
+ * listen() is where the routes are compiled and also where the port is bound, and only the first
414
+ * of those is wanted here: an application that answered on its port while being profiled would be
415
+ * a surprise, and a second copy of a running service is worse than a surprise. So listen is
416
+ * replaced by the half that matters. The callback it was given is not run, for the same reason.
417
+ *
418
+ * @param {string[]} argv
419
+ * @returns {number} exit code
420
+ */
421
+ function profile(argv) {
422
+ const loaded = loadApps(argv, "profile");
423
+ if (!loaded) {
424
+ return 1;
425
+ }
426
+ for (const app of loaded.apps) {
427
+ printProfile(app, loaded.apps.length > 1);
420
428
  }
421
429
  return 0;
422
430
  }
@@ -446,6 +454,108 @@ const ADVICE = [
446
454
  ]
447
455
  ];
448
456
 
457
+ /**
458
+ * The plan for one route, the way a database explains a query.
459
+ *
460
+ * `profile` answers "how much of this application is on the fast path", which is a question about
461
+ * the whole table. This one answers "what happens when this request arrives", which is the question
462
+ * somebody has when one endpoint is slower than they expected: how it is matched, what is copied
463
+ * out of it, what runs, and what each layer costs the route.
464
+ *
465
+ * @param {string[]} argv the path to explain, then the entry
466
+ * @returns {number} exit code
467
+ */
468
+ function explain(argv) {
469
+ const args = argv.filter((arg) => !arg.startsWith("--"));
470
+ const wanted = args[0];
471
+ if (!wanted) {
472
+ console.error(`Name the route to explain: npx ${TO} explain /api/items`);
473
+ return 1;
474
+ }
475
+ const loaded = loadApps(args.slice(1), "explain");
476
+ if (!loaded) {
477
+ return 1;
478
+ }
479
+
480
+ const { callbackUsage, UNKNOWN, QUERY } = require("./usage.js");
481
+ let found = 0;
482
+ for (const app of loaded.apps) {
483
+ const entries = collectRoutes(app, "").filter(({ route }) => !route.use);
484
+ for (const { route, full } of entries) {
485
+ if (!matchesWanted(full, route.method, wanted)) {
486
+ continue;
487
+ }
488
+ found++;
489
+ const native = route._native;
490
+ console.log(`
491
+ ${String(route.method).toUpperCase()} ${full}
492
+ `);
493
+ console.log(
494
+ ` route ${native ? `native (µWS matched ${native.path} and dispatched by method)` : `router (matched here, layer by layer: ${route._whyGeneric ?? "it was not eligible"})`}`
495
+ );
496
+ if (native) {
497
+ console.log(
498
+ ` headers ${native.skipHeaders ? "not copied (nothing in the chain reads one)" : "copied out of µWS (something in the chain reads them)"}`
499
+ );
500
+ console.log(
501
+ ` query ${native.skipQuery ? "not parsed (nothing in the chain reads it)" : "parsed when something asks for it"}`
502
+ );
503
+ if (native.guards) {
504
+ console.log(` guards ${native.guards} case guard(s) in front of it`);
505
+ }
506
+ }
507
+
508
+ const chain = route.callbacks ?? [];
509
+ const ahead = native?.ahead ? `, ${native.ahead} mounted layer(s) in front of it` : "";
510
+ console.log(
511
+ ` chain ${chain.length} layer(s)${ahead}${native?.declarative ? ", compiled into a response written at startup" : ""}`
512
+ );
513
+ for (const fn of chain) {
514
+ const usage = callbackUsage(fn);
515
+ const name = fn.name || "(anonymous)";
516
+ const notes = [];
517
+ if (usage & UNKNOWN) {
518
+ notes.push("not readable at registration: it keeps the route off the compiled path");
519
+ } else {
520
+ notes.push("readable at registration");
521
+ if (usage & QUERY) notes.push("reads the query");
522
+ }
523
+ console.log(` ${name.padEnd(22)}${notes.join(", ")}`);
524
+ }
525
+ console.log(
526
+ ` body ${route.bodyMethods ? `read for ${route.bodyMethods.join(", ")}` : "read for POST, PUT, PATCH and QUERY, when one is declared"}`
527
+ );
528
+ }
529
+ }
530
+
531
+ if (found === 0) {
532
+ console.error(`No route is registered as "${wanted}". Run \`npx ${TO} profile\` to see the ones that are.`);
533
+ return 1;
534
+ }
535
+ return 0;
536
+ }
537
+
538
+ /**
539
+ * Whether a route answers to the name given on the command line: the path as registered, with an
540
+ * optional method in front and an optional "*" at the end for a prefix.
541
+ *
542
+ * @param {string} full
543
+ * @param {string} method
544
+ * @param {string} wanted
545
+ * @returns {boolean}
546
+ */
547
+ function matchesWanted(full, method, wanted) {
548
+ let path = wanted;
549
+ const space = wanted.indexOf(" ");
550
+ if (space !== -1) {
551
+ if (wanted.slice(0, space).toUpperCase() !== String(method).toUpperCase()) {
552
+ return false;
553
+ }
554
+ path = wanted.slice(space + 1);
555
+ }
556
+ return path.endsWith("*") ? full.startsWith(path.slice(0, -1)) : full === path;
557
+ }
558
+
449
559
  /**
450
560
  * A summary that says how much of this application the native router carries, and what could be
451
561
  * changed to make it carry more.
@@ -582,11 +692,19 @@ function main(argv) {
582
692
  if (command === "profile") {
583
693
  return profile(argv.slice(1));
584
694
  }
695
+ if (command === "verify") {
696
+ return verify(argv.slice(1));
697
+ }
698
+ if (command === "explain") {
699
+ return explain(argv.slice(1));
700
+ }
585
701
  if (command !== "migrate") {
586
702
  console.log(`Usage:
587
703
  npx ${TO} migrate [dir] rewrite require("${FROM}") and import from "${FROM}" to "${TO}"
588
704
  npx ${TO} profile [entry] load an application without listening and print what compiling
589
705
  its routes decided, route by route
706
+ npx ${TO} explain <route> what happens when a request for that route arrives
707
+ npx ${TO} verify [dir] check that this machine and this project can run it at all
590
708
  npx ${TO} differences print what behaves differently, without changing anything
591
709
 
592
710
  Options:
@@ -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
  /**
package/src/index.js CHANGED
@@ -49,7 +49,9 @@ try {
49
49
  * response: object,
50
50
  * application: object,
51
51
  * static: Function,
52
+ * testing: object,
52
53
  * compression: Function,
54
+ * serverTiming: Function,
53
55
  * json: Function,
54
56
  * urlencoded: Function,
55
57
  * text: Function,
@@ -73,9 +75,15 @@ module.exports.response = Response.prototype;
73
75
  module.exports.application = Application.Application.prototype;
74
76
 
75
77
  module.exports.static = middlewares.static;
78
+ // what listen() decided about each route, as something a test can assert on rather than something
79
+ // to read in a terminal. See src/testing.js
80
+ module.exports.testing = require("./testing.js");
76
81
  // not one of express's, since express has none: the compression module is what everyone installs
77
82
  // instead, and this is that middleware's options and behaviour without the install
78
83
  module.exports.compression = require("./compression.js");
84
+ // Server-Timing with the routing verdict in it, which no other framework can report because no
85
+ // other framework has two routes to tell apart. See src/server-timing.js
86
+ module.exports.serverTiming = require("./server-timing.js");
79
87
  module.exports.json = middlewares.json;
80
88
  module.exports.urlencoded = middlewares.urlencoded;
81
89
  module.exports.text = middlewares.text;