fulmine.js 5.5.1 → 5.6.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/src/response.js CHANGED
@@ -228,9 +228,10 @@ module.exports = class Response extends LazyWritable {
228
228
  * measured on uWS alone, 500 writes of 66 bytes take 13ms against 0.06ms for 100 of them.
229
229
  * Handing it the same bytes in blocks costs 0.4ms. Nothing here changes what goes on the wire,
230
230
  * only how many calls it takes to put it there.
231
- * @type {Buffer[]}
231
+ * Null until the first chunked write, since a res.send never queues anything.
232
+ * @type {Buffer[]|null}
232
233
  */
233
- #queued = [];
234
+ #queued = null;
234
235
 
235
236
  /** How many bytes {@link #queued} holds, kept alongside so the flush does not add them up. */
236
237
  #queuedBytes = 0;
@@ -437,12 +438,12 @@ module.exports = class Response extends LazyWritable {
437
438
  * @param {((err?: Error|null) => void)|null} callback the stream's, when there is one waiting
438
439
  */
439
440
  #flushQueued(callback) {
440
- if (this.#queuedBytes === 0) {
441
+ if (this.#queued === null || this.#queuedBytes === 0) {
441
442
  if (callback) callback(null);
442
443
  return;
443
444
  }
444
445
  const body = this.#queued.length === 1 ? this.#queued[0] : Buffer.concat(this.#queued, this.#queuedBytes);
445
- this.#queued = [];
446
+ this.#queued = null;
446
447
  this.#queuedBytes = 0;
447
448
 
448
449
  const ok = this._res.write(body);
@@ -467,13 +468,16 @@ module.exports = class Response extends LazyWritable {
467
468
  }
468
469
 
469
470
  /**
470
- * The booked flush. An arrow so it can be handed to nextTick without binding it every write.
471
+ * The booked flush. Static, so a response that never writes in pieces allocates nothing for it:
472
+ * nextTick forwards the receiver as an argument.
473
+ *
474
+ * @param {any} res
471
475
  */
472
- #flushOnTick = () => {
473
- this.#flushBooked = false;
474
- if (this.aborted || this.finished || this.#queuedBytes === 0) return;
475
- this._res.cork(() => this.#flushQueued(null));
476
- };
476
+ static #flushOnTick(res) {
477
+ res.#flushBooked = false;
478
+ if (res.aborted || res.finished || res.#queuedBytes === 0) return;
479
+ res._res.cork(() => res.#flushQueued(null));
480
+ }
477
481
 
478
482
  /**
479
483
  * Writable's sink. Sends the headers if they have not gone yet, then hands the chunk to uWS,
@@ -520,7 +524,7 @@ module.exports = class Response extends LazyWritable {
520
524
  // turn or once it is big enough to be worth a call. A stream that writes once per
521
525
  // turn, an SSE feed for instance, still leaves on its own turn: the queue only ever
522
526
  // gathers what was written without yielding in between.
523
- this.#queued.push(/** @type {Buffer} */ (chunk));
527
+ (this.#queued ??= []).push(/** @type {Buffer} */ (chunk));
524
528
  this.#queuedBytes += /** @type {Buffer} */ (chunk).byteLength;
525
529
  // a chunk that is already big enough to be worth its own call leaves with whatever
526
530
  // was waiting in front of it, rather than paying for a copy it does not need
@@ -529,7 +533,7 @@ module.exports = class Response extends LazyWritable {
529
533
  } else {
530
534
  if (!this.#flushBooked) {
531
535
  this.#flushBooked = true;
532
- process.nextTick(this.#flushOnTick);
536
+ process.nextTick(Response.#flushOnTick, this);
533
537
  }
534
538
  this.writingChunk = false;
535
539
  callback(null);
@@ -786,7 +790,10 @@ module.exports = class Response extends LazyWritable {
786
790
  // remembered rather than measured: only a caller that asks for content-length pays
787
791
  // for it, and uWS is measuring the same bytes for the wire anyway
788
792
  this._sentBody = data ?? "";
789
- this._res.end(data);
793
+ // and null is sent as the empty body it means. uWS answers end(null) with a
794
+ // response the client never sees the end of, where node and express send an empty
795
+ // 200: res.end(null) is what the compression module's own test suite does
796
+ this._res.end(data ?? "");
790
797
  }
791
798
  }
792
799
 
@@ -1329,11 +1336,16 @@ module.exports = class Response extends LazyWritable {
1329
1336
  }
1330
1337
 
1331
1338
  /**
1332
- * Sends the status line and the headers now, without waiting for a body, which is node's
1339
+ * Hands the status line and the headers over now, without waiting for a body, which is node's
1333
1340
  * flushHeaders(). Callers use it to let the client start on the head while the body is still
1334
1341
  * being produced, and one of them is `@angular/ssr`'s writeResponseToNodeResponse, which calls
1335
1342
  * it before streaming a rendered page.
1336
1343
  *
1344
+ * **The head does not reach the wire here.** uWS holds it until the first body chunk, so a
1345
+ * client sees nothing until then, where express answers at once. `beginWrite` is uWS's API for
1346
+ * this and is unusable: it emits a stray CRLF before the first chunk size, which node's parser
1347
+ * rejects as HPE_INVALID_CHUNK_SIZE. Checked against v20.69.0, the latest release.
1348
+ *
1337
1349
  * A second call does nothing, as node's does. Nothing is written for a response already
1338
1350
  * finished or aborted: uWS has let go of it by then.
1339
1351
  *
@@ -1425,10 +1437,6 @@ module.exports = class Response extends LazyWritable {
1425
1437
  * @param {Record<string, string>|[string, string][]} [headers]
1426
1438
  * @returns {void}
1427
1439
  */
1428
-
1429
- /**
1430
- *
1431
- */
1432
1440
  addTrailers(headers) {}
1433
1441
 
1434
1442
  /**
@@ -1440,10 +1448,6 @@ module.exports = class Response extends LazyWritable {
1440
1448
  * @param {() => void} [callback]
1441
1449
  * @returns {this}
1442
1450
  */
1443
-
1444
- /**
1445
- *
1446
- */
1447
1451
  setTimeout(msecs, callback) {
1448
1452
  if (typeof callback === "function") {
1449
1453
  this.once("timeout", callback);
@@ -1458,20 +1462,12 @@ module.exports = class Response extends LazyWritable {
1458
1462
  * @param {any} [socket]
1459
1463
  * @returns {void}
1460
1464
  */
1461
-
1462
- /**
1463
- *
1464
- */
1465
1465
  assignSocket(socket) {}
1466
1466
 
1467
1467
  /**
1468
1468
  * @param {any} [socket]
1469
1469
  * @returns {void}
1470
1470
  */
1471
-
1472
- /**
1473
- *
1474
- */
1475
1471
  detachSocket(socket) {}
1476
1472
 
1477
1473
  /**
@@ -2005,12 +2001,6 @@ module.exports = class Response extends LazyWritable {
2005
2001
  return this.set("content-type", ct);
2006
2002
  }
2007
2003
 
2008
- /**
2009
- * express carries both names for the same method, and middleware reaches for either.
2010
- * @type {(type: string) => any}
2011
- */
2012
- contentType = this.type;
2013
-
2014
2004
  /**
2015
2005
  * Adds a field to Vary, without repeating one already there.
2016
2006
  * @param {string|string[]} field
@@ -2040,3 +2030,7 @@ module.exports = class Response extends LazyWritable {
2040
2030
  return this.finished;
2041
2031
  }
2042
2032
  };
2033
+
2034
+ // res.contentType is res.type under express's other name. On the prototype rather than an instance
2035
+ // field, which wrote one own property per response in the constructor.
2036
+ /** @type {any} */ (module.exports.prototype).contentType = module.exports.prototype.type;
package/src/router.js CHANGED
@@ -446,7 +446,7 @@ class Walk {
446
446
  return this.step(undefined);
447
447
  }
448
448
  if (kind === CALLBACK_ROUTER) {
449
- if (callback.constructor.name === "Application") {
449
+ if (callback._isApplication) {
450
450
  rememberApp(this, route, req);
451
451
  useApp(req, callback);
452
452
  }
@@ -616,10 +616,15 @@ function nativeFail(err) {
616
616
  * @returns {number}
617
617
  */
618
618
  function mountPrefixLength(route, req) {
619
- const path = req._opPath;
619
+ // a use with no path is EMPTY_REGEX, which matches "" at 0 whatever the path is. Answered
620
+ // without the exec, since this runs per hop and most middleware is pathless
621
+ if (route.pattern === EMPTY_REGEX) {
622
+ return 0;
623
+ }
620
624
  if (typeof route.pattern === "string") {
621
625
  return route.pattern.length;
622
626
  }
627
+ const path = req._opPath;
623
628
  const matched = route.pattern.exec(path === "" ? "/" : path);
624
629
  return matched ? Math.min(matched[0].length, path.length) : 0;
625
630
  }
@@ -1057,7 +1062,7 @@ function guardsInside(router, mount, pathPrefix, chain, inherited) {
1057
1062
  * @param {any} req
1058
1063
  */
1059
1064
  function rememberApp(walk, route, req) {
1060
- if (walk.router._isApplication && route.callbacks[0]?.constructor.name === "Application") {
1065
+ if (walk.router._isApplication && route.callbacks[0]?._isApplication) {
1061
1066
  (req._appStack ??= []).push(route, req.app);
1062
1067
  }
1063
1068
  }
@@ -2536,6 +2541,24 @@ module.exports = class Router extends EventEmitter {
2536
2541
  });
2537
2542
  }
2538
2543
 
2544
+ /**
2545
+ * The same walk without the promise pair, for a uWS handler that never awaited it. nativeDone
2546
+ * and nativeFail defer their epilogues to the microtask the await used to resume on, so the
2547
+ * visible order holds.
2548
+ *
2549
+ * @param {any} req
2550
+ * @param {any} res
2551
+ */
2552
+ _routeRequestDirect(req, res) {
2553
+ const walk = new Walk(this, req, res, this._routes, false, undefined, nativeDone, nativeFail);
2554
+ try {
2555
+ walk.dispatch(0);
2556
+ } catch (err) {
2557
+ // what a throw inside a promise executor did: reject, once
2558
+ nativeFail.call(walk, err);
2559
+ }
2560
+ }
2561
+
2539
2562
  /**
2540
2563
  * Mounts middleware, or a whole router, at a path. The path is optional, and a mount matches
2541
2564
  * everything under it, which is what separates it from all(). Mounting a Router sets its
package/src/types.d.ts CHANGED
@@ -17,6 +17,7 @@ limitations under the License.
17
17
  declare module "fulmine.js" {
18
18
  import e from "express";
19
19
  import uWS from "uWebSockets.js";
20
+ import { ZlibOptions, BrotliOptions } from "zlib";
20
21
 
21
22
  type Settings = {
22
23
  uwsOptions?: uWS.AppOptions;
@@ -37,6 +38,24 @@ declare module "fulmine.js" {
37
38
  export import static = e.static;
38
39
  // export import query = e.query;
39
40
 
41
+ // express has no compression middleware, so there is nothing to re-export: these are the
42
+ // compression module's options, which this one takes as they are
43
+ interface CompressionOptions extends ZlibOptions {
44
+ /** The smallest body worth compressing, in bytes or as "1kb". Default 1024. */
45
+ threshold?: number | string;
46
+ /** Whether this response should be compressed at all. */
47
+ filter?: (req: e.Request, res: e.Response) => boolean;
48
+ /** What to use when the request carries no Accept-Encoding. Default "identity". */
49
+ enforceEncoding?: string;
50
+ /** Brotli options. The default quality is 4. */
51
+ brotli?: BrotliOptions;
52
+ }
53
+ export function compression(options?: CompressionOptions): e.RequestHandler;
54
+ export namespace compression {
55
+ /** The default filter: any compressible content type. */
56
+ function filter(req: e.Request, res: e.Response): boolean;
57
+ }
58
+
40
59
  export import urlencoded = e.urlencoded;
41
60
 
42
61
  export import RouterOptions = e.RouterOptions;
package/src/utils.js CHANGED
@@ -788,6 +788,113 @@ function stringify(value, replacer, spaces, escape) {
788
788
  return json;
789
789
  }
790
790
 
791
+ // What negotiateEncoding may answer with, since a caller can only offer what it can produce
792
+ const ENCODING_BR = 1;
793
+ const ENCODING_GZIP = 2;
794
+ const ENCODING_DEFLATE = 4;
795
+ const ENCODING_ANY = ENCODING_BR | ENCODING_GZIP | ENCODING_DEFLATE;
796
+
797
+ /**
798
+ * The encoding to answer with, read straight off Accept-Encoding rather than through negotiator:
799
+ * the header is a short list of names with an optional q, and building a Negotiator per response
800
+ * to read it costs more than the scan does.
801
+ *
802
+ * The tie-break is negotiator's, for the list the compression module hands it: brotli first, then
803
+ * gzip, then deflate, and identity last.
804
+ *
805
+ * Only the encodings named in `allowed` are on offer, since the caller may not be able to
806
+ * produce all three: express.static offers the two it can have lying on disk. An uncompressed
807
+ * answer is always on offer, and is what an empty header ends up choosing.
808
+ *
809
+ * @param {string} accept the header, or "" when the request carried none
810
+ * @param {number} allowed ENCODING_BR, ENCODING_GZIP and ENCODING_DEFLATE, or'd together
811
+ * @returns {string} "br", "gzip", "deflate", "identity", or "" when nothing is acceptable
812
+ */
813
+ function negotiateEncoding(accept, allowed) {
814
+ // -1 while a name has not appeared: q=0 is a refusal and has to be told apart from silence
815
+ let br = -1;
816
+ let gzip = -1;
817
+ let deflate = -1;
818
+ let identity = -1;
819
+ let star = -1;
820
+ // the lowest q anything was named with, which is what an unnamed identity is worth, see below
821
+ let minQuality = 1;
822
+ let index = 0;
823
+ while (index < accept.length) {
824
+ let end = accept.indexOf(",", index);
825
+ if (end === -1) {
826
+ end = accept.length;
827
+ }
828
+ let semi = accept.indexOf(";", index);
829
+ if (semi === -1 || semi > end) {
830
+ semi = end;
831
+ }
832
+ const name = accept.slice(index, semi).trim().toLowerCase();
833
+ let q = 1;
834
+ if (semi < end) {
835
+ const params = accept.slice(semi + 1, end);
836
+ const at = params.indexOf("q=");
837
+ if (at !== -1) {
838
+ const parsed = parseFloat(params.slice(at + 2));
839
+ // a q nobody can read is a refusal, which is how negotiator reads it too
840
+ q = parsed === parsed ? parsed : 0;
841
+ }
842
+ }
843
+ if (q < minQuality) {
844
+ minQuality = q;
845
+ }
846
+ switch (name) {
847
+ case "br":
848
+ br = q;
849
+ break;
850
+ case "gzip":
851
+ gzip = q;
852
+ break;
853
+ case "deflate":
854
+ deflate = q;
855
+ break;
856
+ case "identity":
857
+ identity = q;
858
+ break;
859
+ case "*":
860
+ star = q;
861
+ break;
862
+ }
863
+ index = end + 1;
864
+ }
865
+ if (br < 0) br = star;
866
+ if (gzip < 0) gzip = star;
867
+ if (deflate < 0) deflate = star;
868
+ // An uncompressed answer that the request did not name is worth the lowest q it named
869
+ // anything with, which is negotiator's rule and not the obvious one: "br;q=0.5, gzip;q=0.9"
870
+ // means gzip, because identity comes in at 0.5 rather than at 1 and does not win the list.
871
+ // A "*" names identity as much as it names anything else, so its q is identity's.
872
+ if (identity < 0) identity = star < 0 ? minQuality : star;
873
+
874
+ if (!(allowed & ENCODING_BR)) br = -1;
875
+ if (!(allowed & ENCODING_GZIP)) gzip = -1;
876
+ if (!(allowed & ENCODING_DEFLATE)) deflate = -1;
877
+
878
+ let best = "";
879
+ let bestQ = 0;
880
+ if (br > bestQ) {
881
+ best = "br";
882
+ bestQ = br;
883
+ }
884
+ if (gzip > bestQ) {
885
+ best = "gzip";
886
+ bestQ = gzip;
887
+ }
888
+ if (deflate > bestQ) {
889
+ best = "deflate";
890
+ bestQ = deflate;
891
+ }
892
+ if (identity > bestQ) {
893
+ best = "identity";
894
+ }
895
+ return best;
896
+ }
897
+
791
898
  const defaultSettings = {
792
899
  "jsonp callback name": "callback",
793
900
  env: () => process.env.NODE_ENV ?? "development",
@@ -1102,6 +1209,11 @@ function createETagGenerator(options) {
1102
1209
  if (body instanceof Stats) {
1103
1210
  return statTag(body, options.weak);
1104
1211
  }
1212
+ // crypto.hash reads a string as the utf8 bytes Buffer.from would have produced, so the tag
1213
+ // is the same one without copying the whole body first
1214
+ if (typeof body === "string" && (encoding === undefined || encoding === "utf8" || encoding === "utf-8")) {
1215
+ return entityTag(body, options.weak);
1216
+ }
1105
1217
  const buf = !Buffer.isBuffer(body) ? Buffer.from(body, encoding) : body;
1106
1218
  return entityTag(buf, options.weak);
1107
1219
  };
@@ -1300,6 +1412,10 @@ module.exports = {
1300
1412
  entityTag,
1301
1413
  statTag,
1302
1414
  contentTypeFor,
1415
+ negotiateEncoding,
1416
+ ENCODING_BR,
1417
+ ENCODING_GZIP,
1418
+ ENCODING_ANY,
1303
1419
  memoizeByString,
1304
1420
  isRangeFresh,
1305
1421
  findIndexStartingFrom,