fulmine.js 5.1.4 → 5.1.6

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/NOTICE CHANGED
@@ -12,6 +12,10 @@ itself and can be inspected with `git log` and `git shortlog -sn`.
12
12
  Fulmine is not affiliated with, endorsed by, or maintained by the authors of
13
13
  Ultimate Express.
14
14
 
15
+ This product includes code derived from fast-querystring
16
+ (https://github.com/anonrig/fast-querystring), Copyright (c) Yagiz Nizipli,
17
+ licensed under the MIT License, vendored in src/parse-query.js.
18
+
15
19
  As required by section 4(b) of the Apache License, the following are the
16
20
  significant changes made to the original work:
17
21
 
@@ -19,10 +23,27 @@ significant changes made to the original work:
19
23
  - Public API documented, and type checked from those annotations.
20
24
  - Router dispatch reworked so a chain of N middlewares allocates one promise
21
25
  instead of N, and the mount stack is no longer walked on every hop.
22
- - Request bodies are collected into a single buffer instead of being copied
23
- once per chunk and again on concatenation.
26
+ - Request bodies are collected in native code by uWebSockets.js when the
27
+ length is known, with the size limit enforced before any byte reaches
28
+ JavaScript, instead of being copied once per chunk and again on
29
+ concatenation; the body parsers read the few headers they need from the
30
+ raw header entries and bind the caller's async context only when a read
31
+ actually goes asynchronous.
24
32
  - Empty request bodies now produce the same value each Express body parser
25
33
  produces, rather than being skipped.
34
+ - Routes whose path is a literal are registered as native uWebSockets.js
35
+ routes, with their path and method handed to the request as
36
+ registration-time constants.
37
+ - sendFile answers small unchanged files from a stat-validated cache, and
38
+ concurrent reads of the same file are coalesced.
39
+ - The query string parser is vendored from fast-querystring and answers on a
40
+ bare null prototype, matching how Express displays parsed queries.
41
+ - Express 5's own test suite runs against Fulmine as a CI gate, from a
42
+ pinned checkout of Express.
43
+ - The app is callable as a request listener, so http.createServer(app),
44
+ supertest and anything else that invokes an app directly keeps working
45
+ through a node:http shim.
46
+ - Releases are built and published to npm from CI.
26
47
  - Benchmark harness reworked: wrk replaced by autocannon so the suite runs
27
48
  anywhere Node does, NODE_ENV is set, load errors and response validation are
28
49
  no longer discarded, rows bounded by shared work are labelled, and scenarios
package/README.md CHANGED
@@ -90,6 +90,31 @@ npx fulmine differences # print the list below and change nothing
90
90
  The command is installed under both `fulmine` and `fulmine.js`. Use `fulmine`: `npx` cannot run a
91
91
  command whose name ends in `.js` on Windows, where it exits without a word.
92
92
 
93
+ ## Docker
94
+
95
+ Two things about µWebSockets.js make a Dockerfile that works for Express fail here, and both have easy answers:
96
+
97
+ - **No Alpine.** µWebSockets.js ships prebuilt binaries linked against glibc. Alpine images use musl, so the binary does not load. Use a Debian-based image such as `node:22-slim` instead of `node:22-alpine`.
98
+ - **`git` must be there when `npm install` runs.** µWebSockets.js is not on npm; it is installed straight from GitHub (`github:uNetworking/uWebSockets.js`), and npm uses git to fetch it. Full images like `node:22` have git; `-slim` ones do not.
99
+
100
+ The clean way to satisfy both is a multi-stage build: install with the full image, run with the slim one.
101
+
102
+ ```dockerfile
103
+ FROM node:22 AS build
104
+ WORKDIR /app
105
+ COPY package*.json ./
106
+ RUN npm ci --omit=dev
107
+
108
+ FROM node:22-slim
109
+ WORKDIR /app
110
+ COPY --from=build /app/node_modules ./node_modules
111
+ COPY . .
112
+ EXPOSE 3000
113
+ CMD ["node", "server.js"]
114
+ ```
115
+
116
+ A single-stage `node:22-slim` image works too if you `apt-get install -y git` before `npm ci`. Prebuilt binaries exist for x64 and arm64 on Linux, macOS and Windows, so nothing is compiled at install time either way.
117
+
93
118
  ## Differences from Express
94
119
 
95
120
  - `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.1.4",
3
+ "version": "5.1.6",
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": {
@@ -289,7 +289,10 @@ function serveStatic(root, options) {
289
289
  options._ownEtag = true;
290
290
 
291
291
  return (req, res, next) => {
292
- next = AsyncResource.bind(next);
292
+ // Not bound here: every path down to sendFile is synchronous, statSync included, so the
293
+ // caller's async context is intact at each of these next() calls. Only sendFile's
294
+ // completion can arrive on a uWS callback that carries no context, and that one
295
+ // continuation is bound where it is handed over.
293
296
 
294
297
  // a file is read, not written: anything but GET and HEAD belongs to whoever comes next, or
295
298
  // is refused outright when this middleware is the last word
@@ -410,11 +413,15 @@ function serveStatic(root, options) {
410
413
 
411
414
  options._stat = stat;
412
415
 
413
- return res.sendFile(_path, options, (e) => {
414
- if (e) {
415
- next(options.fallthrough && FALLTHROUGH_STATUSES.has(e.status) ? undefined : e);
416
- }
417
- });
416
+ return res.sendFile(
417
+ _path,
418
+ options,
419
+ AsyncResource.bind((e) => {
420
+ if (e) {
421
+ next(options.fallthrough && FALLTHROUGH_STATUSES.has(e.status) ? undefined : e);
422
+ }
423
+ })
424
+ );
418
425
  };
419
426
  }
420
427
 
@@ -462,9 +469,11 @@ function createInflate(contentEncoding) {
462
469
  * @param {string} [charsetPolicy] which charsets this parser accepts, as body-parser draws the
463
470
  * lines: "utf" (json, utf-* only), "urlencoded" (utf-8 and iso-8859-1), "any" (anything iconv
464
471
  * knows), or undefined for a parser that never decodes (raw)
472
+ * @param {boolean} [keepsBuffer] whether the collected buffer itself escapes to the application,
473
+ * which rules out handing it a view over uWS memory
465
474
  * @returns {(options?: object) => Function} the middleware factory
466
475
  */
467
- function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy) {
476
+ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy, keepsBuffer) {
468
477
  return function (options) {
469
478
  // a copy, because everything below writes the parsed values back: with the caller's own
470
479
  // object, altering it after the parser was built would alter the parser
@@ -500,6 +509,10 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
500
509
  }
501
510
  if (typeof options.defaultCharset === "undefined") options.defaultCharset = "utf-8";
502
511
 
512
+ // whether the collected bytes escape the collection callback: the raw parser hands the
513
+ // buffer itself to the application, and a verify hook may keep what it is shown
514
+ const copyBody = keepsBuffer || typeof options.verify === "function";
515
+
503
516
  // Whether a content-type is one this parser claims, remembered per parser.
504
517
  //
505
518
  // Only reached when the caller asked for a wildcard or a list, since a plain type takes the
@@ -514,14 +527,17 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
514
527
  let additionalMethods;
515
528
 
516
529
  return (req, res, next) => {
517
- next = AsyncResource.bind(next);
530
+ // Not bound yet: every return in this prologue is synchronous, so the caller's async
531
+ // context is still intact and an AsyncResource here would be 1.4 microseconds of
532
+ // nothing. The bind happens below, only once a real read is about to go async.
518
533
 
519
534
  // skip reading body twice
520
535
  if (req.bodyRead) {
521
536
  return next();
522
537
  }
523
538
 
524
- const type = req.headers["content-type"];
539
+ // straight from the raw entries: three headers do not justify building the object
540
+ const type = req._rawHeader("content-type");
525
541
 
526
542
  // req.body is deliberately left undefined until a parser claims the request. That is
527
543
  // what lets a handler tell "nothing parsed this" apart from "the body was empty",
@@ -534,13 +550,13 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
534
550
  return next();
535
551
  }
536
552
 
537
- const length = req.headers["content-length"];
553
+ const length = req._rawHeader("content-length");
538
554
 
539
555
  // No content-length and no transfer-encoding means the request carries no body at all,
540
556
  // and a body parser must leave it alone rather than parse nothing into an empty value.
541
557
  // type-is applies this before matching the type, but the simpleType shortcut below
542
558
  // compares strings directly and would otherwise skip the check.
543
- if (req.headers["transfer-encoding"] === undefined && isNaN(length)) {
559
+ if (req._rawHeader("transfer-encoding") === undefined && isNaN(length)) {
544
560
  return next();
545
561
  }
546
562
 
@@ -620,7 +636,8 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
620
636
  const abs = [];
621
637
  let inflate;
622
638
  let totalSize = 0;
623
- const contentEncoding = (req.headers["content-encoding"] || "identity").toLowerCase();
639
+ const rawContentEncoding = req._rawHeader("content-encoding");
640
+ const contentEncoding = (rawContentEncoding || "identity").toLowerCase();
624
641
  if (!options.inflate && contentEncoding !== "identity") {
625
642
  return next(
626
643
  bodyError("content encoding unsupported", 415, "encoding.unsupported", {
@@ -629,19 +646,64 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
629
646
  );
630
647
  }
631
648
  if (options.inflate) {
632
- inflate = createInflate(req.headers["content-encoding"]);
649
+ inflate = createInflate(rawContentEncoding);
633
650
  if (inflate === false) {
634
651
  return next(
635
652
  bodyError(
636
- 'unsupported content encoding "' + req.headers["content-encoding"] + '"',
653
+ 'unsupported content encoding "' + rawContentEncoding + '"',
637
654
  415,
638
655
  "encoding.unsupported",
639
- { encoding: req.headers["content-encoding"] }
656
+ {
657
+ encoding: rawContentEncoding
658
+ }
640
659
  )
641
660
  );
642
661
  }
643
662
  }
644
663
 
664
+ // From here the body really gets read, and uWS delivers it on native callbacks that
665
+ // carry no async context, so this is the one continuation that has to be bound: an
666
+ // upstream middleware's AsyncLocalStorage must still be there when next runs
667
+ next = AsyncResource.bind(next);
668
+
669
+ // with a known content-length and nothing to decompress, uWS can collect the whole
670
+ // body in native code: one callback instead of one per chunk, the limit enforced
671
+ // before any byte reaches JS, and no copy at all - the parsers turn the bytes into
672
+ // req.body before the callback returns, so a view over uWS's own memory is enough
673
+ if (!req.receivedData && !inflate && !isNaN(length) && Number(length) > 0 && req._res.collectBody) {
674
+ req.bodyRead = true;
675
+ const declared = Number(length);
676
+ req._res.collectBody(options.limit, (body) => {
677
+ if (body === null) {
678
+ // over maxSize: uWS refused it natively
679
+ return next(
680
+ bodyError("request entity too large", 413, "entity.too.large", {
681
+ limit: options.limit,
682
+ received: options.limit
683
+ })
684
+ );
685
+ }
686
+ if (body.byteLength !== declared) {
687
+ return next(
688
+ bodyError("request size did not match content length", 400, "request.size.invalid", {
689
+ expected: declared,
690
+ length: declared,
691
+ received: body.byteLength
692
+ })
693
+ );
694
+ }
695
+ let buf = Buffer.from(body);
696
+ if (copyBody) {
697
+ buf = Buffer.from(buf);
698
+ }
699
+ if (!runVerify(req, res, next, options, buf)) {
700
+ return;
701
+ }
702
+ beforeReturn(req, res, next, options, buf, encoding);
703
+ });
704
+ return;
705
+ }
706
+
645
707
  // uWS neuters its ArrayBuffer after the callback, so every chunk has to be copied out of
646
708
  // it - and then Buffer.concat copied the whole body a second time. when content-length is
647
709
  // known and we aren't inflating, the final size is known up front, so chunks can go
@@ -860,10 +922,17 @@ const json = createBodyParser(
860
922
  "utf"
861
923
  );
862
924
 
863
- const raw = createBodyParser("application/octet-stream", function (req, res, next, options, buf) {
864
- req.body = buf;
865
- next();
866
- });
925
+ const raw = createBodyParser(
926
+ "application/octet-stream",
927
+ function (req, res, next, options, buf) {
928
+ req.body = buf;
929
+ next();
930
+ },
931
+ undefined,
932
+ undefined,
933
+ // req.body is the collected buffer itself, so it must not be a view over uWS memory
934
+ true
935
+ );
867
936
 
868
937
  const text = createBodyParser(
869
938
  "text/plain",
package/src/request.js CHANGED
@@ -324,6 +324,28 @@ module.exports = class Request extends Readable {
324
324
  });
325
325
  }
326
326
 
327
+ /**
328
+ * One header by its lowercase wire name, straight from the raw entries. The body parsers ask
329
+ * for three of these per request, and materializing the whole headers object for that costs
330
+ * more than all three scans together. Reads the built object instead when it already exists,
331
+ * so joined duplicates come out the same either way.
332
+ *
333
+ * @param {string} name lowercase
334
+ * @returns {string|undefined}
335
+ */
336
+ _rawHeader(name) {
337
+ if (this.#cachedHeaders !== null) {
338
+ return this.#cachedHeaders[name];
339
+ }
340
+ const entries = this.#rawHeadersEntries;
341
+ for (let i = 0, len = entries.length; i < len; i += 2) {
342
+ if (entries[i] === name) {
343
+ return entries[i + 1];
344
+ }
345
+ }
346
+ return undefined;
347
+ }
348
+
327
349
  /**
328
350
  * Whether there is any point still reading the body: once the response is finished or the
329
351
  * connection is gone, uWS has nothing left to hand over.