fulmine.js 5.11.1 → 5.12.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Fulmine.js
4
4
 
5
- A drop-in replacement for Express 5, running on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js) instead of `node:http`. Your existing middleware keeps working.
5
+ Fulmine - means lightning ⚡ in Italian - is a drop-in replacement for Express 5, running on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js) instead of `node:http`. Your existing middleware keeps working.
6
6
 
7
7
  ```js
8
8
  const express = require("fulmine.js"); // instead of require("express")
@@ -29,9 +29,12 @@ npx fulmine.js explain /api/items # what happens when a request for that route
29
29
  See [Migrating](#migrating) for what it handles and what it deliberately does not.
30
30
 
31
31
  [![npm version](https://img.shields.io/npm/v/fulmine.js)](https://www.npmjs.com/package/fulmine.js)
32
- [![Node.js >= 22.0.0](https://img.shields.io/badge/Node.js-%3E=22.0.0-green)](https://nodejs.org)
32
+ [![Node.js 22 | 24 | 26](https://img.shields.io/badge/Node.js-22%20%7C%2024%20%7C%2026-green)](https://nodejs.org)
33
+ [![HTTP Arena](https://img.shields.io/endpoint?url=https://www.http-arena.com/badge/fulmine/h1.json)](https://www.http-arena.com/#tuned=0)
33
34
  [![Coverage Status](https://coveralls.io/repos/github/nigrosimone/fulmine.js/badge.svg?branch=main)](https://coveralls.io/github/nigrosimone/fulmine.js?branch=main)
34
35
  [![CodeQL](https://github.com/nigrosimone/fulmine.js/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/nigrosimone/fulmine.js/actions/workflows/codeql.yml)
36
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/nigrosimone/fulmine.js/badge)](https://scorecard.dev/viewer/?uri=github.com/nigrosimone/fulmine.js)
37
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14089/badge)](https://www.bestpractices.dev/projects/14089)
35
38
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
36
39
 
37
40
  ## Table of contents
@@ -282,6 +285,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
282
285
  - `app.listen()` returns the app rather than a separate server object, and the app answers as an `http.Server`: `app instanceof http.Server` is true, which is what the graceful shutdown wrappers and the connection trackers look for. There is still no node server underneath, the socket belongs to µWS, so what is answered is the surface and not the plumbing. There: `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family. Not there: 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`. Runnable: [`examples/graceful-shutdown.js`](./examples/graceful-shutdown.js).
283
286
  - `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.
284
287
  - 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.
