fulmine.js 5.0.0 → 5.1.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
@@ -23,6 +23,7 @@ npx fulmine differences # just the list of what to check by hand
23
23
 
24
24
  See [Migrating](#migrating) for what it handles and what it deliberately does not.
25
25
 
26
+ [![npm version](https://img.shields.io/npm/v/fulmine.js)](https://www.npmjs.com/package/fulmine.js)
26
27
  [![Node.js >= 22.0.0](https://img.shields.io/badge/Node.js-%3E=22.0.0-green)](https://nodejs.org)
27
28
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
28
29
 
@@ -30,13 +31,13 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
30
31
 
31
32
  There are several fast HTTP servers for Node built on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js). What is scarce is one you can actually drop into an existing Express application without rewriting it.
32
33
 
33
- Compatibility here is not a claim, it is a test suite. Every test runs against real Express first and then against Fulmine, and the outputs have to match byte for byte. That is what makes `helmet`, `cors`, `passport`, `morgan`, `multer`, `express-session` and the rest of the ecosystem work rather than "mostly work".
34
+ Compatibility here is not a claim, it is a test suite. Every test runs against real Express first and then against Fulmine, and the outputs have to match byte for byte. That is what makes `helmet`, `cors`, `passport`, `morgan`, `multer`, `express-session` and the rest of the ecosystem work rather than "mostly work". Express 5's own test suite runs against Fulmine too, and passes whole: 1130 passing, 0 failing at the pinned Express version.
34
35
 
35
36
  ## Performance
36
37
 
37
38
  Fulmine is faster than Express where the framework itself is doing the work, and the same speed where it is not. Both halves of that sentence matter, so here is the honest version.
38
39
 
39
- **Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. Plain routing lands between 1.9x and 3.2x: hello-world 1.9x to 2.2x, an API endpoint with params and a query 2.6x to 3.2x, nested routers 2x to 2.5x, a urlencoded body 2.6x to 3.2x, 5000 concurrent connections 2.4x to 2.7x. Route tables are where the native router shows: a thousand routes 5.4x to 8.1x, with a parameter in every one of them 5.8x to 8.7x, a parameterised route in a mounted router 3.8x to 5.9x. Those routes go to µWS's own router instead of being scanned, so the gap grows with the table instead of shrinking. The one routing scenario still even is a chain of 100 middlewares, 0.95x to 1x, where the cost is calling application code a hundred times rather than routing.
40
+ **Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. Plain routing lands between 1.9x and 3.9x: hello-world 1.9x to 2.7x, an API endpoint with params and a query 3x to 3.8x, five route shapes served by one process 2.7x to 3.9x, nested routers 2.2x to 2.8x, a urlencoded body 3.2x to 3.8x, a thousand concurrent connections 2.7x to 3x. Route tables are where the native router shows: a thousand routes 9.8x to 12.7x, with a parameter in every one of them 9.9x to 14.3x, a parameterised route in a mounted router 7.4x to 8.8x. Those routes go to µWS's own router instead of being scanned, so the gap grows with the table instead of shrinking. Even the chain of 100 middlewares, for a long time the one routing row that stayed even because its cost is calling application code a hundred times, sits at 1.35x to 1.55x after the per-request allocation work of August 2026.
40
41
 
41
42
  **Where it is a wash.** Any request whose cost is dominated by work both servers hand to the same library. A 512 KiB JSON body is `JSON.parse`, a gzipped response is zlib, a hashed upload is OpenSSL, a 5 MiB stream is memory bandwidth. On those the ratio is capped by arithmetic somewhere around 1.0x to 1.2x, and no amount of work on either server moves it. The benchmark labels those rows rather than quietly publishing them as if the two were equivalent.
42
43
 
@@ -47,8 +48,7 @@ Two things worth knowing before comparing numbers with anyone:
47
48
 
48
49
  There is no table here on purpose. CI runs the whole benchmark on every push and every pull request
49
50
  and posts the result where it belongs: as a comment on the commit or the pull request, and as a
50
- `benchmark-summary` artifact on the run. A table pasted in here would be a snapshot of one machine
51
- on one day, and would start rotting immediately. See [`benchmark/README.md`](./benchmark/README.md)
51
+ `benchmark-summary` artifact on the run, see [`benchmark/README.md`](./benchmark/README.md)
52
52
  to run it yourself.
53
53
 
54
54
  ## Attribution
@@ -84,7 +84,6 @@ command whose name ends in `.js` on Windows, where it exits without a word.
84
84
  ## Differences from Express
85
85
 
86
86
  - `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.
87
- - `case sensitive routing` is enabled by default.
88
87
  - `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.
89
88
  - 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.
90
89
  - For HTTPS, instead of doing this:
@@ -134,8 +133,7 @@ app.listen(3000, () => {
134
133
 
135
134
  1. Fulmine tries to optimize routing as much as possible, but it's only possible if:
136
135
 
137
- - `case sensitive routing` is enabled (it is by default, unlike in normal Express).
138
- - the path is a plain string, or its parameters are whole segments: `/users/:id` and `/a/:b/c/:d` qualify, `/flights/:from-:to` does not, and neither does a `*splat` or a `{}` group.
136
+ - the path is a plain string, or its parameters are whole segments: `/users/:id` and `/a/:b/c/:d` qualify, `/flights/:from-:to` does not, and neither does a `*splat` or a `{}` group. Routing is case-insensitive by default, as in Express; a request in the registered case is still served natively, any other case takes the ordinary path, and a route whose overlap with an earlier one leans on a cased literal goes the ordinary way for every request.
139
137
  - inside a mounted router, nothing registered after the route in that router could match the same path. `/orders/:id`, `/orders/:id/items` and `/invoices/:id` are all optimized together, since no request reaches two of them. `/users/:id` followed by `/users/me` is not: Express answers `/users/me` with the first of the two and the native router would answer it with the second, so both go the ordinary way.
140
138
 
141
139
  Optimized routes can be up to 10 times faster than normal routes, as they're using native uWS router and have pre-calculated path.
@@ -237,7 +235,7 @@ In general, basically all features and options are supported. Use the [Express 5
237
235
  - 🚧 express.request (this is not a constructor but a prototype for replacing methods)
238
236
  - 🚧 express.response (this is not a constructor but a prototype for replacing methods)
239
237
  - 🚧 express.application (likewise: a method added here is on every app)
240
- - ❌ express.Route. `app.route("/path").get(...).post(...)` works and is what almost everyone means by this; what is missing is the class itself, for constructing a route and wiring it up by hand.
238
+ - ✅ express.Route. Both `app.route("/path").get(...).post(...)` and the class itself, for building a route by hand and dispatching to it.
241
239
 
242
240
  ### Application
243
241
 
@@ -459,6 +457,26 @@ twice, once with `express` and once with this, and fails on any difference. That
459
457
  test means writing something that prints what you want compared, and why a test that prints from
460
458
  both the server and the client at once is a bug: the two orderings are a race.
461
459
 
460
+ ### Writing a comparison test
461
+
462
+ A test file is an ordinary script. The first line is its description, the second may carry a marker,
463
+ and the rest sets up an app, makes requests and prints. `tests/helpers.js` has what to print with:
464
+
465
+ | | |
466
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
467
+ | `fetchTest(url, init)` | `fetch`, plus a line with the status and the headers worth comparing. Returns the response untouched, so the test goes on to read the body as it would have. Lines come out in call order, never in arrival order. |
468
+ | `sequential([() => …])` | Runs requests one at a time. `Promise.all` starts them together and the two servers then answer in whatever order they scheduled, which is a difference the runner would report as a failure. |
469
+ | `// INSPECT` | On the second line. The runner then mounts `inspectRequest` in front of every app the file makes, and each request prints its `method`, `url`, `originalUrl`, `baseUrl`, `path`, `protocol`, `secure`, `hostname`, `host`, `xhr`, `subdomains` and `query`. |
470
+ | `// OFF: reason` | Skips the file. |
471
+
472
+ `// INSPECT` is not free everywhere, which is why it is asked for rather than always on. It is a
473
+ middleware, so a route behind it stops being compiled into a declarative response and is served by
474
+ the ordinary path instead: a file whose routes do compile would quietly stop covering the compiled
475
+ one. And Express builds its router at the first `use()`, freezing `strict routing` and
476
+ `case sensitive routing` as they are at that moment, so a file that sets either one afterwards must
477
+ not ask for it. Everywhere else it is worth having: it is what caught a pathless mount dropping the
478
+ middleware in front of it.
479
+
462
480
  `npm run test:express` is the other kind of test: it clones Express at the version in
463
481
  `devDependencies`, points its entry at this source and runs its suite against it. It is a bug mine
464
482
  rather than a gate, and its exit status says nothing. Read the header of `tools/express-suite.js`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.0.0",
3
+ "version": "5.1.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": {
@@ -29,6 +29,8 @@ const {
29
29
  NullObject
30
30
  } = require("./utils.js");
31
31
  const querystring = require("fast-querystring");
32
+ const Request = require("./request.js");
33
+ const Response = require("./response.js");
32
34
  const ViewClass = require("./view.js");
33
35
  const path = require("path");
34
36
  const os = require("os");
@@ -37,6 +39,10 @@ const cluster = require("cluster");
37
39
 
38
40
  const cpuCount = os.cpus().length;
39
41
 
42
+ // marks a "trust proxy" that was never set by the application, under the key express uses, so a
43
+ // mounted sub-app knows it may inherit the parent's
44
+ const trustProxyDefaultSymbol = "@@symbol:trust_proxy_default";
45
+
40
46
  const workers = [];
41
47
  let taskKey = 0;
42
48
  const workerTasks = new NullObject();
@@ -101,9 +107,69 @@ class Application extends Router {
101
107
  this.ssl = settings.uwsOptions.key_file_name && settings.uwsOptions.cert_file_name;
102
108
  this.cache = new NullObject();
103
109
  this.engines = { __proto__: null };
104
- this.locals = {
105
- settings: this.settings
110
+ // a null prototype, as express gives app.locals, so a local named like an Object method
111
+ // is just a local
112
+ this.locals = Object.create(null);
113
+ this.locals.settings = this.settings;
114
+ // each app gets its own request/response prototype layer, so extending app.request cannot
115
+ // leak into another app; a mounted sub-app re-parents its layer onto the parent's below.
116
+ // The constructors are written out: the implicit derived one spreads its arguments, which
117
+ // was an allocation on every request
118
+ this._request = class extends Request {
119
+ /**
120
+ * @param {any} req
121
+ * @param {any} res
122
+ * @param {any} app
123
+ */
124
+ constructor(req, res, app) {
125
+ super(req, res, app);
126
+ }
106
127
  };
128
+ this._response = class extends Response {
129
+ /**
130
+ * @param {any} res
131
+ * @param {any} req
132
+ * @param {any} app
133
+ */
134
+ constructor(res, req, app) {
135
+ super(res, req, app);
136
+ }
137
+
138
+ /**
139
+ * Node counts an explicit writeHead as the head gone out; remembered here so the
140
+ * automatic OPTIONS reply can refuse to add headers after it, as express's does.
141
+ *
142
+ * @param {number} statusCode
143
+ * @param {string|Record<string, any>} [statusMessage]
144
+ * @param {Record<string, any>} [headers]
145
+ * @returns {this}
146
+ */
147
+ writeHead(statusCode, statusMessage, headers) {
148
+ this._headWritten = true;
149
+ return super.writeHead(statusCode, statusMessage, headers);
150
+ }
151
+ };
152
+ this.request = this._request.prototype;
153
+ this.response = this._response.prototype;
154
+ this.on("mount", (parent) => {
155
+ // the parent's extensions show through, and an override here stays here. Only an
156
+ // application has a layer to hang onto: a plain router mount leaves things alone
157
+ if (parent.request) {
158
+ Object.setPrototypeOf(this.request, parent.request);
159
+ }
160
+ if (parent.response) {
161
+ Object.setPrototypeOf(this.response, parent.response);
162
+ }
163
+ // a "trust proxy" this app never set is inherited from the parent, as express does:
164
+ // the defaults are deleted so get() falls through to the parent's value
165
+ if (
166
+ this.settings[trustProxyDefaultSymbol] === true &&
167
+ typeof parent.settings["trust proxy fn"] === "function"
168
+ ) {
169
+ delete this.settings["trust proxy"];
170
+ delete this.settings["trust proxy fn"];
171
+ }
172
+ });
107
173
  this.listenCalled = false;
108
174
  this.workers = [];
109
175
  for (let i = 0; i < settings.threads; i++) {
@@ -121,7 +187,15 @@ class Application extends Router {
121
187
  // first and waits for the second, the way node's server.close() does
122
188
  this._listenSocket = undefined;
123
189
  this._pendingResponses = new Set();
190
+ // on the per-app prototype layer, not per response: the set is the same for every
191
+ // response this app serves, and the per-request write was pure repetition
192
+ /** @type {any} */ (this.response)._pendingIn = this._pendingResponses;
124
193
  this._draining = false;
194
+ // read here, at construction, the way express does; an empty NODE_ENV means development,
195
+ // which the ?? in the shared default would miss
196
+ if (typeof this.settings.env === "undefined") {
197
+ this.settings.env = process.env.NODE_ENV || "development";
198
+ }
125
199
  for (const key in defaultSettings) {
126
200
  if (typeof this.settings[key] === "undefined") {
127
201
  if (typeof defaultSettings[key] === "function") {
@@ -131,6 +205,11 @@ class Application extends Router {
131
205
  }
132
206
  }
133
207
  }
208
+ // non-enumerable, so the marker never shows up walking the settings
209
+ Object.defineProperty(this.settings, trustProxyDefaultSymbol, {
210
+ configurable: true,
211
+ value: true
212
+ });
134
213
  this.set("view", ViewClass);
135
214
  this.set("views", path.resolve("views"));
136
215
  }
@@ -186,19 +265,29 @@ class Application extends Router {
186
265
  }
187
266
  if (key === "trust proxy") {
188
267
  if (!value) {
189
- delete this.settings["trust proxy fn"];
268
+ // compiled, not deleted: an explicit false must shadow a parent's setting when
269
+ // this app is mounted, and a deleted key would read straight through to it
270
+ this.settings["trust proxy fn"] = compileTrust(false);
190
271
  } else {
191
272
  this.settings["trust proxy fn"] = compileTrust(value);
192
273
  }
274
+ // set explicitly, so a mount no longer inherits the parent's
275
+ Object.defineProperty(this.settings, trustProxyDefaultSymbol, {
276
+ configurable: true,
277
+ value: false
278
+ });
193
279
  } else if (key === "query parser") {
194
280
  if (value === "extended") {
195
281
  this.settings["query parser fn"] = fastQueryParse;
196
- } else if (value === "simple") {
282
+ } else if (value === "simple" || value === true) {
197
283
  this.settings["query parser fn"] = querystring.parse;
198
284
  } else if (typeof value === "function") {
199
285
  this.settings["query parser fn"] = value;
200
- } else {
286
+ } else if (value === false) {
201
287
  this.settings["query parser fn"] = undefined;
288
+ } else {
289
+ // express's wording, which applications match on
290
+ throw new TypeError("unknown value for query parser function: " + value);
202
291
  }
203
292
  } else if (key === "views") {
204
293
  // a list of directories is searched in order by View.lookup, each resolved here once
@@ -220,7 +309,8 @@ class Application extends Router {
220
309
  delete this.settings["etag fn"];
221
310
  break;
222
311
  default:
223
- throw new Error(`Invalid etag mode: ${value}`);
312
+ // express's wording, which applications match on
313
+ throw new TypeError("unknown value for etag function: " + value);
224
314
  }
225
315
  }
226
316
  }
@@ -275,18 +365,16 @@ class Application extends Router {
275
365
  *
276
366
  * @param {any} res uWS response
277
367
  * @param {any} req uWS request, readable only during this call
278
- * @returns {{request: any, response: any}}
368
+ * @returns {any} the request, with the response reachable as request.res
279
369
  */
280
370
  handleRequest(res, req) {
281
- const handled = super.handleRequest(res, req);
282
- const response = handled.response;
283
- this._pendingResponses.add(response);
371
+ const request = super.handleRequest(res, req);
284
372
  // removal rides the close listener the Response constructor already has, since a second
285
373
  // once() per request measured a tenth of a microsecond on the hot path.
286
374
  // An aborted response only flips its flags without emitting 'close', which is why
287
375
  // close()'s drain also sweeps the set by those flags instead of trusting this alone
288
- response._pendingIn = this._pendingResponses;
289
- return handled;
376
+ this._pendingResponses.add(request.res);
377
+ return request;
290
378
  }
291
379
 
292
380
  /**
@@ -296,7 +384,8 @@ class Application extends Router {
296
384
  */
297
385
  _createRequestHandler() {
298
386
  this.uwsApp.any("/*", async (res, req) => {
299
- const { request, response } = this.handleRequest(res, req);
387
+ const request = this.handleRequest(res, req);
388
+ const response = request.res;
300
389
 
301
390
  try {
302
391
  const matchedRoute = await this._routeRequest(request, response);
@@ -326,21 +415,22 @@ class Application extends Router {
326
415
  *
327
416
  * @param {number|string} [port] port, or a unix socket path; 0 picks a free port
328
417
  * @param {string} [host] interface to bind; every interface when omitted
418
+ * @param {number} [backlog] accepted for node's signature; uWS sizes its own queue
329
419
  * @param {(err?: Error) => void} [callback] called once bound, or with the bind error
330
420
  * @returns {this} the app, which doubles as the server handle
331
421
  */
332
- listen(port, host, callback) {
422
+ listen(port, host, backlog, callback) {
333
423
  this._compileOptimizedRoutes();
334
424
  this._createRequestHandler();
335
- // support listen(callback)
336
- if (!callback && typeof port === "function") {
425
+ // node's shapes: (cb), (port, cb), (port, host, cb) and (port, host, backlog, cb)
426
+ if (typeof port === "function") {
337
427
  callback = port;
338
428
  port = 0;
339
- }
340
- // support listen(port, callback)
341
- if (typeof host === "function") {
429
+ } else if (typeof host === "function") {
342
430
  callback = host;
343
431
  host = undefined;
432
+ } else if (typeof backlog === "function") {
433
+ callback = backlog;
344
434
  }
345
435
  // bare listen() and listen(undefined, cb) bind an OS-assigned port, as node does; left
346
436
  // undefined the port fell through to the unix-socket branch below
package/src/cli.js CHANGED
@@ -51,9 +51,9 @@ const DIFFERENCES = [
51
51
  'A body sent with GET or DELETE is not read unless you add the method: app.set("body methods", [...]).'
52
52
  ],
53
53
  [
54
- "case sensitive routing is on by default",
55
- '/Users and /users are two different routes unless you set app.set("case sensitive routing", false).\n' +
56
- "It is on because it is what makes a route eligible for the native router."
54
+ "case sensitive routing matches Express: insensitive by default",
55
+ "/Users and /users are the same route, as in Express 5. A request in the registered case is still\n" +
56
+ 'answered by the native router; set app.set("case sensitive routing", true) to make case matter.'
57
57
  ],
58
58
  [
59
59
  "x-powered-by is off by default",
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ const uWS = require("uWebSockets.js");
21
21
  const uWSAny = /** @type {any} */ (uWS);
22
22
  const Application = require("./application.js");
23
23
  const Router = require("./router.js");
24
+ const Route = require("./route.js");
24
25
  const middlewares = require("./middlewares.js");
25
26
  const Request = require("./request.js");
26
27
  const Response = require("./response.js");
@@ -41,6 +42,7 @@ try {
41
42
  /**
42
43
  * @type {typeof Application & {
43
44
  * Router: Function,
45
+ * Route: typeof Route,
44
46
  * request: object,
45
47
  * response: object,
46
48
  * application: object,
@@ -59,6 +61,9 @@ module.exports.Router = function (options) {
59
61
  return new Router(options)._asCallable();
60
62
  };
61
63
 
64
+ // express exports it, and code that builds a route by hand rather than through a router uses it
65
+ module.exports.Route = Route;
66
+
62
67
  module.exports.request = Request.prototype;
63
68
  module.exports.response = Response.prototype;
64
69
  // the third of the trio: adding a method here adds it to every app, the same as express.application
@@ -30,6 +30,9 @@ const { fastQueryParse, NullObject, asStatError, httpError, memoizeByString } =
30
30
  // real one of the same size would
31
31
  const MAX_PREALLOCATED_BODY = 1024 * 1024;
32
32
 
33
+ // what the finish pass feeds zlib: no bytes, only the flush flag
34
+ const EMPTY_BUFFER = Buffer.alloc(0);
35
+
33
36
  // The failures express.static answers by moving on to the next handler rather than by reporting
34
37
  // them, when fallthrough is on. They all mean the same thing: the request is not a file here.
35
38
  //
@@ -351,6 +354,16 @@ function serveStatic(root, options) {
351
354
  }
352
355
  }
353
356
 
357
+ // a file asked for with a trailing slash is not that file: send stats the path slash and
358
+ // all and gets ENOTDIR, so a root mounted as a file answers 404 there, not the file
359
+ if (req.endsWithSlash && !stat.isDirectory()) {
360
+ if (!options.fallthrough) {
361
+ res.status(404);
362
+ return next(httpError(404));
363
+ }
364
+ return next();
365
+ }
366
+
354
367
  if (stat.isDirectory()) {
355
368
  if (!req.endsWithSlash) {
356
369
  if (options.redirect) {
@@ -430,6 +443,9 @@ function createInflate(contentEncoding) {
430
443
  default:
431
444
  return false;
432
445
  }
446
+ // the flag the final flush passes, so a truncated stream errors instead of resolving empty
447
+ /** @type {any} */ (stream)._finishFlag =
448
+ encoding === "br" ? zlib.constants.BROTLI_OPERATION_FINISH : zlib.constants.Z_FINISH;
433
449
  return stream;
434
450
  }
435
451
 
@@ -648,61 +664,54 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
648
664
  let finished = false;
649
665
 
650
666
  /**
651
- * One chunk from uWS. Decompresses it, counts it against the limit and keeps it. The
652
- * finished flag matters: uWS goes on delivering chunks after an oversized body has
653
- * been refused, and without it every further chunk would answer the request again.
667
+ * A zlib throw becomes the 400 body-parser answers a corrupt body with.
654
668
  *
655
- * @param {any} buf a Buffer, or an ArrayBuffer straight from uWS
669
+ * zlib reports it twice: process() throws, and the stream emits 'error' a tick
670
+ * later. fast-zlib removes its own listeners on the way out, so that second one
671
+ * lands on nothing, and an unhandled 'error' event ends the process: a corrupt
672
+ * gzip body was enough to take the server down. The listener goes on after the
673
+ * throw, since process() would have removed it.
674
+ *
675
+ * @param {any} err what inflate.process threw
656
676
  */
657
- function onData(buf) {
658
- if (finished) {
659
- return;
660
- }
661
- if (!Buffer.isBuffer(buf)) {
662
- buf = Buffer.from(buf);
663
- }
664
- if (inflate) {
665
- try {
666
- buf = inflate.process(buf);
667
- } catch (e) {
668
- // a body that does not decompress is the client's mistake, and zlib throwing
669
- // here used to escape into whatever called us.
670
- //
671
- // zlib reports it twice: process() throws, and the stream emits 'error' a
672
- // tick later. fast-zlib removes its own listeners on the way out, so that
673
- // second one lands on nothing, and an unhandled 'error' event ends the
674
- // process: a corrupt gzip body was enough to take the server down. The
675
- // listener goes on after the throw, since process() would have removed it
676
- /** @type {any} */ (inflate).instance?.on?.("error", () => {});
677
- finished = true;
678
- abs.length = 0;
679
- target = null;
680
- const err = /** @type {any} */ (e);
681
- err.status = 400;
682
- err.statusCode = 400;
683
- err.expose = true;
684
- return next(err);
685
- }
686
- }
677
+ function failInflate(err) {
678
+ /** @type {any} */ (inflate).instance?.on?.("error", () => {});
679
+ finished = true;
680
+ abs.length = 0;
681
+ target = null;
682
+ err.status = 400;
683
+ err.statusCode = 400;
684
+ err.expose = true;
685
+ next(err);
686
+ }
687
687
 
688
+ /**
689
+ * Counts a decompressed chunk against the limit and keeps it. Answers whether the
690
+ * caller may go on, since passing the limit answers the request right here.
691
+ *
692
+ * @param {Buffer} buf
693
+ * @returns {boolean}
694
+ */
695
+ function keepChunk(buf) {
688
696
  totalSize += buf.length;
689
697
  if (totalSize > options.limit) {
690
698
  finished = true;
691
699
  abs.length = 0;
692
700
  target = null;
693
- return next(
701
+ next(
694
702
  bodyError("request entity too large", 413, "entity.too.large", {
695
703
  limit: options.limit,
696
704
  received: totalSize
697
705
  })
698
706
  );
707
+ return false;
699
708
  }
700
709
 
701
710
  if (target) {
702
711
  if (targetOffset + buf.length <= target.length) {
703
712
  buf.copy(target, targetOffset);
704
713
  targetOffset += buf.length;
705
- return;
714
+ return true;
706
715
  }
707
716
  // more body than content-length promised: keep what we have and fall back
708
717
  abs.push(Buffer.from(target.subarray(0, targetOffset)));
@@ -711,6 +720,34 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
711
720
 
712
721
  // shallow copy, to avoid shared references for large bodies.
713
722
  abs.push(Buffer.from(buf));
723
+ return true;
724
+ }
725
+
726
+ /**
727
+ * One chunk from uWS. Decompresses it, counts it against the limit and keeps it. The
728
+ * finished flag matters: uWS goes on delivering chunks after an oversized body has
729
+ * been refused, and without it every further chunk would answer the request again.
730
+ *
731
+ * @param {any} buf a Buffer, or an ArrayBuffer straight from uWS
732
+ */
733
+ function onData(buf) {
734
+ if (finished) {
735
+ return;
736
+ }
737
+ if (!Buffer.isBuffer(buf)) {
738
+ buf = Buffer.from(buf);
739
+ }
740
+ if (inflate) {
741
+ try {
742
+ buf = inflate.process(buf);
743
+ } catch (e) {
744
+ // a body that does not decompress is the client's mistake, and zlib
745
+ // throwing here used to escape into whatever called us
746
+ return failInflate(e);
747
+ }
748
+ }
749
+
750
+ keepChunk(buf);
714
751
  }
715
752
 
716
753
  /** The body is complete: assemble it, hand it to the parser and continue routing. */
@@ -719,6 +756,20 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
719
756
  return;
720
757
  }
721
758
  finished = true;
759
+ if (inflate) {
760
+ // the flush per chunk cannot tell a complete stream from one cut short, so
761
+ // the finish pass asks zlib outright: a truncated gzip body used to resolve
762
+ // to whatever bytes had come out, where body-parser answers 400
763
+ let tail;
764
+ try {
765
+ tail = inflate.process(EMPTY_BUFFER, inflate._finishFlag);
766
+ } catch (e) {
767
+ return failInflate(e);
768
+ }
769
+ if (tail.length && !keepChunk(tail)) {
770
+ return;
771
+ }
772
+ }
722
773
  // fewer bytes than content-length promised: the request was cut short, and parsing
723
774
  // what did arrive would answer as though it were the whole thing. Not when
724
775
  // inflating, where content-length counts the compressed bytes and totalSize the
package/src/node-shim.js CHANGED
@@ -393,7 +393,8 @@ function isNodeRequest(req) {
393
393
  function serveNodeRequest(router, nodeReq, nodeRes, next) {
394
394
  const shimRes = new NodeHttpResponse(nodeReq, nodeRes);
395
395
  const shimReq = new NodeHttpRequest(nodeReq);
396
- const { request, response } = router.handleRequest(shimRes, shimReq);
396
+ const request = router.handleRequest(shimRes, shimReq);
397
+ const response = request.res;
397
398
 
398
399
  return router._routeRequest(request, response).then((matched) => {
399
400
  if (matched || response.headersSent || response.aborted) {