diff --git a/components/Scope.ts b/components/Scope.ts index 6ae3dc7c2..61e135116 100644 --- a/components/Scope.ts +++ b/components/Scope.ts @@ -68,7 +68,30 @@ export class Scope extends EventEmitter { this.databaseEvents = databaseEventsEmitter; this.applicationScope = applicationScope; this.resources = applicationScope?.resources ?? resources; - this.server = applicationScope?.server ?? server; + + const baseServer = applicationScope?.server ?? server; + const scopeRef = this; + // Wrap server so http/request/ws/upgrade calls automatically carry this plugin's name, + // urlPath, and host — enabling routing and before/after dependencies on named middleware. + this.server = new Proxy(baseServer, { + get(target, prop, receiver) { + if (prop === 'http' || prop === 'request' || prop === 'ws' || prop === 'upgrade') { + const method = Reflect.get(target, prop, receiver); + if (typeof method === 'function') { + return (listener: any, options?: any) => { + const scopeConfig = (scopeRef.options?.getAll() as any) ?? {}; + return method.call(target, listener, { + name: pluginName, + urlPath: scopeConfig.urlPath || undefined, + host: scopeConfig.host || undefined, + ...options, + }); + }; + } + } + return Reflect.get(target, prop, receiver); + }, + }) as Server; this.#entryHandlers = []; this.#pendingInitialLoads = new Set(); diff --git a/package-lock.json b/package-lock.json index 63d848f34..7ce2fb053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -114,7 +114,7 @@ "intercept-stdout": "0.1.2", "mkcert": "^3.2.0", "mocha": "^11.7.5", - "mqtt": "~4.3.8", + "mqtt": "^5.15.1", "oxlint": "^1.31.0", "prettier": "~3.8.0", "rewire": "^9.0.1", @@ -4567,6 +4567,16 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", @@ -5270,6 +5280,19 @@ "node": ">=8" } }, + "node_modules/broker-factory": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.14.tgz", + "integrity": "sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, "node_modules/browser-stdout": { "version": "1.3.1", "dev": true, @@ -5575,15 +5598,11 @@ } }, "node_modules/commist": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/commist/-/commist-1.1.0.tgz", - "integrity": "sha512-rraC8NXWOEjhADbZe9QBNzLAN5Q3fsTPQtBV+fEVj6xKIgDgNiEVE6ZNfHpZOqfQ21YUzfVNUXLOEZquYvQPPg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", "dev": true, - "license": "MIT", - "dependencies": { - "leven": "^2.1.0", - "minimist": "^1.1.0" - } + "license": "MIT" }, "node_modules/complex.js": { "version": "2.4.3", @@ -6463,6 +6482,20 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-unique-numbers": { + "version": "9.0.27", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", + "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/fast-uri": { "version": "3.1.0", "funding": [ @@ -6801,13 +6834,6 @@ "node": ">=14.14" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -7811,84 +7837,12 @@ } }, "node_modules/help-me": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/help-me/-/help-me-3.0.0.tgz", - "integrity": "sha512-hx73jClhyk910sidBB7ERlnhMlFsJJIBqSVMFDwPN8o2v9nmp5KgLq1Xz1Bf1fCMMZ6mPrX159iG0VLy/fPMtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^7.1.6", - "readable-stream": "^3.6.0" - } - }, - "node_modules/help-me/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "dev": true, "license": "MIT" }, - "node_modules/help-me/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/help-me/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/help-me/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/help-me/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/http-errors": { "version": "2.0.1", "license": "MIT", @@ -7977,18 +7931,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "license": "ISC" @@ -8074,6 +8016,16 @@ "lodash.toarray": "^3.0.0" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "license": "MIT", @@ -8423,16 +8375,6 @@ "version": "0.1.2", "license": "MIT" }, - "node_modules/leven": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", - "integrity": "sha512-nvVPLpIHUxCUoRLrFqTgSxXJ614d8AgQoWl7zPe/2VadE8+1dpU3LBhowRuBAcuwruWtOdD8oYC9jDNJjXDPyA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/levn": { "version": "0.4.1", "dev": true, @@ -8935,37 +8877,36 @@ } }, "node_modules/mqtt": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-4.3.8.tgz", - "integrity": "sha512-2xT75uYa0kiPEF/PE0VPdavmEkoBzMT/UL9moid0rAvlCtV48qBwxD62m7Ld/4j8tSkIO1E/iqRl/S72SEOhOw==", + "version": "5.15.1", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz", + "integrity": "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==", "dev": true, "license": "MIT", "dependencies": { - "commist": "^1.0.0", + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", "concat-stream": "^2.0.0", - "debug": "^4.1.1", - "duplexify": "^4.1.1", - "help-me": "^3.0.0", - "inherits": "^2.0.3", - "lru-cache": "^6.0.0", - "minimist": "^1.2.5", - "mqtt-packet": "^6.8.0", - "number-allocator": "^1.0.9", - "pump": "^3.0.0", - "readable-stream": "^3.6.0", - "reinterval": "^1.1.0", - "rfdc": "^1.3.0", - "split2": "^3.1.0", - "ws": "^7.5.5", - "xtend": "^4.0.2" + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "number-allocator": "^1.0.14", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" }, "bin": { - "mqtt": "bin/mqtt.js", - "mqtt_pub": "bin/pub.js", - "mqtt_sub": "bin/sub.js" + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" }, "engines": { - "node": ">=10.0.0" + "node": ">=16.0.0" } }, "node_modules/mqtt-packet": { @@ -8977,88 +8918,56 @@ "process-nextick-args": "^2.0.1" } }, - "node_modules/mqtt/node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/mqtt/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mqtt/node_modules/mqtt-packet": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-6.10.0.tgz", - "integrity": "sha512-ja8+mFKIHdB1Tpl6vac+sktqy3gA8t9Mduom1BA75cI+R9AHnZOiaBQwpGiWnaVJLDGRdNhQmFaAqd7tkKSMGA==", + "node_modules/mqtt/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", "dependencies": { - "bl": "^4.0.2", - "debug": "^4.1.1", - "process-nextick-args": "^2.0.1" + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, "node_modules/mqtt/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "dev": true, "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/mqtt/node_modules/split2": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", - "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", - "dev": true, - "license": "ISC", - "dependencies": { - "readable-stream": "^3.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/mqtt/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "node_modules/mqtt/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "dependencies": { + "safe-buffer": "~5.2.0" } }, "node_modules/ms": { @@ -9798,16 +9707,6 @@ "node": ">=14.0.0" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "license": "MIT", @@ -10272,13 +10171,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/reinterval": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", - "integrity": "sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==", - "dev": true, - "license": "MIT" - }, "node_modules/repeating": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", @@ -10726,6 +10618,32 @@ "node": ">=0.3.1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/sonic-boom": { "version": "3.8.1", "license": "MIT", @@ -11393,6 +11311,57 @@ "node": ">=0.10.0" } }, + "node_modules/worker-factory": { + "version": "7.0.49", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.49.tgz", + "integrity": "sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.31", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.31.tgz", + "integrity": "sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.16", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.16.tgz", + "integrity": "sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "broker-factory": "^3.1.14", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.14", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.14.tgz", + "integrity": "sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, "node_modules/workerpool": { "version": "9.3.4", "dev": true, @@ -11464,13 +11433,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/yaml": { "version": "2.8.3", "license": "ISC", diff --git a/server/REST.ts b/server/REST.ts index 4e67f1211..761d69273 100644 --- a/server/REST.ts +++ b/server/REST.ts @@ -290,98 +290,107 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope) resources = scope.resources; if (started) return; started = true; - scope.server.http(async (request: Request, nextHandler) => { - if (request.isWebSocket) return; - return http(request, nextHandler); - }, httpOptions); + scope.server.http( + async (request: Request, nextHandler) => { + if (request.isWebSocket) return; + return http(request, nextHandler); + }, + { after: 'authentication', ...httpOptions } + ); if ((httpOptions as any).webSocket === false) return; - scope.server.ws(async (ws, request, chainCompletion) => { - connectionCount++; - const incomingMessages = new IterableEventQueue(); - if (!addedMetrics) { - addedMetrics = true; - addAnalyticsListener((metrics) => { - if (connectionCount > 0) - metrics.push({ - metric: 'ws-connections', - connections: connectionCount, - byThread: true, - }); - }); - } - // TODO: We should set a lower keep-alive ws.socket.setKeepAlive(600000); - let hasError; - ws.on('error', (error) => { - hasError = true; - harperLogger.warn(error); - }); - let deserializer; - ws.on('message', function message(body) { - if (!deserializer) - deserializer = getDeserializer(request.requestedContentType ?? request.headers.asObject['content-type'], false); - const data = deserializer(body); - recordAction(body.length, 'bytes-received', request.handlerPath, 'message', 'ws'); - incomingMessages.push(data); - }); - let iterator; - ws.on('close', () => { - connectionCount--; - recordActionBinary(!hasError, 'connection', 'ws', 'disconnect'); - incomingMessages.emit('close'); - if (iterator) iterator.return(); - }); - try { - await chainCompletion; - const url = request.url.slice(1); - const entry = resources.getMatch(url, 'ws'); - recordActionBinary(Boolean(entry), 'connection', 'ws', 'connect'); - if (!entry) { - // TODO: Ideally we would like to have a 404 response before upgrading to WebSocket protocol, probably - return ws.close(1011, `No resource was found to handle ${request.pathname}`); - } else { - request.handlerPath = entry.path; - recordAction( - (action) => ({ - count: action.count, - total: connectionCount, - }), - 'connections', - request.handlerPath, - 'connect', - 'ws' - ); - request.authorize = true; - const resourceRequest = new RequestTarget(entry.relativeURL); // TODO: We don't want to have to remove the forward slash and then re-add it - resourceRequest.checkPermission = request.user?.role?.permission ?? {}; - const resource = entry.Resource; - const responseStream = await transaction(request, () => { - return resource.connect(resourceRequest, incomingMessages, request); + scope.server.ws( + async (ws, request, chainCompletion) => { + connectionCount++; + const incomingMessages = new IterableEventQueue(); + if (!addedMetrics) { + addedMetrics = true; + addAnalyticsListener((metrics) => { + if (connectionCount > 0) + metrics.push({ + metric: 'ws-connections', + connections: connectionCount, + byThread: true, + }); }); - iterator = responseStream[Symbol.asyncIterator](); + } + // TODO: We should set a lower keep-alive ws.socket.setKeepAlive(600000); + let hasError; + ws.on('error', (error) => { + hasError = true; + harperLogger.warn(error); + }); + let deserializer; + ws.on('message', function message(body) { + if (!deserializer) + deserializer = getDeserializer( + request.requestedContentType ?? request.headers.asObject['content-type'], + false + ); + const data = deserializer(body); + recordAction(body.length, 'bytes-received', request.handlerPath, 'message', 'ws'); + incomingMessages.push(data); + }); + let iterator; + ws.on('close', () => { + connectionCount--; + recordActionBinary(!hasError, 'connection', 'ws', 'disconnect'); + incomingMessages.emit('close'); + if (iterator) iterator.return(); + }); + try { + await chainCompletion; + const url = request.url.slice(1); + const entry = resources.getMatch(url, 'ws'); + recordActionBinary(Boolean(entry), 'connection', 'ws', 'connect'); + if (!entry) { + // TODO: Ideally we would like to have a 404 response before upgrading to WebSocket protocol, probably + return ws.close(1011, `No resource was found to handle ${request.pathname}`); + } else { + request.handlerPath = entry.path; + recordAction( + (action) => ({ + count: action.count, + total: connectionCount, + }), + 'connections', + request.handlerPath, + 'connect', + 'ws' + ); + request.authorize = true; + const resourceRequest = new RequestTarget(entry.relativeURL); // TODO: We don't want to have to remove the forward slash and then re-add it + resourceRequest.checkPermission = request.user?.role?.permission ?? {}; + const resource = entry.Resource; + const responseStream = await transaction(request, () => { + return resource.connect(resourceRequest, incomingMessages, request); + }); + iterator = responseStream[Symbol.asyncIterator](); - let result; - while (!(result = await iterator.next()).done) { - const messageBinary = await serializeMessage(result.value, request); - ws.send(messageBinary); - recordAction(messageBinary.length, 'bytes-sent', request.handlerPath, 'message', 'ws'); - if (ws._socket.writableNeedDrain) { - await new Promise((resolve) => ws._socket.once('drain', resolve)); + let result; + while (!(result = await iterator.next()).done) { + const messageBinary = await serializeMessage(result.value, request); + ws.send(messageBinary); + recordAction(messageBinary.length, 'bytes-sent', request.handlerPath, 'message', 'ws'); + if (ws._socket.writableNeedDrain) { + await new Promise((resolve) => ws._socket.once('drain', resolve)); + } } } + } catch (error) { + if (error.statusCode) { + if (error.statusCode === 500) harperLogger.warn(error); + else harperLogger.info(error); + } else harperLogger.error(error); + ws.close( + HTTP_TO_WEBSOCKET_CLOSE_CODES[error.statusCode] || // try to return a helpful code + 1011, // otherwise generic internal error + errorToString(error) + ); } - } catch (error) { - if (error.statusCode) { - if (error.statusCode === 500) harperLogger.warn(error); - else harperLogger.info(error); - } else harperLogger.error(error); - ws.close( - HTTP_TO_WEBSOCKET_CLOSE_CODES[error.statusCode] || // try to return a helpful code - 1011, // otherwise generic internal error - errorToString(error) - ); - } - ws.close(); - }, httpOptions); + ws.close(); + }, + { after: 'authentication', ...httpOptions } + ); } const HTTP_TO_WEBSOCKET_CLOSE_CODES = { 401: 3000, diff --git a/server/Server.ts b/server/Server.ts index ff3b236de..9a5553241 100644 --- a/server/Server.ts +++ b/server/Server.ts @@ -56,19 +56,25 @@ export interface ServerOptions { securePort?: number; mtls?: boolean; usageType?: string; + /** @deprecated Use `before` or `after` for explicit ordering instead */ + runFirst?: boolean; + /** Name for this middleware entry, used by `before`/`after` in other entries. Defaults to the registering component's name. */ + name?: string; + /** This middleware must run before the named middleware */ + before?: string; + /** This middleware must run after the named middleware */ + after?: string; + /** Only handle requests whose pathname starts with this prefix */ + urlPath?: string; + /** Only handle requests for this virtual hostname */ + host?: string; } interface WebSocketOptions extends ServerOptions { subProtocol: string; } -export interface UpgradeOptions { - port?: number; - securePort?: number; - runFirst?: boolean; -} +export interface UpgradeOptions extends ServerOptions {} -export interface HttpOptions extends ServerOptions { - runFirst?: boolean; -} +export interface HttpOptions extends ServerOptions {} export interface ContentTypeHandler { serialize(data: any): Buffer | string; serializeStream(data: any): Buffer | string; diff --git a/server/graphqlQuerying.ts b/server/graphqlQuerying.ts index 0c70ac085..7fef30ec2 100644 --- a/server/graphqlQuerying.ts +++ b/server/graphqlQuerying.ts @@ -696,6 +696,6 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope) throw error; } }, - { port, securePort } + { port, securePort, after: 'authentication' } ); } diff --git a/server/http.ts b/server/http.ts index 0e08291d4..f489936a8 100644 --- a/server/http.ts +++ b/server/http.ts @@ -26,6 +26,7 @@ import { server, type ServerOptions, type HttpOptions, type UpgradeOptions, Upgr import { setPortServerMap, SERVERS } from './serverRegistry.ts'; import { getComponentName } from '../components/componentLoader.ts'; import { throttle } from './throttle.ts'; +import { makeCallbackChain as buildCallbackChain } from './middlewareChain.ts'; import { WebSocketServer } from 'ws'; const { errorToString } = harperLogger; @@ -36,7 +37,15 @@ server.upgrade = onUpgrade; const websocketServers = {}; const httpServers = {}, httpChain = {}, - httpResponders = []; + httpResponders: { + listener: Function; + port: number | string; + name?: string; + before?: string; + after?: string; + urlPath?: string; + host?: string; + }[] = []; let httpOptions: HttpOptions = {}; export const universalHeaders: [string, string][] = []; const udsCleanupPaths: { socketPath: string; yamlPath: string }[] = []; @@ -268,7 +277,16 @@ export function httpServer(listener, options) { for (const { port, secure } of getPorts(options)) { servers.push(getHTTPServer(port, secure, options)); if (typeof listener === 'function') { - httpResponders[options?.runFirst ? 'unshift' : 'push']({ listener, port: options?.port || port }); + const entry = { + listener, + port: options?.port || port, + name: options?.name ?? getComponentName(), + before: options?.before, + after: options?.after, + urlPath: options?.urlPath || undefined, + host: options?.host || undefined, + }; + httpResponders[options?.runFirst ? 'unshift' : 'push'](entry); } else { listener.isSecure = secure; registerServer(listener, port, false); @@ -561,21 +579,18 @@ function getHTTPServer(port: number, secure: boolean, options: ServerOptions) { return httpServers[port]; } -function makeCallbackChain(responders, portNum) { - let nextCallback = unhandled; - // go through the listeners in reverse order so each callback can be passed to the one before - // and then each middleware layer can call the next middleware layer - for (let i = responders.length; i > 0; ) { - const { listener, port } = responders[--i]; - if (port === portNum || port === 'all') { - const callback = nextCallback; - nextCallback = (...args) => { - // for listener only layers, the response through - return listener(...args, callback); - }; - } - } - return nextCallback; +function makeCallbackChain(responders: typeof httpResponders, portNum: number | string, requestArgIndex: number = 0) { + return buildCallbackChain( + responders, + portNum, + unhandled, + () => { + harperLogger.warn( + `Cycle detected in middleware before/after ordering on port ${portNum}; falling back to registration order.` + ); + }, + requestArgIndex + ); } function unhandled(request) { if (request.user) { @@ -609,7 +624,16 @@ const upgradeListeners = [], function onUpgrade(listener: UpgradeListener, options: UpgradeOptions) { for (const { port } of getPorts(options)) { - upgradeListeners[options?.runFirst ? 'unshift' : 'push']({ listener, port }); + const entry = { + listener, + port: options?.port || port, + name: options?.name ?? getComponentName(), + before: options?.before, + after: options?.after, + urlPath: options?.urlPath || undefined, + host: options?.host || undefined, + }; + upgradeListeners[options?.runFirst ? 'unshift' : 'push'](entry); upgradeChains[port] = makeCallbackChain(upgradeListeners, port); } } @@ -620,6 +644,12 @@ type OnWebSocketOptions = { maxPayload?: number; usageType?: string; mtls?: boolean; + runFirst?: boolean; + name?: string; + before?: string; + after?: string; + urlPath?: string; + host?: string; }; const websocketListeners = [], websocketChains = {}; @@ -688,8 +718,17 @@ function onWebSocket(listener: (ws: WebSocket) => void, options: OnWebSocketOpti servers.push(server); - websocketListeners[options?.runFirst ? 'unshift' : 'push']({ listener, port }); - websocketChains[port] = makeCallbackChain(websocketListeners, port); + const wsEntry = { + listener, + port: options?.port || port, + name: options?.name ?? getComponentName(), + before: options?.before, + after: options?.after, + urlPath: options?.urlPath || undefined, + host: options?.host || undefined, + }; + websocketListeners[options?.runFirst ? 'unshift' : 'push'](wsEntry); + websocketChains[port] = makeCallbackChain(websocketListeners, port, 1); // mqtt doesn't invoke the http handler so this needs to be here to load up the http chains. httpChain[port] = makeCallbackChain(httpResponders, port); diff --git a/server/middlewareChain.ts b/server/middlewareChain.ts new file mode 100644 index 000000000..7d709ba09 --- /dev/null +++ b/server/middlewareChain.ts @@ -0,0 +1,270 @@ +export type HttpEntry = { + listener: Function; + port: number | string; + name?: string; + before?: string; + after?: string; + urlPath?: string; + host?: string; +}; + +/** + * Topological sort of middleware entries respecting `before`/`after` constraints. + * Uses the original registration index as a tiebreaker so config order is preserved + * when there are no constraints between two entries. + * + * `before: 'X'` → this entry must run before the FIRST entry named X. + * `after: 'X'` → this entry must run after the LAST entry named X. + * + * @param onCycle - called when a cycle is detected; entries are returned unsorted. + */ +export function topoSort(entries: HttpEntry[], onCycle?: () => void): HttpEntry[] { + const n = entries.length; + if (n <= 1) return entries; + + // Map name → first and last index (for before/after semantics) + const nameToFirst = new Map(); + const nameToLast = new Map(); + for (let i = 0; i < n; i++) { + const name = entries[i].name; + if (name) { + if (!nameToFirst.has(name)) nameToFirst.set(name, i); + nameToLast.set(name, i); + } + } + + // successors[i] = list of indices that must come after i + const successors: number[][] = Array.from({ length: n }, () => []); + const inDegree = new Int32Array(n); + const addEdge = (from: number, to: number) => { + successors[from].push(to); + inDegree[to]++; + }; + + for (let i = 0; i < n; i++) { + const { before, after } = entries[i]; + if (before) { + const j = nameToFirst.get(before); + if (j !== undefined && j !== i) addEdge(i, j); + } + if (after) { + const j = nameToLast.get(after); + if (j !== undefined && j !== i) addEdge(j, i); + } + } + + // Kahn's algorithm; use original index as tiebreaker to preserve registration/config order + const ready: number[] = []; + for (let i = 0; i < n; i++) { + if (inDegree[i] === 0) ready.push(i); + } + + const sorted: HttpEntry[] = []; + while (ready.length > 0) { + const i = ready.shift()!; + sorted.push(entries[i]); + for (const j of successors[i]) { + if (--inDegree[j] === 0) { + // Binary-insert to keep ready sorted by original index + let lo = 0, + hi = ready.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (ready[mid] < j) lo = mid + 1; + else hi = mid; + } + ready.splice(lo, 0, j); + } + } + } + + if (sorted.length !== n) { + onCycle?.(); + return entries; + } + return sorted; +} + +/** + * Builds a linear middleware chain from a sorted array of entries. + * The first entry in `sorted` is the outermost (called first). + * `fallback` is invoked when all entries call next() without handling the request. + */ +export function buildLinearChain(sorted: HttpEntry[], fallback: Function): Function { + let next = fallback; + for (let i = sorted.length; i > 0; ) { + const { listener } = sorted[--i]; + const callback = next; + next = (...args: any[]) => listener(...args, callback); + } + return next; +} + +/** + * Resolves transitive `after` dependencies for a set of entries. + * If entry A says `after: 'auth'` and auth is in `nameToEntry` but not in `entries`, + * auth is pulled into the result so that the ordering constraint can be satisfied. + * `before` constraints do NOT pull in entries — they only affect ordering. + */ +export function resolveDeps(entries: HttpEntry[], nameToEntry: Map): HttpEntry[] { + const included = new Set(entries); + let changed = true; + while (changed) { + changed = false; + for (const entry of [...included]) { + if (entry.after) { + const dep = nameToEntry.get(entry.after); + if (dep && !included.has(dep)) { + included.add(dep); + changed = true; + } + } + } + } + return [...included]; +} + +/** + * Normalizes a urlPath by stripping a single trailing slash (except for the root '/'). + * '/api' and '/api/' are treated equivalently for routing/matching. + */ +export function normalizeUrlPath(urlPath: string | undefined): string | undefined { + if (!urlPath || urlPath.length <= 1) return urlPath; + return urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath; +} + +/** + * Returns true when `request` satisfies the route's host and urlPath constraints. + * urlPath matching is prefix-based and segment-boundary-aware: + * '/api' matches '/api' and '/api/foo' but NOT '/api2'. + * Trailing slashes on `route.urlPath` are ignored. + */ +export function matchesRoute(request: any, route: { host?: string; urlPath?: string }): boolean { + if (route.host) { + const hostHeader: string = request.headers?.asObject?.host ?? ''; + const requestHost = hostHeader.split(':')[0]; + if (requestHost !== route.host) return false; + } + const urlPath = normalizeUrlPath(route.urlPath); + if (urlPath) { + const pathname: string = request.pathname ?? '/'; + if (pathname !== urlPath && !pathname.startsWith(urlPath + '/')) return false; + } + return true; +} + +/** + * Returns a proxy of `request` with `pathname` and `url` rewritten so that + * `prefix` is stripped from the path. e.g. prefix='/foo', pathname='/foo/bar' → '/bar'. + * Trailing slashes on `prefix` are ignored. The strip is computed lazily on each + * access so that downstream mutations to `request.pathname` remain reflected. + * The original request object is not mutated. + */ +export function stripPrefix(request: any, prefix: string): any { + const normalizedPrefix = normalizeUrlPath(prefix) ?? ''; + return new Proxy(request, { + get(target, prop) { + if (prop === 'pathname') { + const origPathname: string = target.pathname ?? '/'; + return origPathname === normalizedPrefix ? '/' : origPathname.slice(normalizedPrefix.length); + } + if (prop === 'url') { + const origPathname: string = target.pathname ?? '/'; + const origUrl: string = target.url ?? ''; + const stripped = origPathname === normalizedPrefix ? '/' : origPathname.slice(normalizedPrefix.length); + return stripped + origUrl.slice(origPathname.length); + } + return Reflect.get(target, prop); + }, + }); +} + +/** + * Builds a dispatching chain when sub-routes (urlPath/host) are present. + * + * Each sub-route gets its own complete chain. If a sub-route entry declares + * `after: 'X'`, entry X is pulled in from any route's registry so that the + * constraint can be satisfied without requiring X to be explicitly registered + * in the sub-route. This is how auth on the default route propagates into + * sub-route chains that depend on it. + * + * Dispatch priority: host+path > host-only > path-only; longer paths win ties. + * + * `requestArgIndex` tells the dispatcher which positional argument carries the request + * object used for host/path matching. HTTP and upgrade chains pass it at index 0; + * WebSocket chains pass `(ws, request, chainCompletion)` so request is at index 1. + * The matched (and prefix-stripped) request is substituted back into the same + * position before forwarding to the inner chain. + */ +export function buildRoutedChain( + portEntries: HttpEntry[], + fallback: Function, + onCycle?: () => void, + requestArgIndex: number = 0 +): Function { + // Global name registry across all routes (first registration wins) + const nameToEntry = new Map(); + for (const entry of portEntries) { + if (entry.name && !nameToEntry.has(entry.name)) nameToEntry.set(entry.name, entry); + } + + // Group entries by (host, normalized urlPath) so that '/api' and '/api/' coalesce. + type RouteGroup = { host?: string; urlPath?: string; entries: HttpEntry[] }; + const routeGroups: RouteGroup[] = []; + for (const entry of portEntries) { + const urlPath = normalizeUrlPath(entry.urlPath); + const group = routeGroups.find((g) => g.host === entry.host && g.urlPath === urlPath); + if (group) group.entries.push(entry); + else routeGroups.push({ host: entry.host, urlPath, entries: [entry] }); + } + + const defaultGroup = routeGroups.find((g) => !g.host && !g.urlPath); + const subRouteGroups = routeGroups.filter((g) => g.host || g.urlPath); + + const subRouteChains = subRouteGroups.map((group) => { + const resolved = resolveDeps(group.entries, nameToEntry); + return { host: group.host, urlPath: group.urlPath, chain: buildLinearChain(topoSort(resolved, onCycle), fallback) }; + }); + + subRouteChains.sort((a, b) => { + const aSpec = (a.host ? 2 : 0) + (a.urlPath ? 1 : 0); + const bSpec = (b.host ? 2 : 0) + (b.urlPath ? 1 : 0); + if (aSpec !== bSpec) return bSpec - aSpec; + return (b.urlPath?.length ?? 0) - (a.urlPath?.length ?? 0); + }); + + const defaultChain = buildLinearChain(topoSort(defaultGroup?.entries ?? [], onCycle), fallback); + + return function dispatch(...args: any[]) { + const request = args[requestArgIndex]; + for (const route of subRouteChains) { + if (matchesRoute(request, route)) { + if (route.urlPath) { + const newArgs = args.slice(); + newArgs[requestArgIndex] = stripPrefix(request, route.urlPath); + return route.chain(...newArgs); + } + return route.chain(...args); + } + } + return defaultChain(...args); + }; +} + +/** + * Builds the complete middleware chain for a given port from the full responders list. + * Uses a flat linear chain when no sub-routes are present (fast path), + * or a route-dispatching chain when any entry has urlPath or host. + */ +export function makeCallbackChain( + responders: HttpEntry[], + portNum: number | string, + fallback: Function, + onCycle?: () => void, + requestArgIndex: number = 0 +): Function { + const portEntries = responders.filter(({ port }) => port === portNum || port === 'all'); + if (portEntries.some((e) => e.urlPath || e.host)) + return buildRoutedChain(portEntries, fallback, onCycle, requestArgIndex); + return buildLinearChain(topoSort(portEntries, onCycle), fallback); +} diff --git a/server/mqtt.ts b/server/mqtt.ts index b7775e8fa..f756596a2 100644 --- a/server/mqtt.ts +++ b/server/mqtt.ts @@ -75,7 +75,7 @@ export function handleApplication(scope: import('../components/Scope.ts').Scope) mqttLog.info?.('WebSocket error', error); }); }, - { ...webSocket } + { ...webSocket, after: 'authentication' } ); // if there is no port, we are piggy-backing off of default app http server // standard TCP socket if (port || securePort) { diff --git a/server/static.ts b/server/static.ts index c032cbb7b..4ee0f2ec0 100644 --- a/server/static.ts +++ b/server/static.ts @@ -162,7 +162,7 @@ export function handleApplication(scope: Scope) { body: send(req, realpathSync(notFoundPath)), }; }, - { runFirst: true } + { before: (scope.options.get(['before']) as string) ?? 'authentication' } ); } diff --git a/unitTests/server/middlewareChain.test.js b/unitTests/server/middlewareChain.test.js new file mode 100644 index 000000000..4e04433a7 --- /dev/null +++ b/unitTests/server/middlewareChain.test.js @@ -0,0 +1,791 @@ +'use strict'; +const assert = require('assert'); +const { + topoSort, + buildLinearChain, + resolveDeps, + matchesRoute, + normalizeUrlPath, + stripPrefix, + makeCallbackChain, +} = require('#src/server/middlewareChain'); + +// Helpers ------------------------------------------------------------------ + +/** Minimal fallback used as the terminal `next` in all chain tests. */ +const UNHANDLED = () => ({ status: -1 }); + +/** Build a minimal HttpEntry. port defaults to 9000. */ +function entry(name, opts = {}) { + return { listener: opts.listener ?? ((_req, next) => next(_req)), port: opts.port ?? 9000, name, ...opts }; +} + +/** Build a simple request object. */ +function req(pathname = '/', host = undefined, url = undefined) { + return { pathname, url: url ?? pathname, headers: { asObject: host ? { host } : {} } }; +} + +// -------------------------------------------------------------------------- +// topoSort +// -------------------------------------------------------------------------- + +describe('topoSort', () => { + it('returns empty array unchanged', () => { + assert.deepStrictEqual(topoSort([]), []); + }); + + it('returns single element unchanged', () => { + const e = entry('a'); + assert.deepStrictEqual(topoSort([e]), [e]); + }); + + it('preserves registration order when no constraints', () => { + const [a, b, c] = ['a', 'b', 'c'].map((n) => entry(n)); + const sorted = topoSort([a, b, c]); + assert.deepStrictEqual( + sorted.map((e) => e.name), + ['a', 'b', 'c'] + ); + }); + + it('enforces `before` constraint', () => { + const a = entry('a'); + const b = entry('b', { before: 'a' }); // b must come before a + // registered order: a, b → sort should give b, a + const sorted = topoSort([a, b]); + assert.deepStrictEqual( + sorted.map((e) => e.name), + ['b', 'a'] + ); + }); + + it('enforces `after` constraint', () => { + const a = entry('a', { after: 'b' }); // a must come after b + const b = entry('b'); + // registered order: a, b → sort should give b, a + const sorted = topoSort([a, b]); + assert.deepStrictEqual( + sorted.map((e) => e.name), + ['b', 'a'] + ); + }); + + it('preserves config order as tiebreaker: auth before rest when both unconstrained', () => { + const auth = entry('authentication'); + const rest = entry('rest', { after: 'authentication' }); + const staticE = entry('static'); + // config: static, authentication, rest + const sorted = topoSort([staticE, auth, rest]); + assert.deepStrictEqual( + sorted.map((e) => e.name), + ['static', 'authentication', 'rest'] + ); + }); + + it('config: rest, authentication, static → auth pulled before rest', () => { + const rest = entry('rest', { after: 'authentication' }); + const auth = entry('authentication'); + const staticE = entry('static'); + // registered: rest(0), authentication(1), static(2) + // constraint: auth before rest → expected: authentication, rest, static + const sorted = topoSort([rest, auth, staticE]); + const names = sorted.map((e) => e.name); + const authIdx = names.indexOf('authentication'); + const restIdx = names.indexOf('rest'); + assert.ok(authIdx < restIdx, `authentication (${authIdx}) should come before rest (${restIdx})`); + }); + + it('`before` applies to the FIRST registered entry with that name', () => { + const a1 = entry('a'); + const a2 = entry('a'); + const b = entry('b', { before: 'a' }); // constrains against a1 only (first 'a') + const sorted = topoSort([a1, a2, b]); + // b must come before a1 (the constrained entry); a2 has no constraint with b + assert.ok(sorted.indexOf(b) < sorted.indexOf(a1), 'b should come before first registered a'); + }); + + it('`after` applies to the LAST registered entry with that name', () => { + const a1 = entry('a'); + const a2 = entry('a'); + const b = entry('b', { after: 'a' }); // constrains against a2 only (last 'a') + const sorted = topoSort([a1, a2, b]); + assert.ok(sorted.indexOf(a2) < sorted.indexOf(b), 'b should come after last registered a'); + assert.ok(sorted.indexOf(a1) < sorted.indexOf(b), 'b should also come after a1 (a1 precedes a2)'); + }); + + it('reference to unknown name is a no-op', () => { + const a = entry('a', { after: 'nonexistent' }); + const b = entry('b'); + const sorted = topoSort([a, b]); + assert.deepStrictEqual( + sorted.map((e) => e.name), + ['a', 'b'] + ); + }); + + it('calls onCycle and returns original order when cycle detected', () => { + let cycleCalled = false; + const a = entry('a', { after: 'b' }); + const b = entry('b', { after: 'a' }); + const original = [a, b]; + const result = topoSort(original, () => { + cycleCalled = true; + }); + assert.strictEqual(cycleCalled, true, 'onCycle should be called'); + assert.strictEqual(result, original, 'should return original array on cycle'); + }); +}); + +// -------------------------------------------------------------------------- +// buildLinearChain +// -------------------------------------------------------------------------- + +describe('buildLinearChain', () => { + it('returns fallback when entry list is empty', () => { + const chain = buildLinearChain([], UNHANDLED); + assert.deepStrictEqual(chain(req()), { status: -1 }); + }); + + it('calls the single listener with (request, next)', () => { + let calledWith; + const e = entry('a', { + listener: (r, next) => { + calledWith = r; + return next(r); + }, + }); + const chain = buildLinearChain([e], UNHANDLED); + const r = req(); + chain(r); + assert.strictEqual(calledWith, r); + }); + + it('calls listeners in sorted order and threads next correctly', () => { + const order = []; + const entries = ['a', 'b', 'c'].map((n) => + entry(n, { + listener: (r, next) => { + order.push(n); + return next(r); + }, + }) + ); + const chain = buildLinearChain(entries, UNHANDLED); + chain(req()); + assert.deepStrictEqual(order, ['a', 'b', 'c']); + }); + + it('short-circuits when a listener returns without calling next', () => { + const order = []; + const a = entry('a', { + listener: (_r, _next) => { + order.push('a'); + return { status: 200 }; + }, + }); + const b = entry('b', { + listener: (r, next) => { + order.push('b'); + return next(r); + }, + }); + const chain = buildLinearChain([a, b], UNHANDLED); + const result = chain(req()); + assert.deepStrictEqual(order, ['a']); + assert.deepStrictEqual(result, { status: 200 }); + }); +}); + +// -------------------------------------------------------------------------- +// resolveDeps +// -------------------------------------------------------------------------- + +describe('resolveDeps', () => { + it('returns same entries when no after deps', () => { + const entries = ['a', 'b'].map((n) => entry(n)); + const registry = new Map(entries.map((e) => [e.name, e])); + const result = resolveDeps(entries, registry); + assert.deepStrictEqual(new Set(result), new Set(entries)); + }); + + it('pulls in a dep that is in the registry but not the entry list', () => { + const auth = entry('authentication'); + const rest = entry('rest', { after: 'authentication' }); + const registry = new Map([ + ['authentication', auth], + ['rest', rest], + ]); + // Only rest is in the initial list; auth should be pulled in + const result = resolveDeps([rest], registry); + assert.ok(result.includes(auth), 'auth should be pulled in'); + assert.ok(result.includes(rest), 'rest should remain'); + }); + + it('resolves transitive deps: A after B, B after C', () => { + const c = entry('c'); + const b = entry('b', { after: 'c' }); + const a = entry('a', { after: 'b' }); + const registry = new Map([ + ['a', a], + ['b', b], + ['c', c], + ]); + const result = resolveDeps([a], registry); + assert.ok(result.includes(b), 'b should be pulled in'); + assert.ok(result.includes(c), 'c should be pulled in transitively'); + }); + + it('does NOT pull in entries referenced only by `before`', () => { + const auth = entry('authentication'); + const staticE = entry('static', { before: 'authentication' }); + const registry = new Map([ + ['authentication', auth], + ['static', staticE], + ]); + // static declares before:auth but auth is not in the list + const result = resolveDeps([staticE], registry); + assert.ok(!result.includes(auth), 'auth should NOT be pulled in via before'); + }); + + it('ignores unknown dep names', () => { + const a = entry('a', { after: 'nonexistent' }); + const registry = new Map([['a', a]]); + const result = resolveDeps([a], registry); + assert.deepStrictEqual(result, [a]); + }); +}); + +// -------------------------------------------------------------------------- +// matchesRoute +// -------------------------------------------------------------------------- + +describe('matchesRoute', () => { + it('matches everything when no constraints', () => { + assert.strictEqual(matchesRoute(req('/foo'), {}), true); + }); + + it('matches exact urlPath', () => { + assert.strictEqual(matchesRoute(req('/api'), { urlPath: '/api' }), true); + }); + + it('matches urlPath with sub-path', () => { + assert.strictEqual(matchesRoute(req('/api/products'), { urlPath: '/api' }), true); + }); + + it('does NOT match a path that is merely a string-prefix (segment boundary required)', () => { + assert.strictEqual(matchesRoute(req('/api2'), { urlPath: '/api' }), false); + }); + + it('does NOT match a completely different path', () => { + assert.strictEqual(matchesRoute(req('/other'), { urlPath: '/api' }), false); + }); + + it('matches virtual host (ignoring port in Host header)', () => { + assert.strictEqual(matchesRoute(req('/', 'example.com:8080'), { host: 'example.com' }), true); + }); + + it('does NOT match wrong host', () => { + assert.strictEqual(matchesRoute(req('/', 'other.com'), { host: 'example.com' }), false); + }); + + it('requires both host and urlPath to match', () => { + const route = { host: 'example.com', urlPath: '/api' }; + assert.strictEqual(matchesRoute(req('/api', 'example.com'), route), true); + assert.strictEqual(matchesRoute(req('/api', 'other.com'), route), false); + assert.strictEqual(matchesRoute(req('/other', 'example.com'), route), false); + }); +}); + +// -------------------------------------------------------------------------- +// makeCallbackChain — integration +// -------------------------------------------------------------------------- + +describe('makeCallbackChain', () => { + it('flat chain: no sub-routes, calls middleware in registration order', () => { + const order = []; + const responders = ['a', 'b', 'c'].map((n) => ({ + name: n, + port: 9000, + listener: (r, next) => { + order.push(n); + return next(r); + }, + })); + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + chain(req()); + assert.deepStrictEqual(order, ['a', 'b', 'c']); + }); + + it('flat chain: before/after constraints override registration order', () => { + const order = []; + const responders = [ + { + name: 'rest', + port: 9000, + after: 'authentication', + listener: (r, next) => { + order.push('rest'); + return next(r); + }, + }, + { + name: 'authentication', + port: 9000, + listener: (r, next) => { + order.push('authentication'); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + chain(req()); + assert.deepStrictEqual(order, ['authentication', 'rest']); + }); + + it('filters by port: only includes matching port entries', () => { + const order = []; + const responders = [ + { + name: 'a', + port: 9000, + listener: (r, next) => { + order.push('a'); + return next(r); + }, + }, + { + name: 'b', + port: 8080, + listener: (r, next) => { + order.push('b'); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + chain(req()); + assert.deepStrictEqual(order, ['a']); + }); + + it('port "all" entries appear in every port chain', () => { + const order = []; + const responders = [ + { + name: 'cors', + port: 'all', + listener: (r, next) => { + order.push('cors'); + return next(r); + }, + }, + { + name: 'a', + port: 9000, + listener: (r, next) => { + order.push('a'); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + chain(req()); + assert.ok(order.includes('cors'), 'cors (port:all) should be included'); + assert.ok(order.includes('a')); + }); + + it('routes to sub-chain by urlPath', () => { + const order = []; + const responders = [ + { + name: 'api-handler', + port: 9000, + urlPath: '/api', + listener: (r, next) => { + order.push('api'); + return next(r); + }, + }, + { + name: 'default-handler', + port: 9000, + listener: (r, next) => { + order.push('default'); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + + order.length = 0; + chain(req('/api/products')); + assert.deepStrictEqual(order, ['api']); + + order.length = 0; + chain(req('/other')); + assert.deepStrictEqual(order, ['default']); + }); + + it('routes to sub-chain by host', () => { + const order = []; + const responders = [ + { + name: 'vhost-handler', + port: 9000, + host: 'example.com', + listener: (r, next) => { + order.push('vhost'); + return next(r); + }, + }, + { + name: 'default-handler', + port: 9000, + listener: (r, next) => { + order.push('default'); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + + order.length = 0; + chain(req('/', 'example.com')); + assert.deepStrictEqual(order, ['vhost']); + + order.length = 0; + chain(req('/', 'other.com')); + assert.deepStrictEqual(order, ['default']); + }); + + it('sub-route auto-pulls auth via `after` dependency', () => { + const order = []; + const responders = [ + // auth on default route + { + name: 'authentication', + port: 9000, + listener: (r, next) => { + order.push('authentication'); + return next(r); + }, + }, + // rest on /api, declares it needs to run after auth + { + name: 'rest', + port: 9000, + urlPath: '/api', + after: 'authentication', + listener: (r, next) => { + order.push('rest'); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + + chain(req('/api/products')); + assert.ok(order.includes('authentication'), 'auth should run for /api requests'); + assert.ok(order.indexOf('authentication') < order.indexOf('rest'), 'auth should run before rest'); + }); + + it('sub-route with `after` dep: dep runs once, not twice', () => { + let authCount = 0; + const responders = [ + { + name: 'authentication', + port: 9000, + listener: (r, next) => { + authCount++; + return next(r); + }, + }, + { name: 'rest', port: 9000, urlPath: '/api', after: 'authentication', listener: (r, next) => next(r) }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + chain(req('/api/products')); + assert.strictEqual(authCount, 1, 'auth should run exactly once per request'); + }); + + it('specificity: host+path wins over path-only for same urlPath prefix', () => { + const order = []; + const responders = [ + { + name: 'path-only', + port: 9000, + urlPath: '/api', + listener: (r, next) => { + order.push('path-only'); + return next(r); + }, + }, + { + name: 'host-path', + port: 9000, + host: 'example.com', + urlPath: '/api', + listener: (r, next) => { + order.push('host-path'); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + chain(req('/api', 'example.com')); + assert.deepStrictEqual(order, ['host-path']); + }); + + it('longer urlPath wins over shorter prefix', () => { + const order = []; + const responders = [ + { + name: 'short', + port: 9000, + urlPath: '/api', + listener: (r, next) => { + order.push('short'); + return next(r); + }, + }, + { + name: 'long', + port: 9000, + urlPath: '/api/v2', + listener: (r, next) => { + order.push('long'); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + chain(req('/api/v2/products')); + assert.deepStrictEqual(order, ['long']); + }); +}); + +// --------------------------------------------------------------------------- +// stripPrefix +// --------------------------------------------------------------------------- + +describe('stripPrefix', () => { + it('strips the prefix from pathname', () => { + const r = stripPrefix(req('/api/products'), '/api'); + assert.strictEqual(r.pathname, '/products'); + }); + + it('strips the prefix from url', () => { + const r = stripPrefix(req('/api/products', undefined, '/api/products?q=1'), '/api'); + assert.strictEqual(r.url, '/products?q=1'); + }); + + it('returns "/" when pathname equals prefix exactly', () => { + const r = stripPrefix(req('/api'), '/api'); + assert.strictEqual(r.pathname, '/'); + }); + + it('does not mutate the original request', () => { + const original = req('/api/products'); + stripPrefix(original, '/api'); + assert.strictEqual(original.pathname, '/api/products'); + }); + + it('sub-route chain receives stripped pathname', () => { + const seen = []; + const responders = [ + { + name: 'api-handler', + port: 9000, + urlPath: '/api', + listener: (r, next) => { + seen.push(r.pathname); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + chain(req('/api/products')); + assert.deepStrictEqual(seen, ['/products']); + }); + + it('sub-route chain receives "/" for exact prefix match', () => { + const seen = []; + const responders = [ + { + name: 'api-handler', + port: 9000, + urlPath: '/api', + listener: (r, next) => { + seen.push(r.pathname); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + chain(req('/api')); + assert.deepStrictEqual(seen, ['/']); + }); + + it('default chain receives unmodified pathname', () => { + const seen = []; + const responders = [ + { name: 'api-handler', port: 9000, urlPath: '/api', listener: (r, next) => next(r) }, + { + name: 'default-handler', + port: 9000, + listener: (r, next) => { + seen.push(r.pathname); + return next(r); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED); + chain(req('/other/path')); + assert.deepStrictEqual(seen, ['/other/path']); + }); + + it('treats trailing slash on prefix as equivalent (no malformed paths)', () => { + const r = stripPrefix(req('/api/foo'), '/api/'); + assert.strictEqual(r.pathname, '/foo'); + const r2 = stripPrefix(req('/api/foo', undefined, '/api/foo?x=1'), '/api/'); + assert.strictEqual(r2.url, '/foo?x=1'); + }); + + it('reflects downstream pathname mutations (lazy evaluation)', () => { + const original = req('/api/products'); + const proxied = stripPrefix(original, '/api'); + assert.strictEqual(proxied.pathname, '/products'); + original.pathname = '/api/things'; + assert.strictEqual(proxied.pathname, '/things'); + }); +}); + +// --------------------------------------------------------------------------- +// normalizeUrlPath +// --------------------------------------------------------------------------- + +describe('normalizeUrlPath', () => { + it('returns undefined for undefined/empty', () => { + assert.strictEqual(normalizeUrlPath(undefined), undefined); + assert.strictEqual(normalizeUrlPath(''), ''); + }); + + it('preserves root "/"', () => { + assert.strictEqual(normalizeUrlPath('/'), '/'); + }); + + it('strips a single trailing slash', () => { + assert.strictEqual(normalizeUrlPath('/api/'), '/api'); + }); + + it('leaves paths without trailing slash unchanged', () => { + assert.strictEqual(normalizeUrlPath('/api/v2'), '/api/v2'); + }); +}); + +// --------------------------------------------------------------------------- +// matchesRoute trailing-slash tolerance +// --------------------------------------------------------------------------- + +describe('matchesRoute with trailing slash', () => { + it('matches sub-paths when route.urlPath ends with "/"', () => { + assert.strictEqual(matchesRoute(req('/api/foo'), { urlPath: '/api/' }), true); + assert.strictEqual(matchesRoute(req('/api'), { urlPath: '/api/' }), true); + assert.strictEqual(matchesRoute(req('/api2'), { urlPath: '/api/' }), false); + }); +}); + +// --------------------------------------------------------------------------- +// variadic dispatch (request at non-zero arg index, e.g. WebSocket chains) +// --------------------------------------------------------------------------- + +describe('variadic dispatch with requestArgIndex', () => { + it('forwards all positional args to the inner chain (default chain)', () => { + const seen = []; + const responders = [ + entry('ws-handler', { + listener: (ws, req, completion, next) => { + seen.push({ ws, pathname: req.pathname, completion }); + return next(ws, req, completion); + }, + }), + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED, undefined, 1); + const fakeWs = { sym: 'ws' }; + const fakeCompletion = Promise.resolve(); + chain(fakeWs, req('/anything'), fakeCompletion); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].ws, fakeWs); + assert.strictEqual(seen[0].pathname, '/anything'); + assert.strictEqual(seen[0].completion, fakeCompletion); + }); + + it('routes by urlPath using arg at requestArgIndex, preserves other args (sub-route)', () => { + const seen = []; + const responders = [ + { + name: 'api-ws', + port: 9000, + urlPath: '/api', + listener: (ws, req, completion, next) => { + seen.push({ ws, pathname: req.pathname, completion }); + return next(ws, req, completion); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED, undefined, 1); + const fakeWs = { sym: 'ws' }; + const fakeCompletion = Promise.resolve(); + chain(fakeWs, req('/api/products'), fakeCompletion); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].ws, fakeWs, 'ws should be forwarded at arg 0'); + assert.strictEqual(seen[0].pathname, '/products', 'request at arg 1 should be prefix-stripped'); + assert.strictEqual(seen[0].completion, fakeCompletion, 'completion should be forwarded at arg 2'); + }); + + it('upgrade-style signature: forwards (request, socket, head) when routing', () => { + const seen = []; + const responders = [ + { + name: 'upgrade-handler', + port: 9000, + urlPath: '/api', + listener: (req, socket, head, next) => { + seen.push({ pathname: req.pathname, socket, head }); + return next(req, socket, head); + }, + }, + ]; + const chain = makeCallbackChain(responders, 9000, UNHANDLED, undefined, 0); + const fakeSocket = { sym: 'socket' }; + const fakeHead = Buffer.from([]); + chain(req('/api/upgrade'), fakeSocket, fakeHead); + assert.strictEqual(seen.length, 1); + assert.strictEqual(seen[0].pathname, '/upgrade'); + assert.strictEqual(seen[0].socket, fakeSocket); + assert.strictEqual(seen[0].head, fakeHead); + }); +}); + +// --------------------------------------------------------------------------- +// onCycle callback +// --------------------------------------------------------------------------- + +describe('onCycle callback', () => { + it('invokes onCycle and falls back to registration order when cycles exist', () => { + const a = entry('a', { before: 'b' }); + const b = entry('b', { before: 'a' }); + let called = 0; + const sorted = topoSort([a, b], () => called++); + assert.strictEqual(called, 1); + assert.deepStrictEqual(sorted, [a, b]); + }); + + it('is wired through makeCallbackChain', () => { + const responders = [ + entry('a', { before: 'b', listener: (r, next) => next(r) }), + entry('b', { before: 'a', listener: (r, next) => next(r) }), + ]; + let called = 0; + makeCallbackChain(responders, 9000, UNHANDLED, () => called++); + assert.strictEqual(called, 1); + }); +});