fulmine.js 5.1.9 → 5.3.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/cli.js CHANGED
@@ -20,6 +20,12 @@ limitations under the License.
20
20
  // Rewrites the module specifier and nothing else. An Express 5 app is a Fulmine app already, so
21
21
  // there is no code to translate: what there is instead is a short list of things that behave
22
22
  // differently, printed at the end, because no rewrite can find those for you.
23
+ //
24
+ // npx fulmine profile [entry]
25
+ //
26
+ // Prints what listen() worked out about each route and normally keeps to itself: which ones µWS
27
+ // answers on its own, which ones fell back to the ordinary router and why, and which ones were
28
+ // compiled all the way down to a response written at startup.
23
29
 
24
30
  const fs = require("fs");
25
31
  const path = require("path");
@@ -259,6 +265,285 @@ function walk(node, visit) {
259
265
  }
260
266
  }
261
267
 
268
+ /** Where an application usually is, when the command was given no entry to load. */
269
+ const DEFAULT_ENTRIES = ["server.js", "app.js", "index.js", "src/server.js", "src/app.js", "src/index.js"];
270
+
271
+ /**
272
+ * The file to load, from the argument, or from package.json's main, or from the usual names.
273
+ *
274
+ * @param {string|undefined} given
275
+ * @returns {string|null}
276
+ */
277
+ function findEntry(given) {
278
+ if (given) {
279
+ const resolved = path.resolve(given);
280
+ return fs.existsSync(resolved) ? resolved : null;
281
+ }
282
+ try {
283
+ const pkg = JSON.parse(fs.readFileSync(path.resolve("package.json"), "utf8"));
284
+ if (pkg.main && fs.existsSync(path.resolve(pkg.main))) {
285
+ return path.resolve(pkg.main);
286
+ }
287
+ } catch {
288
+ // no package.json, or one that will not parse: the usual names are still worth trying
289
+ }
290
+ for (const name of DEFAULT_ENTRIES) {
291
+ const resolved = path.resolve(name);
292
+ if (fs.existsSync(resolved)) {
293
+ return resolved;
294
+ }
295
+ }
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * Every route of an application and of the routers under it, each with the router it belongs to
301
+ * and the path it answers from the outside.
302
+ *
303
+ * @param {any} router
304
+ * @param {string} prefix
305
+ * @param {any[]} [into]
306
+ * @returns {any[]}
307
+ */
308
+ function collectRoutes(router, prefix, into = []) {
309
+ for (const route of router._routes ?? []) {
310
+ const full = prefix + (typeof route.path === "string" ? route.path : String(route.pattern)) || "/";
311
+ into.push({ route, full });
312
+ const mounted = route.callbacks?.[0];
313
+ if (mounted && Array.isArray(mounted._routes)) {
314
+ collectRoutes(mounted, typeof route.path === "string" ? prefix + route.path : prefix, into);
315
+ }
316
+ }
317
+ return into;
318
+ }
319
+
320
+ /**
321
+ * Loads an application without letting it listen, and prints what compiling its routes decided.
322
+ *
323
+ * listen() is where the routes are compiled and also where the port is bound, and only the first
324
+ * of those is wanted here: an application that answered on its port while being profiled would be
325
+ * a surprise, and a second copy of a running service is worse than a surprise. So listen is
326
+ * replaced by the half that matters. The callback it was given is not run, for the same reason.
327
+ *
328
+ * @param {string[]} argv
329
+ * @returns {number} exit code
330
+ */
331
+ function profile(argv) {
332
+ const entry = findEntry(argv.find((arg) => !arg.startsWith("--")));
333
+ if (!entry) {
334
+ console.error(
335
+ "Nothing to profile: name the file that builds the application, or run this from a\n" +
336
+ "directory whose package.json main points at it."
337
+ );
338
+ return 1;
339
+ }
340
+
341
+ // the same module instance the application will load, so patching this prototype patches the
342
+ // application it builds. An app is a callable, so its own prototype is not the one that carries
343
+ // the methods: walk up to whichever link owns listen
344
+ const express = require("./index.js");
345
+ let proto = Object.getPrototypeOf(express());
346
+ while (proto && !Object.prototype.hasOwnProperty.call(proto, "listen")) {
347
+ proto = Object.getPrototypeOf(proto);
348
+ }
349
+ if (!proto) {
350
+ console.error("This build of fulmine has no listen() to stand in for, which should not happen.");
351
+ return 1;
352
+ }
353
+
354
+ const listened = [];
355
+ const realListen = proto.listen;
356
+ proto.listen = function stubbedListen() {
357
+ this._compileOptimizedRoutes();
358
+ listened.push(this);
359
+ return this;
360
+ };
361
+
362
+ try {
363
+ require(entry);
364
+ } catch (e) {
365
+ const error = /** @type {any} */ (e);
366
+ proto.listen = realListen;
367
+ console.error(`${path.relative(process.cwd(), entry)} could not be loaded:\n${error.stack ?? error}`);
368
+ return 1;
369
+ }
370
+ proto.listen = realListen;
371
+
372
+ let apps = listened;
373
+ if (apps.length === 0) {
374
+ // an application that exports itself rather than listening, which is how a testable one is
375
+ // usually written. Compiling it here is the same work listen() would have done
376
+ const exported = require(entry);
377
+ const candidate = exported?.default ?? exported?.app ?? exported;
378
+ if (candidate && Array.isArray(candidate._routes)) {
379
+ candidate._compileOptimizedRoutes();
380
+ apps = [candidate];
381
+ }
382
+ }
383
+
384
+ if (apps.length === 0) {
385
+ console.error(
386
+ `${path.relative(process.cwd(), entry)} built no application: it neither called listen() nor\n` +
387
+ "exported one. Point this at the file that does."
388
+ );
389
+ return 1;
390
+ }
391
+
392
+ for (const app of apps) {
393
+ printProfile(app, apps.length > 1);
394
+ }
395
+ return 0;
396
+ }
397
+
398
+ // what a reason means for whoever wrote the route, when it means anything they can act on. A
399
+ // method µWS does not serve, or a path only a regular expression can match, is not something to
400
+ // go and fix; an ordering that costs the native match is.
401
+ /** @type {[RegExp, (match: RegExpExecArray) => string][]} */
402
+ const ADVICE = [
403
+ [
404
+ /^the parameter route (.+) is written before it$/,
405
+ (match) =>
406
+ `write it above ${match[1]}. Express answers whichever matches first, so the order is` +
407
+ ` already what decides,\n and with the literal first µWS can match it in C++ as well.`
408
+ ],
409
+ [
410
+ /^something before it in the same router overlaps its paths$/,
411
+ () =>
412
+ "something registered earlier answers some of the same paths, so the chain that would" +
413
+ " reach this route\n cannot be worked out ahead of time. Narrowing the earlier path, or moving this one above it, frees it."
414
+ ],
415
+ [
416
+ /^a route after it in the same mounted router could answer the same paths$/,
417
+ () =>
418
+ "a route below it in the same mounted router overlaps it. Inside a mount the later one" +
419
+ " has to be able to win,\n which a precomputed chain cannot express. Narrowing either path frees it."
420
+ ]
421
+ ];
422
+
423
+ /**
424
+ * A summary that says how much of this application the native router carries, and what could be
425
+ * changed to make it carry more.
426
+ *
427
+ * There is no score here on purpose. A percentage of routes is not a percentage of traffic: an
428
+ * application with a thousand cold routes and one hot one that fell back would score well and
429
+ * serve badly. What is printed instead is counted rather than judged, and the advice is only
430
+ * printed for the reasons somebody can actually act on.
431
+ *
432
+ * @param {any[]} routes
433
+ * @param {any[]} native
434
+ * @param {any[]} declarative
435
+ */
436
+ function printSummary(routes, native, declarative) {
437
+ console.log("\nWhat this adds up to\n");
438
+ console.log(` ${native.length} of ${routes.length} route(s) matched by µWS in C++`);
439
+ if (declarative.length > 0) {
440
+ console.log(` ${declarative.length} answered from a response written at startup, running no javascript`);
441
+ }
442
+
443
+ const skipHeaders = native.filter(({ route }) => route._native.skipHeaders).length;
444
+ const skipQuery = native.filter(({ route }) => route._native.skipQuery).length;
445
+ if (skipHeaders || skipQuery) {
446
+ console.log(
447
+ ` ${skipHeaders} copy no request headers, ${skipQuery} read no query: the analysis proved nothing asks for them`
448
+ );
449
+ }
450
+
451
+ if (native.length > 0) {
452
+ const ahead = native.map(({ route }) => route._native.ahead);
453
+ const total = ahead.reduce((sum, n) => sum + n, 0);
454
+ console.log(
455
+ ` layers in front of a compiled handler: ${Math.min(...ahead)} at least, ${Math.max(...ahead)} at most,` +
456
+ ` ${(total / ahead.length).toFixed(1)} on average`
457
+ );
458
+ }
459
+
460
+ // the routes whose reason somebody can do something about
461
+ const worth = [];
462
+ for (const { route, full } of routes) {
463
+ if (route._native || !route._whyGeneric) continue;
464
+ for (const [pattern, say] of ADVICE) {
465
+ const match = pattern.exec(route._whyGeneric);
466
+ if (match) {
467
+ worth.push(` ${route.method} ${full}\n ${say(match)}`);
468
+ break;
469
+ }
470
+ }
471
+ }
472
+ if (worth.length > 0) {
473
+ console.log(`\nWorth changing, if these are routes that carry traffic\n`);
474
+ console.log(worth.join("\n\n"));
475
+ }
476
+ }
477
+
478
+ /**
479
+ * @param {any} app
480
+ * @param {boolean} several whether to say which application this is
481
+ */
482
+ function printProfile(app, several) {
483
+ const entries = collectRoutes(app, "");
484
+ const routes = entries.filter(({ route }) => !route.use);
485
+ const mounts = entries.filter(({ route }) => route.use);
486
+ const native = routes.filter(({ route }) => route._native);
487
+ const declarative = native.filter(({ route }) => route._native.declarative);
488
+
489
+ if (several) {
490
+ console.log(`\n=== an application listening on ${app._listenHost ?? "its own port"} ===`);
491
+ }
492
+ console.log(
493
+ `\n${routes.length} route(s), ${native.length} answered by µWS itself` +
494
+ `${declarative.length ? `, ${declarative.length} of them without running any javascript` : ""}\n`
495
+ );
496
+
497
+ for (const { route, full } of routes) {
498
+ const method = String(route.method).padEnd(7);
499
+ const where = full.padEnd(34);
500
+ if (route._native) {
501
+ const notes = [];
502
+ if (route._native.declarative) notes.push("compiled to a response");
503
+ if (route._native.ahead) notes.push(`${route._native.ahead} in front of it in its chain`);
504
+ if (route._native.guards) notes.push(`${route._native.guards} case guard(s)`);
505
+ if (route._native.skipHeaders) notes.push("copies no request headers");
506
+ if (route._native.skipQuery) notes.push("reads no query");
507
+ console.log(
508
+ ` ${method}${where}µWS ${route._native.path}${notes.length ? ` (${notes.join(", ")})` : ""}`
509
+ );
510
+ } else {
511
+ console.log(` ${method}${where}router: ${route._whyGeneric ?? "it was not eligible"}`);
512
+ }
513
+ }
514
+
515
+ // a mount holding a router is one the compiler could have walked into; anything else is
516
+ // middleware, and listing every helmet and cors as a mount that "was not walked into" says
517
+ // nothing anyone can act on
518
+ const routers = mounts.filter(
519
+ ({ route }) => route.callbacks?.length === 1 && Array.isArray(route.callbacks[0]?._routes)
520
+ );
521
+ const missed = routers.filter(({ route }) => !route._walkedInto);
522
+ if (missed.length > 0) {
523
+ console.log(`\n${missed.length} mounted router(s) the compiler did not walk into:\n`);
524
+ for (const { route, full } of missed) {
525
+ console.log(` ${(full || "/").padEnd(40)}${route._whyGeneric ?? "it was not eligible"}`);
526
+ }
527
+ }
528
+
529
+ const middleware = mounts.length - routers.length;
530
+ if (middleware > 0) {
531
+ console.log(
532
+ `\n${middleware} middleware in front of them. Every request walks the ones whose path it` +
533
+ ` matches,\nand a compiled route walks them from a list worked out at startup rather than by matching.`
534
+ );
535
+ }
536
+
537
+ printSummary(routes, native, declarative);
538
+
539
+ console.log(
540
+ "\nA route answered by µWS is matched in C++ and reaches javascript with its chain already\n" +
541
+ "known. One that fell back is matched here, in order, the way Express does it: correct\n" +
542
+ "either way, and the reason is printed so it can be changed if it is worth changing.\n" +
543
+ "The server was not started and its listen callback was not run."
544
+ );
545
+ }
546
+
262
547
  /**
263
548
  * @param {string[]} argv
264
549
  */
@@ -268,13 +553,18 @@ function main(argv) {
268
553
  printDifferences();
269
554
  return 0;
270
555
  }
556
+ if (command === "profile") {
557
+ return profile(argv.slice(1));
558
+ }
271
559
  if (command !== "migrate") {
272
560
  console.log(`Usage:
273
- npx ${TO} migrate [dir] rewrite require("${FROM}") and import from "${FROM}" to "${TO}"
274
- npx ${TO} differences print what behaves differently, without changing anything
561
+ npx ${TO} migrate [dir] rewrite require("${FROM}") and import from "${FROM}" to "${TO}"
562
+ npx ${TO} profile [entry] load an application without listening and print what compiling
563
+ its routes decided, route by route
564
+ npx ${TO} differences print what behaves differently, without changing anything
275
565
 
276
566
  Options:
277
- --dry-run say what would change and change nothing`);
567
+ --dry-run migrate: say what would change and change nothing`);
278
568
  return command ? 1 : 0;
279
569
  }
280
570
 
@@ -363,7 +653,14 @@ function printDifferences() {
363
653
  }
364
654
 
365
655
  if (require.main === module) {
366
- process.exitCode = main(process.argv.slice(2));
656
+ const code = main(process.argv.slice(2));
657
+ if (process.argv[2] === "profile") {
658
+ // profile loaded somebody's application, and loading it may have opened a database handle,
659
+ // a timer or a µWS app of its own. None of that is ours to unwind, and there is nothing
660
+ // left to print. Only here, so the command itself stays a function a test can call
661
+ process.exit(code);
662
+ }
663
+ process.exitCode = code;
367
664
  }
368
665
 
369
- module.exports = { main, findSpecifiers, collectFiles, DIFFERENCES };
666
+ module.exports = { main, findSpecifiers, collectFiles, findEntry, collectRoutes, profile, DIFFERENCES };
@@ -1,3 +1,22 @@
1
+ /*
2
+ Copyright 2024 dimden.dev
3
+ Copyright 2026 Nigro Simone
4
+
5
+ This file is derived from Ultimate Express and has been modified.
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
18
+ */
19
+
1
20
  const acorn = require("acorn");
2
21
  const { stringify, withDefaultCharset, withUtf8Charset } = require("./utils.js");
3
22
  // H3App, DeclarativeResponse and _cfg all exist at runtime but are missing from the
package/src/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  Copyright 2024 dimden.dev
3
3
  Copyright 2026 Nigro Simone
4
4
 
5
+ This file is derived from Ultimate Express and has been modified.
6
+
5
7
  Licensed under the Apache License, Version 2.0 (the "License");
6
8
  you may not use this file except in compliance with the License.
7
9
  You may obtain a copy of the License at
@@ -2,6 +2,8 @@
2
2
  Copyright 2024 dimden.dev
3
3
  Copyright 2026 Nigro Simone
4
4
 
5
+ This file is derived from Ultimate Express and has been modified.
6
+
5
7
  Licensed under the Apache License, Version 2.0 (the "License");
6
8
  you may not use this file except in compliance with the License.
7
9
  You may obtain a copy of the License at
@@ -24,7 +26,7 @@ const qs = require("qs");
24
26
  const parseQuery = require("./parse-query.js");
25
27
  const { kGetSafe } = require("./usage.js");
26
28
  const { AsyncResource } = require("async_hooks");
27
- const { fastQueryParse, NullObject, asStatError, httpError, memoizeByString } = require("./utils.js");
29
+ const { fastQueryParse, NullObject, asStatError, httpError, memoizeByString, containsDotFile } = require("./utils.js");
28
30
 
29
31
  // largest content-length we will allocate a body buffer for up front. above this the body is
30
32
  // collected chunk by chunk instead, so a declared-but-unsent body cannot pin more memory than a
@@ -251,7 +253,7 @@ function bodyError(message, status, type, extra) {
251
253
  * that climbs out of the root, applies the dotfiles and index rules, and hands the rest over.
252
254
  *
253
255
  * @param {string} root directory to serve from
254
- * @param {object} [options] index, redirect, fallthrough, dotfiles, extensions, setHeaders, etag
256
+ * @param {import("./options").StaticOptions} [options]
255
257
  * @returns {(req: any, res: any, next: (err?: any) => void) => any}
256
258
  */
257
259
  function serveStatic(root, options) {
@@ -309,9 +311,13 @@ function serveStatic(root, options) {
309
311
 
310
312
  const iq = req.url.indexOf("?");
311
313
  let url;
314
+ // the path as it was written, before decoding: whether it names a directory is decided on
315
+ // this and not on what the escapes turn into, which is how send decides it. "/a/%2F" asks
316
+ // for a file called "/" inside "a", and not for the index of a directory
317
+ const rawPath = iq !== -1 ? req.url.substring(0, iq) : req.url;
312
318
 
313
319
  try {
314
- url = decodeURIComponent(iq !== -1 ? req.url.substring(0, iq) : req.url);
320
+ url = decodeURIComponent(rawPath);
315
321
  } catch (e) {
316
322
  // 400 and not 404: send answers a path it cannot decode with a Bad Request, since
317
323
  // nothing was asked for that could be missing
@@ -320,28 +326,83 @@ function serveStatic(root, options) {
320
326
  return next(httpError(400));
321
327
  } else return next();
322
328
  }
329
+ // A decoded NUL is a bad request and not a missing file, which is how send reads it too.
330
+ // Without this the byte reaches fs, and what comes back is node's own complaint with the
331
+ // absolute path of the root inside it, so a request could ask the server where it lives.
332
+ if (url.indexOf("\0") !== -1) {
333
+ if (!options.fallthrough) {
334
+ res.status(400);
335
+ return next(httpError(400));
336
+ } else return next();
337
+ }
323
338
  let _path = url;
324
- const fullpath = path.resolve(path.join(options.root, url));
325
- if (options.root && !fullpath.startsWith(path.resolve(options.root))) {
339
+ const fullpath = path.resolve(path.join(root, url));
340
+ // What serve-static hands send is this path, except that a bare "/" under a mount the
341
+ // request did not write with one becomes "": without that rule a mount whose root is a file
342
+ // would ask the disk for a directory and could never answer at all.
343
+ //
344
+ // Send then stats `normalize(join(root, path))`, and both of those keep a trailing
345
+ // separator where `resolve` takes it off. The separator is not decoration: the disk refuses
346
+ // a file that is asked for as a directory, and the name inside the error carries it, which
347
+ // is what an error handler prints when fallthrough is off.
348
+ const mountRelative = rawPath === "/" && !req.endsWithSlash ? "" : url;
349
+ const statTarget = mountRelative.endsWith("/") && !fullpath.endsWith(path.sep) ? fullpath + path.sep : fullpath;
350
+ if (root && !fullpath.startsWith(path.resolve(root))) {
326
351
  if (!options.fallthrough) {
327
352
  res.status(403);
328
353
  return next(httpError(403));
329
354
  } else return next();
330
355
  }
331
356
 
357
+ // Before the stat, because send judges the path before it looks at the disk: a hidden
358
+ // segment anywhere in a path that does not exist answers what the dotfiles rule says and
359
+ // not the ENOENT the disk would have given. sendFile applies the same rule below, and
360
+ // reaches it only for paths that do exist.
361
+ // normalized first, as send normalizes before it judges: a ".." segment is not a hidden
362
+ // file, and resolving it away is what tells the two apart
363
+ if (containsDotFile(path.normalize(url).split(/[\\/]/))) {
364
+ const refusal = options.dotfiles === "deny" ? 403 : options.dotfiles === "allow" ? 0 : 404;
365
+ if (refusal !== 0 && !(options.dotfiles === "ignore_files" && !path.basename(url).startsWith("."))) {
366
+ if (!options.fallthrough) {
367
+ res.status(refusal);
368
+ return next(httpError(refusal));
369
+ }
370
+ return next();
371
+ }
372
+ }
373
+
332
374
  let stat;
333
375
  try {
334
- stat = fs.statSync(fullpath);
376
+ stat = fs.statSync(statTarget);
335
377
  } catch (err) {
378
+ // the one to report when nothing is found: send hands each failed attempt to the next
379
+ // one and reports whichever came last, so an extensions option that also missed names
380
+ // the file it looked for and not the bare path
381
+ let statError = err;
382
+ // a path written with a trailing slash asks for a directory, and send answers that by
383
+ // looking for the index inside it. With nothing there, the file it names is that
384
+ // index and not the directory that does not exist either
385
+ if (rawPath.endsWith("/") && options.index) {
386
+ try {
387
+ fs.statSync(path.join(fullpath, options.index));
388
+ } catch (indexError) {
389
+ statError = indexError;
390
+ }
391
+ }
336
392
  const ext = path.extname(fullpath);
337
393
  let i = 0;
338
- if (ext === "" && options.extensions) {
394
+ // a path that resolves to a directory gets no extension hung off it. The test is on the
395
+ // decoded url and not on fullpath, because resolve() has already taken the trailing
396
+ // separator off that one, and not on the raw path either: send tries the extension on
397
+ // what the escapes decoded to, while it looks for an index on the path as written
398
+ if (ext === "" && !url.endsWith("/") && options.extensions) {
339
399
  while (i < options.extensions.length) {
340
400
  try {
341
401
  stat = fs.statSync(fullpath + "." + options.extensions[i]);
342
402
  _path = url + "." + options.extensions[i];
343
403
  break;
344
- } catch (err) {
404
+ } catch (extensionError) {
405
+ statError = extensionError;
345
406
  i++;
346
407
  }
347
408
  }
@@ -353,7 +414,7 @@ function serveStatic(root, options) {
353
414
  // error handler with its errno, code, syscall and path still on it, and an
354
415
  // error handler doing res.send(err) sends those as JSON. Passing the string
355
416
  // sent an HTML page instead.
356
- return next(asStatError(err));
417
+ return next(asStatError(statError));
357
418
  } else return next();
358
419
  }
359
420
  }
@@ -472,13 +533,16 @@ function createInflate(contentEncoding) {
472
533
  * knows), or undefined for a parser that never decodes (raw)
473
534
  * @param {boolean} [keepsBuffer] whether the collected buffer itself escapes to the application,
474
535
  * which rules out handing it a view over uWS memory
475
- * @returns {(options?: object) => Function} the middleware factory
536
+ * @returns {(options?: import("./options").BodyParserOptions) => Function} the middleware factory
476
537
  */
477
538
  function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy, keepsBuffer) {
478
- return function (options) {
539
+ return function (userOptions) {
479
540
  // a copy, because everything below writes the parsed values back: with the caller's own
480
- // object, altering it after the parser was built would alter the parser
481
- options = options && typeof options === "object" ? { ...options } : new NullObject();
541
+ // object, altering it after the parser was built would alter the parser. The type says
542
+ // settled because the block below fills in every default, which is what the middleware
543
+ // and its closures then rely on
544
+ /** @type {import("./options").BodyParserOptions} */
545
+ const options = userOptions && typeof userOptions === "object" ? { ...userOptions } : new NullObject();
482
546
  // refused where it is written, not where it is used: an option nobody can honour is a
483
547
  // mistake in the application, and body-parser throws for it at the same point
484
548
  if (options.verify !== undefined && options.verify !== false && typeof options.verify !== "function") {
@@ -491,11 +555,17 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
491
555
  // and every comparison against it is false. express.json({ limit: 5 * 1024 * 1024 }) had no
492
556
  // limit at all. parse, and only what needs parsing
493
557
  if (typeof options.limit === "undefined") {
494
- options.limit = bytes.parse("100kb");
558
+ options.limit = /** @type {number} */ (bytes.parse("100kb"));
495
559
  } else if (typeof options.limit !== "number") {
496
- options.limit = bytes.parse(options.limit);
560
+ // bytes.parse answers null for a size it cannot read, and body-parser passes that
561
+ // along untouched too: matching it matters more than improving on it here
562
+ options.limit = /** @type {number} */ (bytes.parse(options.limit));
497
563
  }
498
564
 
565
+ // settled above, and read once: every check below wants the value, not the bag
566
+ const limit = /** @type {number} */ (options.limit);
567
+ const defaultCharset = /** @type {string} */ (options.defaultCharset ?? "utf-8");
568
+
499
569
  if (typeof options.inflate === "undefined") options.inflate = true;
500
570
  if (typeof options.type === "undefined") options.type = defaultType;
501
571
  if (typeof options.type === "string") {
@@ -523,7 +593,9 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
523
593
  //
524
594
  // typeis.is and not typeis(req, ...): the request form first checks that there is a body,
525
595
  // and the caller below has established that already.
526
- const claimsType = memoizeByString((contentType) => !!typeis.is(contentType, options.type));
596
+ const claimsType = memoizeByString(
597
+ (contentType) => !!typeis.is(contentType, /** @type {string[]} */ (options.type))
598
+ );
527
599
 
528
600
  let additionalMethods;
529
601
 
@@ -585,7 +657,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
585
657
  // answers 415 even for an empty body, and before the verify hook can run
586
658
  let encoding;
587
659
  if (charsetPolicy) {
588
- encoding = charsetOf(type) ?? options.defaultCharset;
660
+ encoding = charsetOf(type) ?? defaultCharset;
589
661
  if (
590
662
  (charsetPolicy === "utf" && encoding.slice(0, 4) !== "utf-") ||
591
663
  (charsetPolicy === "urlencoded" && encoding !== "utf-8" && encoding !== "iso-8859-1")
@@ -611,12 +683,12 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
611
683
  }
612
684
 
613
685
  // skip reading too large body
614
- if (length && +length > options.limit) {
686
+ if (length && +length > limit) {
615
687
  return next(
616
688
  bodyError("request entity too large", 413, "entity.too.large", {
617
689
  expected: +length,
618
690
  length: +length,
619
- limit: options.limit
691
+ limit: limit
620
692
  })
621
693
  );
622
694
  }
@@ -674,13 +746,13 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
674
746
  if (!req.receivedData && !inflate && !isNaN(length) && Number(length) > 0 && req._res.collectBody) {
675
747
  req.bodyRead = true;
676
748
  const declared = Number(length);
677
- req._res.collectBody(options.limit, (body) => {
749
+ req._res.collectBody(limit, (body) => {
678
750
  if (body === null) {
679
751
  // over maxSize: uWS refused it natively
680
752
  return next(
681
753
  bodyError("request entity too large", 413, "entity.too.large", {
682
- limit: options.limit,
683
- received: options.limit
754
+ limit: limit,
755
+ received: limit
684
756
  })
685
757
  );
686
758
  }
@@ -710,7 +782,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
710
782
  // known and we aren't inflating, the final size is known up front, so chunks can go
711
783
  // straight into one buffer and the body is copied once.
712
784
  // the cap means a client that declares a body and never sends it costs no more than one
713
- // that actually sends a body that size, and content-length above options.limit was
785
+ // that actually sends a body that size, and content-length above limit was
714
786
  // already rejected above
715
787
  const declaredLength = inflate ? -1 : Number(length);
716
788
  let target =
@@ -757,13 +829,13 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
757
829
  */
758
830
  function keepChunk(buf) {
759
831
  totalSize += buf.length;
760
- if (totalSize > options.limit) {
832
+ if (totalSize > limit) {
761
833
  finished = true;
762
834
  abs.length = 0;
763
835
  target = null;
764
836
  next(
765
837
  bodyError("request entity too large", 413, "entity.too.large", {
766
- limit: options.limit,
838
+ limit: limit,
767
839
  received: totalSize
768
840
  })
769
841
  );
package/src/node-shim.js CHANGED
@@ -100,8 +100,10 @@ class NodeHttpRequest {
100
100
  const url = req.url || "/";
101
101
  const question = url.indexOf("?");
102
102
  this._path = question === -1 ? url : url.slice(0, question);
103
- // uWS answers the query without its "?", and an empty string when there is none
104
- this._query = question === -1 ? "" : url.slice(question + 1);
103
+ // uWS answers the query without its "?", undefined when the url carries none and "" when
104
+ // it carries an empty one, and req.url keeps that difference
105
+ /** @type {string|undefined} */
106
+ this._query = question === -1 ? undefined : url.slice(question + 1);
105
107
  }
106
108
 
107
109
  /** The path, without the query, which is what uWS answers here. */
@@ -109,7 +111,7 @@ class NodeHttpRequest {
109
111
  return this._path;
110
112
  }
111
113
 
112
- /** The query string without its "?", empty when there is none, as uWS reports it. */
114
+ /** The query string without its "?", undefined when the url has none, as uWS reports it. */
113
115
  getQuery() {
114
116
  return this._query;
115
117
  }