fulmine.js 5.12.1 → 5.12.2
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 +1 -0
- package/package.json +1 -1
- package/src/router.js +76 -13
- package/src/utils.js +50 -16
package/README.md
CHANGED
|
@@ -285,6 +285,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
|
|
|
285
285
|
- `app.listen()` returns the app rather than a separate server object, and the app answers as an `http.Server`: `app instanceof http.Server` is true, which is what the graceful shutdown wrappers and the connection trackers look for. There is still no node server underneath, the socket belongs to µWS, so what is answered is the surface and not the plumbing. There: `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family. Not there: nothing emits `connection`, `request` or `upgrade`, `getConnections()` counts the requests in flight rather than sockets, and the timeouts belong to µWS and are set through `uwsOptions.idleTimeout`. Anything that wants to serve its own protocol on the socket, socket.io being the usual case, still wants `app.uwsApp`. Runnable: [`examples/graceful-shutdown.js`](./examples/graceful-shutdown.js).
|
|
286
286
|
- `x-powered-by` is disabled by default. Express sends `X-Powered-By: Express` unless you turn it off; Fulmine does not send it unless you turn it on with `app.set("x-powered-by", true)`. The header only tells anyone asking which framework is running.
|
|
287
287
|
- request body is only read for POST, PUT, PATCH and QUERY requests by default. You can add additional methods by setting `body methods` to array with uppercased methods.
|
|
288
|
+
- **A request whose `Content-Length` cannot be trusted is refused by hanging up, with no answer at all.** Node's parser refuses two of these with a `400` and Fulmine refuses the same two: a repeated `Content-Length`, whatever the values say, and one that is not a plain count of bytes, an empty value included. µWS accepts both and frames the request on the first value, or on no body at all, so what the client sent as a body is read as the next request on the connection: that is request smuggling, and a proxy in front reading the other value is all it takes. The answer differs from Express because it cannot be helped. µWS only skips the request it has already queued when the response is closed rather than completed, and writing the `400` completes it, so the choice is between telling the client and stopping the smuggled request. Nothing well behaved sends two content-lengths.
|
|
288
289
|
- **A compiled route answers `connection: keep-alive` to a client that sent `Connection: close`.** A handler simple enough to be read at registration time is answered by µWS from a response written once at `listen()`, and that response cannot read the request. The socket still closes, so what is wrong is the header and not the transport. A response that would carry a validator is never compiled, so conditional requests behave as on Express; `app.set("declarative responses", false)` turns compiling off.
|
|
289
290
|
- **Informational responses go nowhere.** `res.writeEarlyHints()`, `res.writeContinue()` and `res.writeProcessing()` are all there, take what node's take and throw what node's throw once the head has gone out, but nothing reaches the wire: µWebSockets.js has no API for a `1xx`. They exist so that code written for Express keeps running rather than dying on "is not a function", which is the only thing a drop-in can honestly promise here. `res.addTrailers()` is the same story, and `res.setTimeout()` and `req.setTimeout()` register the listener without changing anything, since µWS runs its own idle timeout through `uwsOptions.idleTimeout`.
|
|
290
291
|
- For HTTPS, instead of doing this:
|
package/package.json
CHANGED
package/src/router.js
CHANGED
|
@@ -241,7 +241,18 @@ class Walk {
|
|
|
241
241
|
// was one closure per hop of every request not on a compiled chain
|
|
242
242
|
for (; routeIndex < routes.length; routeIndex++) {
|
|
243
243
|
const r = routes[routeIndex];
|
|
244
|
-
|
|
244
|
+
// A HEAD request enters a route whose path matched even when its verb cannot serve
|
|
245
|
+
// one: express exempts HEAD from the method check ("if (!hasMethod && method !==
|
|
246
|
+
// 'HEAD')" in router/index.js), so the layer's parameters are captured and its
|
|
247
|
+
// param() callbacks run before the route is dropped. Only asked when the router has
|
|
248
|
+
// callbacks to run, since entering a route to step straight back out of it is
|
|
249
|
+
// otherwise pure cost with nothing to show for it. runRoute steps over it.
|
|
250
|
+
if (!(
|
|
251
|
+
r.all ||
|
|
252
|
+
r.method === req.method ||
|
|
253
|
+
req._isOptions ||
|
|
254
|
+
(req._isHead && (r.gettable || r.paramCallbacks.size > 0))
|
|
255
|
+
)) {
|
|
245
256
|
// taken only to fail: _preprocessRequest decodes again and turns it into the
|
|
246
257
|
// error, so the handlers of a route this request cannot run never see it
|
|
247
258
|
if (mayFailDecode && router._pathMatches(r, req) && router._paramsFailToDecode(r, req)) {
|
|
@@ -514,20 +525,20 @@ class Walk {
|
|
|
514
525
|
if (parentMethods !== null) {
|
|
515
526
|
req._matchedMethods = parentMethods;
|
|
516
527
|
}
|
|
517
|
-
if (req._isOptions && childMethods.size) {
|
|
528
|
+
if (req._isOptions && childMethods.size && !req._error) {
|
|
518
529
|
// OPTIONS routing is different, it stops in the router if matched.
|
|
519
530
|
// Express answers as the router hands back, so a throw while answering,
|
|
520
531
|
// a head already written being the way, walks on to later error handlers
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
return this.step(err);
|
|
527
|
-
}
|
|
532
|
+
try {
|
|
533
|
+
router._sendOptionsReply(req, res, childMethods);
|
|
534
|
+
return this.resolve(true);
|
|
535
|
+
} catch (err) {
|
|
536
|
+
return this.step(err);
|
|
528
537
|
}
|
|
529
|
-
return this.resolve(false);
|
|
530
538
|
}
|
|
539
|
+
// An error carried out of the mount is not answered by the automatic reply, and
|
|
540
|
+
// stopping here handed it to the default page: express walks on to the error
|
|
541
|
+
// handlers written after the mount, for OPTIONS as for any other method.
|
|
531
542
|
this.step(undefined);
|
|
532
543
|
})
|
|
533
544
|
// a rejection out of the nested walk, or a throw above, must reject this one
|
|
@@ -550,6 +561,11 @@ class Walk {
|
|
|
550
561
|
}
|
|
551
562
|
return this.step(undefined);
|
|
552
563
|
}
|
|
564
|
+
// entered only so its param callbacks could run, see the scan in dispatch: the verb
|
|
565
|
+
// cannot serve a HEAD, so nothing here answers it
|
|
566
|
+
if (req._isHead && !route.all && !route.gettable && route.method !== "HEAD") {
|
|
567
|
+
return this.step(undefined);
|
|
568
|
+
}
|
|
553
569
|
|
|
554
570
|
const out = callback(req, res, this.next);
|
|
555
571
|
if (out instanceof Promise) {
|
|
@@ -671,6 +687,42 @@ function setMountedPath(req) {
|
|
|
671
687
|
req._lastUrl = req.url;
|
|
672
688
|
}
|
|
673
689
|
|
|
690
|
+
const NO_PARAM_NAMES = [];
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The parameter names a route captures with its own pattern.
|
|
694
|
+
*
|
|
695
|
+
* This is the set express runs param callbacks for. A name that reached req.params from a mount
|
|
696
|
+
* above, through mergeParams, belongs to that mount's router and not to this one, and express does
|
|
697
|
+
* not call this router's param() for it: it walks the keys the layer itself matched. Reading
|
|
698
|
+
* req.params instead ran a callback for every inherited name too, which is visible whenever such a
|
|
699
|
+
* callback does anything, and turned a 200 into a 500 when one of them refused the value.
|
|
700
|
+
*
|
|
701
|
+
* Worked out once per route and kept, since it follows from the pattern and never changes.
|
|
702
|
+
*
|
|
703
|
+
* @param {any} route
|
|
704
|
+
* @returns {string[]}
|
|
705
|
+
*/
|
|
706
|
+
function ownParamNames(route) {
|
|
707
|
+
let names = route._ownParamNames;
|
|
708
|
+
if (names !== undefined) {
|
|
709
|
+
return names;
|
|
710
|
+
}
|
|
711
|
+
if (route.optimizedParams) {
|
|
712
|
+
// µWS matched the pattern and hands the values back by position, under these names
|
|
713
|
+
names = route.optimizedParams;
|
|
714
|
+
} else if (route.pattern instanceof RegExp) {
|
|
715
|
+
const meta = getPatternMeta(route.pattern);
|
|
716
|
+
// outputNames is what _extractParams writes into params; a RegExp the application wrote
|
|
717
|
+
// itself was never compiled here, so its capture groups are the names
|
|
718
|
+
names = meta ? meta.outputNames : regexpGroupKeys(route.pattern);
|
|
719
|
+
} else {
|
|
720
|
+
names = NO_PARAM_NAMES;
|
|
721
|
+
}
|
|
722
|
+
route._ownParamNames = names;
|
|
723
|
+
return names;
|
|
724
|
+
}
|
|
725
|
+
|
|
674
726
|
/**
|
|
675
727
|
* Whether this route reads the parameters of the mounts above it, which is its own router asking
|
|
676
728
|
* for them. The stack holds what a mergeParams router captured on the way in, and a plain router
|
|
@@ -2514,8 +2566,14 @@ module.exports = class Router extends EventEmitter {
|
|
|
2514
2566
|
|
|
2515
2567
|
// the route's own router's callbacks: an optimized chain is walked by the app even when it
|
|
2516
2568
|
// ends in a mounted router's route
|
|
2569
|
+
//
|
|
2570
|
+
// A route an OPTIONS request reaches only to have its verb counted for the automatic reply
|
|
2571
|
+
// is not a route this request runs, and express does not run its app.param() callbacks for
|
|
2572
|
+
// it. The same condition runRoute counts the verb under, see the OPTIONS branch there. The
|
|
2573
|
+
// decoding above still happens either way, because express decodes a layer whose path
|
|
2574
|
+
// matched whatever its method is, which is what answers 400 for a malformed escape.
|
|
2517
2575
|
const paramCallbacks = route.paramCallbacks;
|
|
2518
|
-
if (paramCallbacks.size > 0) {
|
|
2576
|
+
if (paramCallbacks.size > 0 && !(req._isOptions && !route.all && route.method !== "OPTIONS")) {
|
|
2519
2577
|
return this._runParamCallbacks(req, res, route, paramCallbacks);
|
|
2520
2578
|
}
|
|
2521
2579
|
return true;
|
|
@@ -2558,9 +2616,14 @@ module.exports = class Router extends EventEmitter {
|
|
|
2558
2616
|
* @returns {Promise<true|"route">|true}
|
|
2559
2617
|
*/
|
|
2560
2618
|
_runParamCallbacks(req, res, route, paramCallbacks) {
|
|
2619
|
+
// the names this route captured itself, not everything in req.params: a merged-in name
|
|
2620
|
+
// belongs to the mount that captured it, see ownParamNames
|
|
2561
2621
|
let names;
|
|
2562
|
-
|
|
2563
|
-
|
|
2622
|
+
const own = ownParamNames(route);
|
|
2623
|
+
for (let i = 0; i < own.length; i++) {
|
|
2624
|
+
const name = own[i];
|
|
2625
|
+
// an optional group that did not match leaves no parameter to call anything for
|
|
2626
|
+
if (paramCallbacks.has(name) && req.params[name] !== undefined) {
|
|
2564
2627
|
(names ??= []).push(name);
|
|
2565
2628
|
}
|
|
2566
2629
|
}
|
package/src/utils.js
CHANGED
|
@@ -596,13 +596,23 @@ function canBeOptimizedWithParams(pattern) {
|
|
|
596
596
|
return true;
|
|
597
597
|
}
|
|
598
598
|
|
|
599
|
+
// What makes a segment something other than the text it is written as: a parameter, a wildcard, an
|
|
600
|
+
// optional group, or an escape. Only two plain literals can prove that two paths never meet, so
|
|
601
|
+
// anything carrying one of these has to be read as "could be anything".
|
|
602
|
+
const NOT_A_LITERAL = /[:*{}\\]/;
|
|
603
|
+
|
|
599
604
|
/**
|
|
600
605
|
* Whether two paths could both match the same request.
|
|
601
606
|
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
607
|
+
* The answer is structural: no position where two different literals meet, and, when neither path
|
|
608
|
+
* can change length, the same number of segments. `/orders/:id` and `/invoices/:id` cannot both
|
|
609
|
+
* match, `/users/:id` and `/users/me` can. The caller reads "do not know" as yes, so every doubt
|
|
610
|
+
* answers true: saying two paths overlap only costs a native registration, while missing one lets
|
|
611
|
+
* µWS answer a request that belonged to an earlier route.
|
|
612
|
+
*
|
|
613
|
+
* A parameter is not the only shape that matches more than itself. A wildcard and an optional group
|
|
614
|
+
* do too, and reading `{:opt}` or `*splat` as the literal text it is written as reported "cannot
|
|
615
|
+
* overlap" for a route that plainly could, which took the earlier route's turn away.
|
|
606
616
|
*
|
|
607
617
|
* @param {string} a
|
|
608
618
|
* @param {string} b
|
|
@@ -612,15 +622,19 @@ function canBeOptimizedWithParams(pattern) {
|
|
|
612
622
|
function pathsCanOverlap(a, b, aIsPrefix = false) {
|
|
613
623
|
const left = a.split("/");
|
|
614
624
|
const right = b.split("/");
|
|
615
|
-
|
|
625
|
+
// An optional group matches its segment or nothing at all and a wildcard matches several, so a
|
|
626
|
+
// path carrying either one matches more than one length and the count settles nothing.
|
|
627
|
+
const fixedLength =
|
|
628
|
+
a.indexOf("{") === -1 && b.indexOf("{") === -1 && a.indexOf("*") === -1 && b.indexOf("*") === -1;
|
|
629
|
+
if (fixedLength && (aIsPrefix ? left.length > right.length : left.length !== right.length)) {
|
|
616
630
|
return false;
|
|
617
631
|
}
|
|
618
|
-
|
|
632
|
+
const shared = left.length < right.length ? left.length : right.length;
|
|
633
|
+
for (let i = 0; i < shared; i++) {
|
|
619
634
|
if (left[i] === right[i]) {
|
|
620
635
|
continue;
|
|
621
636
|
}
|
|
622
|
-
|
|
623
|
-
if (left[i].charCodeAt(0) === 0x3a || right[i].charCodeAt(0) === 0x3a) {
|
|
637
|
+
if (NOT_A_LITERAL.test(left[i]) || NOT_A_LITERAL.test(right[i])) {
|
|
624
638
|
continue;
|
|
625
639
|
}
|
|
626
640
|
return false;
|
|
@@ -1436,6 +1450,32 @@ function withUtf8Charset(value) {
|
|
|
1436
1450
|
const HEADER_TOKEN = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/;
|
|
1437
1451
|
const HEADER_VALUE = /[^\t\x20-\x7e\x80-\xff]/;
|
|
1438
1452
|
|
|
1453
|
+
/**
|
|
1454
|
+
* One of node's header errors, built the way node builds it.
|
|
1455
|
+
*
|
|
1456
|
+
* Assigning the code is not the whole of it. Node also puts the code in the first line of the
|
|
1457
|
+
* stack, by naming the error "TypeError [THE_CODE]" while V8 formats that line and then taking
|
|
1458
|
+
* the name back off. Whatever prints a stack therefore says which code it was, and the default
|
|
1459
|
+
* error page prints exactly that: without this, the same refusal reads "TypeError:" here and
|
|
1460
|
+
* "TypeError [ERR_INVALID_CHAR]:" behind Express. Found by fuzzing against express.
|
|
1461
|
+
*
|
|
1462
|
+
* @param {string} message
|
|
1463
|
+
* @param {string} code
|
|
1464
|
+
* @returns {NodeJS.ErrnoException}
|
|
1465
|
+
*/
|
|
1466
|
+
function headerError(message, code) {
|
|
1467
|
+
/** @type {NodeJS.ErrnoException} */
|
|
1468
|
+
const err = new TypeError(message);
|
|
1469
|
+
err.name = `TypeError [${code}]`;
|
|
1470
|
+
// reading it is what makes V8 format the line, and it formats it from the name above
|
|
1471
|
+
void err.stack;
|
|
1472
|
+
// back to the prototype's "TypeError", which is what node leaves behind. Cast because Error
|
|
1473
|
+
// declares name as always present, and this deletes the own property to uncover it again
|
|
1474
|
+
delete (/** @type {any} */ (err).name);
|
|
1475
|
+
err.code = code;
|
|
1476
|
+
return err;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1439
1479
|
/**
|
|
1440
1480
|
* Refuses a header name that is not an HTTP token, the way node's setHeader does and with its
|
|
1441
1481
|
* error, so an application catching ERR_INVALID_HTTP_TOKEN behind Express catches it here.
|
|
@@ -1446,10 +1486,7 @@ const HEADER_VALUE = /[^\t\x20-\x7e\x80-\xff]/;
|
|
|
1446
1486
|
*/
|
|
1447
1487
|
function validateHeaderName(name) {
|
|
1448
1488
|
if (typeof name !== "string" || !HEADER_TOKEN.test(name)) {
|
|
1449
|
-
|
|
1450
|
-
const err = new TypeError(`Header name must be a valid HTTP token ["${name}"]`);
|
|
1451
|
-
err.code = "ERR_INVALID_HTTP_TOKEN";
|
|
1452
|
-
throw err;
|
|
1489
|
+
throw headerError(`Header name must be a valid HTTP token ["${name}"]`, "ERR_INVALID_HTTP_TOKEN");
|
|
1453
1490
|
}
|
|
1454
1491
|
}
|
|
1455
1492
|
|
|
@@ -1471,10 +1508,7 @@ function validateHeaderValue(name, value) {
|
|
|
1471
1508
|
return;
|
|
1472
1509
|
}
|
|
1473
1510
|
if (HEADER_VALUE.test(value)) {
|
|
1474
|
-
|
|
1475
|
-
const err = new TypeError(`Invalid character in header content ["${name}"]`);
|
|
1476
|
-
err.code = "ERR_INVALID_CHAR";
|
|
1477
|
-
throw err;
|
|
1511
|
+
throw headerError(`Invalid character in header content ["${name}"]`, "ERR_INVALID_CHAR");
|
|
1478
1512
|
}
|
|
1479
1513
|
}
|
|
1480
1514
|
|