288
+ - **A compiled route answers `connection: keep-alive` to a client that sent `Connection: close`.** A handler simple enough to be read at registration time is answered by µWS from a response written once at `listen()`, and that response cannot read the request. The socket still closes, so what is wrong is the header and not the transport. A response that would carry a validator is never compiled, so conditional requests behave as on Express; `app.set("declarative responses", false)` turns compiling off.
285
289
  - **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`.
286
290
  - For HTTPS, instead of doing this:
287
291
 
@@ -325,6 +329,7 @@ app.listen(3000, () => {
325
329
  Runnable: [`examples/https.js`](./examples/https.js).
326
330
 
327
331
  - This also applies to non-SSL HTTP too. Use `app.listen()` rather than creating a server by hand. `http.createServer(app)` does work, because the app is a request listener like Express's and answers node's requests through a shim, which is what lets `supertest`, `vhost` and anything else that calls an app keep working. But it serves those requests through `node:http` rather than through µWS, so the speed is Express's. It is there for compatibility, not for production.
332
+ - **Node 22, 24 and 26, not every version above 22.** µWebSockets.js ships one prebuilt binary per Node ABI and skips the odd lines, so Node 23 and 25 have no binary to load and fail at `require`. `npx fulmine.js verify` says which binary this machine wants and whether it is there. The odd/even model ends with Node 26, so the gap closes on its own.
328
333
  - Node.JS max header size is 16384 bytes, while uWebSockets by default is 4096 bytes, so if you need longer headers set the env variable `UWS_HTTP_MAX_HEADERS_SIZE` to max byte count you need.
329
334
  - uWebSockets drops a request whose body arrives slower than 16KB/s, and the timeout is not reachable from JavaScript, while Node.JS waits as long as the client needs. Uploads over very slow connections can therefore fail here and succeed on Express. A body stalled for 5 seconds still completes; one stalled for 12 seconds gets its socket reset at around 11.8 seconds.
330
335
 
@@ -700,8 +705,9 @@ Two of these keep a compiled form alongside the value, which you can also set di
700
705
  - `etag fn`, the function that produces an ETag. Setting `etag` compiles one; setting this replaces it.
701
706
  - `query parser fn`, likewise for `query parser`.
702
707
 
703
- Fulmine adds five of its own:
708
+ Fulmine adds six of its own:
704
709
 
710
+ - `etag methods`, unset by default. Express computes the generated ETag for every method, and so does this until told otherwise. `app.set("etag methods", ["GET", "HEAD"])` skips the digest on every other method, where freshness is not defined and the validator can never match: worth 21% here on a 4KB POST answer. An ETag set by hand still goes out whatever the method.
705
711
  - `declarative responses`, on by default. Lets a simple enough handler be compiled into a native uWS response, described under [Performance tips](#performance-tips).
706
712
  - `connection headers`, on by default. Express sends `Connection: keep-alive` and `Keep-Alive` on every response, and so does this. Turn it off and neither goes out, while a connection the client asked to close still answers `Connection: close`: it is the advertisement that goes, not the truth. Worth 2% to 3.5% here on a route that is not compiled, plus the bytes.
707
713
  - `file cache`, on by default. Small files served by `res.sendFile` come from a bounded in-process cache, checked against the file's `stat` on every request, so an edited file is never served stale. Turn it off where every request has to reach the disk, which is what a public benchmark asks of a standard entry: it was worth about 4% on a 4KB file here, so the cost of turning it off is small.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.11.1",
3
+ "version": "5.12.1",
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": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node tests/index.js",
11
- "test:unit": "node --test \"tests/unit/*.test.js\"",
11
+ "test:unit": "node --test --test-timeout=120000 \"tests/unit/*.test.js\"",
12
12
  "test:types": "tsd --files tests/types/*.test-d.ts",
13
13
  "test:express": "node tools/express-suite.js",
14
14
  "fuzz": "node tools/fuzz.js",
@@ -68,7 +68,7 @@
68
68
  "dependencies": {
69
69
  "@types/express": "^5.0.6",
70
70
  "accepts": "^2.0.0",
71
- "acorn": "^8.16.0",
71
+ "acorn": "^8.18.0",
72
72
  "bytes": "^3.1.2",
73
73
  "compressible": "^2.0.18",
74
74
  "content-disposition": "^1.1.0",
@@ -82,8 +82,8 @@
82
82
  "mime-types": "^3.0.2",
83
83
  "ms": "^2.1.3",
84
84
  "proxy-addr": "^2.0.7",
85
- "qs": "^6.15.2",
86
- "range-parser": "^1.2.1",
85
+ "qs": "^6.15.3",
86
+ "range-parser": "^1.3.0",
87
87
  "statuses": "^2.0.2",
88
88
  "tseep": "^1.3.1",
89
89
  "type-is": "^2.1.0",
@@ -91,7 +91,6 @@
91
91
  "vary": "^1.1.2"
92
92
  },
93
93
  "devDependencies": {
94
- "@codechecks/client": "^0.1.12",
95
94
  "@commitlint/cli": "^21.2.1",
96
95
  "@commitlint/config-conventional": "^21.2.0",
97
96
  "@eslint/js": "^10.0.1",
@@ -105,7 +104,7 @@
105
104
  "@types/fresh": "^0.5.3",
106
105
  "@types/mime-types": "^3.0.1",
107
106
  "@types/ms": "^2.1.0",
108
- "@types/node": "^25.9.1",
107
+ "@types/node": "^26.2.0",
109
108
  "@types/proxy-addr": "^2.0.3",
110
109
  "@types/statuses": "^2.0.6",
111
110
  "@types/type-is": "^1.6.7",
@@ -117,21 +116,21 @@
117
116
  "cookie-parser": "^1.4.7",
118
117
  "cookie-session": "^2.1.1",
119
118
  "cors": "^2.8.6",
120
- "ejs": "^3.1.10",
119
+ "ejs": "^6.0.1",
121
120
  "errorhandler": "^1.5.2",
122
121
  "eslint": "^10.8.0",
123
122
  "eslint-config-prettier": "^10.1.8",
124
- "eslint-plugin-jsdoc": "^63.3.2",
123
+ "eslint-plugin-jsdoc": "^64.1.0",
125
124
  "etag": "^1.8.1",
126
- "eventsource": "^4.1.0",
127
- "exit-hook": "^2.2.1",
125
+ "eventsource": "^5.0.0",
126
+ "exit-hook": "^5.1.0",
128
127
  "express": "^5",
129
128
  "express-art-template": "^1.0.1",
130
129
  "express-basic-auth": "^1.2.1",
131
130
  "express-dot-engine": "^1.0.8",
132
131
  "express-fast-json-stringify": "^1.3.0",
133
132
  "express-fileupload": "^1.5.2",
134
- "express-handlebars": "^8.0.7",
133
+ "express-handlebars": "^9.0.1",
135
134
  "express-http-proxy": "^2.1.2",
136
135
  "express-mongo-sanitize": "^2.2.0",
137
136
  "express-rate-limit": "^8.5.2",
@@ -142,20 +141,20 @@
142
141
  "globals": "^17.8.0",
143
142
  "graphql-http": "^1.22.4",
144
143
  "helmet": "^8.2.0",
145
- "http-proxy-middleware": "^3.0.5",
144
+ "http-proxy-middleware": "^4.2.0",
146
145
  "husky": "^9.1.7",
147
146
  "lint-staged": "^17.3.0",
148
147
  "method-override": "^3.0.0",
149
148
  "morgan": "^1.11.0",
150
149
  "multer": "^2.1.1",
151
150
  "mustache-express": "^1.3.2",
152
- "nyc": "^17.1.0",
151
+ "nyc": "^18.0.0",
153
152
  "on-finished": "^2.4.1",
154
153
  "on-headers": "^1.1.0",
155
- "pako": "^2.1.0",
154
+ "pako": "^3.0.1",
156
155
  "passport": "^0.7.0",
157
156
  "passport-local": "^1.0.0",
158
- "pkg-pr-new": "^0.0.75",
157
+ "pkg-pr-new": "^0.0.87",
159
158
  "prettier": "^3.9.6",
160
159
  "pug": "^3.0.4",
161
160
  "release-it": "^21.0.1",
@@ -26,7 +26,8 @@ const {
26
26
  createETagGenerator,
27
27
  fastQueryParse,
28
28
  durationSetting,
29
- NullObject
29
+ NullObject,
30
+ settingsEpoch
30
31
  } = require("./utils.js");
31
32
  const parseQuery = require("./parse-query.js");
32
33
  const Request = require("./request.js");
@@ -207,11 +208,11 @@ class Application extends Router {
207
208
  // a "trust proxy" this app never set is inherited from the parent, as express does:
208
209
  // the defaults are deleted so get() falls through to the parent's value
209
210
  if (
210
- this.settings[trustProxyDefaultSymbol] === true &&
211
- typeof parent.settings["trust proxy fn"] === "function"
211
+ this._settings[trustProxyDefaultSymbol] === true &&
212
+ typeof parent._settings["trust proxy fn"] === "function"
212
213
  ) {
213
- delete this.settings["trust proxy"];
214
- delete this.settings["trust proxy fn"];
214
+ delete this._settings["trust proxy"];
215
+ delete this._settings["trust proxy fn"];
215
216
  }
216
217
  });
217
218
  this.listenCalled = false;
@@ -247,20 +248,20 @@ class Application extends Router {
247
248
  this._draining = false;
248
249
  // read here, at construction, the way express does; an empty NODE_ENV means development,
249
250
  // which the ?? in the shared default would miss
250
- if (typeof this.settings.env === "undefined") {
251
- this.settings.env = process.env.NODE_ENV || "development";
251
+ if (typeof this._settings.env === "undefined") {
252
+ this._settings.env = process.env.NODE_ENV || "development";
252
253
  }
253
254
  for (const key in defaultSettings) {
254
- if (typeof this.settings[key] === "undefined") {
255
+ if (typeof this._settings[key] === "undefined") {
255
256
  if (typeof defaultSettings[key] === "function") {
256
- this.settings[key] = defaultSettings[key](this);
257
+ this._settings[key] = defaultSettings[key](this);
257
258
  } else {
258
- this.settings[key] = defaultSettings[key];
259
+ this._settings[key] = defaultSettings[key];
259
260
  }
260
261
  }
261
262
  }
262
263
  // non-enumerable, so the marker never shows up walking the settings
263
- Object.defineProperty(this.settings, trustProxyDefaultSymbol, {
264
+ Object.defineProperty(this._settings, trustProxyDefaultSymbol, {
264
265
  configurable: true,
265
266
  value: true
266
267
  });
@@ -371,54 +372,60 @@ class Application extends Router {
371
372
  if (!value) {
372
373
  // compiled, not deleted: an explicit false must shadow a parent's setting when
373
374
  // this app is mounted, and a deleted key would read straight through to it
374
- this.settings["trust proxy fn"] = compileTrust(false);
375
+ this._settings["trust proxy fn"] = compileTrust(false);
375
376
  } else {
376
- this.settings["trust proxy fn"] = compileTrust(value);
377
+ this._settings["trust proxy fn"] = compileTrust(value);
377
378
  }
378
379
  // set explicitly, so a mount no longer inherits the parent's
379
- Object.defineProperty(this.settings, trustProxyDefaultSymbol, {
380
+ Object.defineProperty(this._settings, trustProxyDefaultSymbol, {
380
381
  configurable: true,
381
382
  value: false
382
383
  });
383
384
  } else if (key === "stat cache") {
384
385
  // compiled here so the read path is a number and not a duration to parse per request
385
- this.settings["stat cache ms"] = durationSetting(value, "stat cache");
386
+ this._settings["stat cache ms"] = durationSetting(value, "stat cache");
386
387
  } else if (key === "query parser") {
387
388
  if (value === "extended") {
388
- this.settings["query parser fn"] = fastQueryParse;
389
+ this._settings["query parser fn"] = fastQueryParse;
389
390
  } else if (value === "simple" || value === true) {
390
- this.settings["query parser fn"] = parseQuery;
391
+ this._settings["query parser fn"] = parseQuery;
391
392
  } else if (typeof value === "function") {
392
- this.settings["query parser fn"] = value;
393
+ this._settings["query parser fn"] = value;
393
394
  } else if (value === false) {
394
- this.settings["query parser fn"] = undefined;
395
+ this._settings["query parser fn"] = undefined;
395
396
  } else {
396
397
  // express's wording, which applications match on
397
398
  throw new TypeError("unknown value for query parser function: " + value);
398
399
  }
399
- } else if (key === "etag") {
400
- // an etag arriving after listen would make send consult freshness headers the
401
- // header-skip routes never copied, so those skips are taken back
402
- if (value !== false && this._skipPresets?.size) {
403
- for (const preset of this._skipPresets) {
404
- preset.skipHeaders = false;
405
- preset.skipQuery = false;
406
- }
407
- this._skipPresets.clear();
400
+ } else if (key === "etag methods") {
401
+ // fulmine's own: the methods whose send() computes a generated ETag. Unset means all
402
+ // of them, which is express's behaviour and what its suite asserts per method; naming
403
+ // ["GET", "HEAD"] skips the digest everywhere a validator can never match, which
404
+ // measured +21% on a 4KB POST answer. See issue #10.
405
+ if (value != null && (!Array.isArray(value) || value.some((m) => typeof m !== "string"))) {
406
+ throw new TypeError('"etag methods" wants an array of method names, or null for all of them');
408
407
  }
408
+ value = value == null ? undefined : value.map((m) => m.toUpperCase());
409
+ } else if (key === "etag") {
410
+ // The skips are not taken back here. They used to be, because send consults freshness
411
+ // and the skip branch looked like it had not copied the headers for it, but that
412
+ // branch reads if-none-match, if-modified-since and cache-control by name whatever
413
+ // this setting says, see request.js:527, and req.fresh reads nothing else off the
414
+ // request. Registering a route or a middleware after listen still takes them back,
415
+ // see router.js:1615: that is a different question, about code the analysis never saw.
409
416
  if (typeof value === "function") {
410
- this.settings["etag fn"] = value;
417
+ this._settings["etag fn"] = value;
411
418
  } else {
412
419
  switch (value) {
413
420
  case true:
414
421
  case "weak":
415
- this.settings["etag fn"] = createETagGenerator({ weak: true });
422
+ this._settings["etag fn"] = createETagGenerator({ weak: true });
416
423
  break;
417
424
  case "strong":
418
- this.settings["etag fn"] = createETagGenerator({ weak: false });
425
+ this._settings["etag fn"] = createETagGenerator({ weak: false });
419
426
  break;
420
427
  case false:
421
- delete this.settings["etag fn"];
428
+ delete this._settings["etag fn"];
422
429
  break;
423
430
  default:
424
431
  // express's wording, which applications match on
@@ -427,7 +434,9 @@ class Application extends Router {
427
434
  }
428
435
  }
429
436
 
430
- this.settings[key] = value;
437
+ this._settings[key] = value;
438
+ // any app's hot-settings copy may resolve through this one, see Router#_hot
439
+ settingsEpoch.n++;
431
440
  return this;
432
441
  }
433
442
 
@@ -520,13 +529,21 @@ class Application extends Router {
520
529
  _serveGeneric(res, req) {
521
530
  const request = this.handleRequest(res, req);
522
531
  const response = request.res;
523
- // armed up front here: this handler can outlive the callback on every path through it
524
- this._armAbort(res, response);
525
-
526
- this._routeRequestDirect(request, response);
527
- // the synchronous stretch has run under the cork uWS holds for this callback, and
528
- // whatever comes after it is outside
529
- response._corkNeeded = true;
532
+ if (request._badFraming === true) {
533
+ return this._refuseFraming(response);
534
+ }
535
+ try {
536
+ this._routeRequestDirect(request, response);
537
+ } finally {
538
+ // the synchronous stretch has run under the cork uWS holds for this callback, and
539
+ // whatever comes after it is outside
540
+ response._corkNeeded = true;
541
+ // an abort can only arrive after this callback returns, as the native handler's
542
+ // finally says: a response that finished inside it never needs uWS told at all
543
+ if (!response.finished) {
544
+ this._armAbort(res, response);
545
+ }
546
+ }
530
547
  }
531
548
 
532
549
  /**
package/src/cli.js CHANGED
@@ -85,10 +85,11 @@ const DIFFERENCES = [
85
85
  'Express sends X-Powered-By: Express unless told not to. Set app.set("x-powered-by", true) to send it.'
86
86
  ],
87
87
  [
88
- "a compiled route is framed differently and never answers 304",
89
- "A handler simple enough to be read at registration time is answered natively, which means chunked\n" +
90
- "framing with no Content-Length, and a conditional request gets the whole body rather than a 304.\n" +
91
- 'app.set("declarative responses", false) turns that off.'
88
+ "a compiled route is framed differently and keeps its connection header",
89
+ "A handler simple enough to be read at registration time is answered natively: chunked framing\n" +
90
+ "with no Content-Length, and a client that sent Connection: close is still told keep-alive,\n" +
91
+ "though the socket does close. A response that would carry a validator is never compiled, so\n" +
92
+ 'conditional requests behave as on Express. app.set("declarative responses", false) turns it off.'
92
93
  ],
93
94
  [
94
95
  "headers are capped at 4096 bytes by default",
@@ -493,9 +494,43 @@ ${error.stack ?? error}`);
493
494
  );
494
495
  return null;
495
496
  }
497
+ stopFileWorkers(apps);
496
498
  return { apps, entry };
497
499
  }
498
500
 
501
+ /**
502
+ * Ends the file-reading threads that building an application started.
503
+ *
504
+ * An Application starts one per `threads` in its constructor, and these commands only ever read
505
+ * what compiling the routes decided: nothing here serves a file, so nothing here needs a thread.
506
+ * They are unref'd, so leaving them would not hang the process, but they are threads holding the
507
+ * library the application loaded, and this command is often not the whole process. It also stops
508
+ * them outliving the directory they were loaded from, which is how a test that profiles a copy and
509
+ * then removes it saw "Cannot find module .../src/worker.js" arrive after it had finished.
510
+ *
511
+ * Best effort throughout: a build with no workers, or a worker already gone, is not an error here.
512
+ *
513
+ * @param {any[]} apps
514
+ * @returns {void}
515
+ */
516
+ function stopFileWorkers(apps) {
517
+ const seen = new Set();
518
+ for (const app of apps) {
519
+ for (const holder of app?.workers ?? []) {
520
+ const worker = holder?.worker;
521
+ if (!worker || seen.has(worker)) {
522
+ continue;
523
+ }
524
+ seen.add(worker);
525
+ try {
526
+ worker.terminate();
527
+ } catch {
528
+ // a thread that never started, or already ended, needs nothing
529
+ }
530
+ }
531
+ }
532
+ }
533
+
499
534
  /**
500
535
  * Loads an application without letting it listen, and prints what compiling its routes decided.
501
536
  *
@@ -50,6 +50,10 @@ const typeValueOf = (type) => (type.indexOf("/") === -1 ? contentTypeFor(type) :
50
50
  const MAX_INSTRUCTION_LENGTH = 65535;
51
51
 
52
52
  // the three that write a body, of which only one may appear
53
+ // The headers a conditional request is answered from. A compiled response cannot read the request,
54
+ // so it cannot honour one, and a handler that sets one has to stay on the ordinary path.
55
+ const VALIDATOR_HEADERS = new Set(["etag", "last-modified"]);
56
+
53
57
  const bodyMethods = new Set(["send", "json", "end"]);
54
58
  // and the four that finish the response, after which nothing a handler does is observable
55
59
  const terminalMethods = new Set(["send", "json", "end", "sendStatus"]);
@@ -734,10 +738,13 @@ module.exports = function compileDeclarative(cb, app) {
734
738
  }
735
739
 
736
740
  for (const header of headers) {
737
- if (header[0].toLowerCase() === "content-length") {
741
+ const name = header[0].toLowerCase();
742
+ if (name === "content-length") {
738
743
  return false;
739
744
  }
740
- decRes = decRes.writeHeader(header[0], header[1]);
745
+ // lowercased as the ordinary path stores every name, so the two paths answer the
746
+ // same bytes whatever casing the handler wrote, see issue #7
747
+ decRes = decRes.writeHeader(name, header[1]);
741
748
  }
742
749
 
743
750
  // sendStatus sends the status message as its body. It has to join `body` here, before the
@@ -748,24 +755,22 @@ module.exports = function compileDeclarative(cb, app) {
748
755
  body.push({ type: "text", value: statuses.message[statusCode] || String(statusCode) });
749
756
  }
750
757
 
751
- // an empty body gets no ETag, which is what Express does and what the ordinary path here
752
- // already did
753
- if (
754
- body.length &&
755
- (bodyFromSend || sendStatusUsed) &&
756
- app.get("etag") &&
757
- !headers.some((header) => header[0].toLowerCase() === "etag")
758
- ) {
759
- if (body.some((part) => part.type !== "text")) {
760
- return false;
761
- } else {
762
- const etag = app.get("etag fn")(body.map((part) => part.value.toString()).join(""));
763
- // an application's own etag function is allowed to decline, and a declarative
764
- // response cannot answer with a header whose value is nothing
765
- if (etag) {
766
- decRes = decRes.writeHeader("ETag", etag);
767
- }
768
- }
758
+ // A response that would carry a validator is not compiled at all.
759
+ //
760
+ // µWS answers a declarative response without reading the request, so it cannot answer a
761
+ // conditional GET: it used to write an ETag computed over the compiled body at listen and
762
+ // then ignore it, so every revalidation got 200 and the whole body where Express answers
763
+ // 304 with none. Dropping the ETag instead would have kept the route compiled, at the
764
+ // price of no validator at all on the simplest routes of every application. Refusing
765
+ // keeps Express's answer, and `etag` false is how a route that does not need one stays
766
+ // compiled, which is what both benchmarks here already set.
767
+ if (headers.some((header) => VALIDATOR_HEADERS.has(header[0].toLowerCase()))) {
768
+ return false;
769
+ }
770
+ // an empty body gets no ETag, in Express and on the ordinary path here, so it has nothing
771
+ // to lose by being compiled
772
+ if (body.length && (bodyFromSend || sendStatusUsed) && app.get("etag")) {
773
+ return false;
769
774
  }
770
775
 
771
776
  // No Content-Length header here: uWS writes the framing itself, and a response carrying
package/src/index.js CHANGED
@@ -35,6 +35,15 @@ try {
35
35
  // older uWS builds do not expose _cfg; there is nothing to fall back to
36
36
  }
37
37
 
38
+ try {
39
+ // the compile cache, in node since 22.8: the next boot of the same code skips compiling it.
40
+ // Asked for here because in practice the framework is the entry point of the application
41
+ // using it. Respects NODE_DISABLE_COMPILE_CACHE, and booting without a cache is not an error
42
+ require("node:module").enableCompileCache?.();
43
+ } catch (error) {
44
+ // node below 22.8, or a disk the cache cannot be written to
45
+ }
46
+
38
47
  // The factory doubles as a namespace, as in Express: Router, static and the body parsers hang off
39
48
  // the function that creates an app.
40
49
  //
@@ -538,7 +538,7 @@ function serveStatic(root, options) {
538
538
  filePath,
539
539
  req.headers["accept-encoding"],
540
540
  twinTtl,
541
- req.app.settings["stat cache ms"]
541
+ req.app._settings["stat cache ms"]
542
542
  );
543
543
  if (twin) {
544
544
  stat = twin.stat;
@@ -546,7 +546,7 @@ function serveStatic(root, options) {
546
546
  }
547
547
  try {
548
548
  if (stat === undefined) {
549
- stat = cachedStat(statTarget, req.app.settings["stat cache ms"]);
549
+ stat = cachedStat(statTarget, req.app._settings["stat cache ms"]);
550
550
  }
551
551
  } catch (err) {
552
552
  // the one to report when nothing is found: send hands each failed attempt to the next
@@ -656,7 +656,12 @@ function serveStatic(root, options) {
656
656
  // already found before the stat below, on the ordinary path
657
657
  const variant =
658
658
  twin ??
659
- pickPrecompressed(filePath, req.headers["accept-encoding"], twinTtl, req.app.settings["stat cache ms"]);
659
+ pickPrecompressed(
660
+ filePath,
661
+ req.headers["accept-encoding"],
662
+ twinTtl,
663
+ req.app._settings["stat cache ms"]
664
+ );
660
665
  if (variant) {
661
666
  _path += variant.suffix;
662
667
  stat = variant.stat;
@@ -935,13 +940,16 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
935
940
  // upstream middleware's AsyncLocalStorage must still be there when next runs
936
941
  next = AsyncResource.bind(next);
937
942
 
938
- // with a known content-length and nothing to decompress, uWS can collect the whole
939
- // body in native code: one callback instead of one per chunk, the limit enforced
940
- // before any byte reaches JS, and no copy at all - the parsers turn the bytes into
941
- // req.body before the callback returns, so a view over uWS's own memory is enough
942
- if (!req.receivedData && !inflate && !isNaN(length) && Number(length) > 0 && req._res.collectBody) {
943
+ // with nothing to decompress, uWS can collect the whole body in native code: one
944
+ // callback instead of one per chunk, the limit enforced before any byte reaches JS,
945
+ // and no copy at all - the parsers turn the bytes into req.body before the callback
946
+ // returns, so a view over uWS's own memory is enough. A declared length was the
947
+ // original case; a chunked body accumulates in the same native vector and only loses
948
+ // the length check, since there is no declaration to hold it to
949
+ const declared = Number(length);
950
+ const declaresLength = !isNaN(declared) && declared > 0;
951
+ if (!req.receivedData && !inflate && req._res.collectBody && (declaresLength || isNaN(declared))) {
943
952
  req.bodyRead = true;
944
- const declared = Number(length);
945
953
  req._res.collectBody(limit, (body) => {
946
954
  if (body === null) {
947
955
  // over maxSize: uWS refused it natively
@@ -952,7 +960,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
952
960
  })
953
961
  );
954
962
  }
955
- if (body.byteLength !== declared) {
963
+ if (declaresLength && body.byteLength !== declared) {
956
964
  return next(
957
965
  bodyError("request size did not match content length", 400, "request.size.invalid", {
958
966
  expected: declared,