From 64959c02232c4450d71d7faf60b83b58debb27a9 Mon Sep 17 00:00:00 2001 From: Jean Burellier Date: Sat, 13 Jun 2026 09:32:46 +0200 Subject: [PATCH 1/4] feat(vhost)!: rewrite in TypeScript, ESM-only, Node 24+, faster matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite index.js as src/index.ts compiled with tsc to ESM in dist/, with bundled type declarations. Matching logic is byte-for-byte faithful to v3. Performance, while staying behaviour-identical: - Static (non-wildcard) hostnames take an ASCII-gated fast path: a length check + lowercase equality decide most requests without the regex, and req.vhost is written directly with length 0. A toLowerCase shortcut was rejected because RegExp i-folding differs from String#toLowerCase for code points like U+212A (Kelvin) and 'İ' length-changing folds; the fast path is gated on an ASCII pattern and defers to the regex otherwise. - Wildcard string hostnames gain an always-safe minimum-length reject before the regex (a match needs at least hostname.length chars). The regex still does the matching and capture extraction. - RegExp hostnames are unchanged. Warm: static match ~1.37x, static no-match ~1.9x, wildcard short no-match ~2.2x vs v3; wildcard match / multi-star / RegExp stay ~1.0x. Tooling: node:test + c8 + typescript-eslint flat config; the legacy multi-version CI matrix is replaced with a Node 24 build-and-test job. BREAKING CHANGE: package is now ESM-only (import, not require) and requires Node.js 24+. --- .eslintignore | 2 - .eslintrc.yml | 9 -- .github/workflows/ci.yml | 244 ++-------------------------------- .gitignore | 1 + eslint.config.js | 27 ++++ index.js | 164 ----------------------- package.json | 46 ++++--- src/index.ts | 273 +++++++++++++++++++++++++++++++++++++++ tsconfig.json | 19 +++ 9 files changed, 363 insertions(+), 422 deletions(-) delete mode 100644 .eslintignore delete mode 100644 .eslintrc.yml create mode 100644 eslint.config.js delete mode 100644 index.js create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 62562b7..0000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -coverage -node_modules diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index cf3015f..0000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,9 +0,0 @@ -root: true -extends: - - standard - - plugin:markdown/recommended -plugins: - - markdown -overrides: - - files: '**/*.md' - processor: 'markdown/markdown' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6b8057..8f28359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,257 +33,41 @@ jobs: - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: "lts/*" + node-version: "24" - name: Install dependencies - run: npm install --ignore-scripts --include=dev + run: npm install --include=dev - name: Run lint run: node --run lint # Use `node --run` to run the script in package.json test: - name: Test + name: Test (Node.js ${{ matrix.node-version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - name: - - Node.js 0.8 - - Node.js 0.10 - - Node.js 0.12 - - io.js 1.x - - io.js 2.x - - io.js 3.x - - Node.js 4.x - - Node.js 5.x - - Node.js 6.x - - Node.js 7.x - - Node.js 8.x - - Node.js 9.x - - Node.js 10.x - - Node.js 11.x - - Node.js 12.x - - Node.js 13.x - - Node.js 14.x - - Node.js 15.x - - Node.js 16.x - - Node.js 17.x - - Node.js 18.x - - Node.js 19.x - - Node.js 20.x - - Node.js 21.x - - Node.js 22.x - - Node.js 23.x - - Node.js 24.x - - include: - - name: Node.js 0.8 - node-version: "0.8" - npm-i: mocha@2.5.3 supertest@1.1.0 - npm-rm: nyc - - - name: Node.js 0.10 - node-version: "0.10" - npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - - - name: Node.js 0.12 - node-version: "0.12" - npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - - - name: io.js 1.x - node-version: "1.8" - npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - - - name: io.js 2.x - node-version: "2.5" - npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - - - name: io.js 3.x - node-version: "3.3" - npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 - - - name: Node.js 4.x - node-version: "4" - npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 - - - name: Node.js 5.x - node-version: "5" - npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 - - - name: Node.js 6.x - node-version: "6" - npm-i: mocha@6.2.3 nyc@14.1.1 supertest@6.1.6 - - - name: Node.js 7.x - node-version: "7" - npm-i: mocha@6.2.3 nyc@14.1.1 supertest@6.1.6 - - - name: Node.js 8.x - node-version: "8" - npm-i: mocha@7.2.0 nyc@14.1.1 supertest@6.1.6 - - - name: Node.js 9.x - node-version: "9" - npm-i: mocha@7.2.0 nyc@14.1.1 supertest@6.1.6 - - - name: Node.js 10.x - node-version: "10" - npm-i: mocha@8.4.0 supertest@6.1.6 - - - name: Node.js 11.x - node-version: "11" - npm-i: mocha@8.4.0 supertest@6.1.6 - - - name: Node.js 12.x - node-version: "12" - npm-i: "supertest@6.1.6" - - - name: Node.js 13.x - node-version: "13" - npm-i: "supertest@6.1.6" - - - name: Node.js 14.x - node-version: "14" - - - name: Node.js 15.x - node-version: "15" - npm-i: "supertest@6.1.6" - - - name: Node.js 16.x - node-version: "16" - - - name: Node.js 17.x - node-version: "17" - - - name: Node.js 18.x - node-version: "18" - - - name: Node.js 19.x - node-version: "19" - - - name: Node.js 20.x - node-version: "20" - - - name: Node.js 21.x - node-version: "21" - - - name: Node.js 22.x - node-version: "22" - - - name: Node.js 23.x - node-version: "23" - - - name: Node.js 24.x - node-version: "24" + node-version: + - "24" steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - - name: Install Node.js ${{ matrix.node-version }} - shell: bash -eo pipefail -l {0} - run: | - nvm install --default ${{ matrix.node-version }} - if [[ "${{ matrix.node-version }}" == 0.* && "$(cut -d. -f2 <<< "${{ matrix.node-version }}")" -lt 10 ]]; then - nvm install --alias=npm 0.10 - nvm use ${{ matrix.node-version }} - sed -i '1s;^.*$;'"$(printf '#!%q' "$(nvm which npm)")"';' "$(readlink -f "$(which npm)")" - npm config set strict-ssl false - fi - dirname "$(nvm which ${{ matrix.node-version }})" >> "$GITHUB_PATH" - - - name: Configure npm - run: | - if [[ "$(npm config get package-lock)" == "true" ]]; then - npm config set package-lock false - else - npm config set shrinkwrap false - fi - - - name: Remove npm module(s) ${{ matrix.npm-rm }} - run: npm rm --silent --save-dev ${{ matrix.npm-rm }} - if: matrix.npm-rm != '' - - - name: Install npm module(s) ${{ matrix.npm-i }} - run: npm install --save-dev ${{ matrix.npm-i }} - if: matrix.npm-i != '' - - - name: Setup Node.js version-specific dependencies - shell: bash - run: | - # eslint for linting - # - remove on Node.js < 10 - if [[ "$(cut -d. -f1 <<< "${{ matrix.node-version }}")" -lt 10 ]]; then - node -pe 'Object.keys(require("./package").devDependencies).join("\n")' | \ - grep -E '^eslint(-|$)' | \ - sort -r | \ - xargs -n1 npm rm --silent --save-dev - fi - - - name: Install Node.js dependencies - run: npm install - - - name: List environment - id: list_env - shell: bash - run: | - echo "node@$(node -v)" - echo "npm@$(npm -v)" - npm -s ls ||: - (npm -s ls --depth=0 ||:) | awk -F'[ @]' 'NR>1 && $2 { print "::set-output name=" $2 "::" $3 }' - - - name: Run tests - shell: bash - run: | - if npm -ps ls nyc | grep -q nyc; then - npm run test-ci - cp coverage/lcov.info "coverage/${{ matrix.node-version }}.lcov" - else - npm test - fi - - - name: Collect code coverage - if: steps.list_env.outputs.nyc != '' - run: | - if [[ -d ./coverage ]]; then - mv ./coverage "./${{ matrix.node-version }}" - mkdir ./coverage - mv "./${{ matrix.node-version }}" "./coverage/${{ matrix.node-version }}" - fi - - - name: Upload code coverage - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - if: steps.list_env.outputs.nyc != '' - with: - name: coverage-${{ matrix.node-version }} # to avoid conflicts - path: "./coverage/${{ matrix.node-version }}" - retention-days: 1 - - coverage: - name: Coverage - permissions: - checks: write # for coverallsapp/github-action to create new checks - contents: read # for actions/checkout to fetch code - needs: test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - persist-credentials: false + node-version: ${{ matrix.node-version }} - - name: Install lcov - shell: bash - run: sudo apt-get -y install lcov + - name: Install dependencies + run: npm install --include=dev - - name: Collect coverage reports - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - path: ./coverage + - name: Build + run: node --run build - - name: Merge coverage reports - shell: bash - run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./coverage/lcov.info + - name: Run tests with coverage + run: node --run test-ci - name: Upload coverage report uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6 diff --git a/.gitignore b/.gitignore index f15b98e..faac44a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .nyc_output/ coverage/ +dist/ node_modules/ npm-debug.log package-lock.json diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f39fb18 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,27 @@ +import js from '@eslint/js' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { + ignores: ['dist/', 'coverage/', 'node_modules/'] + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['src/**/*.ts'], + languageOptions: { + parserOptions: { + projectService: true + } + } + }, + { + files: ['bench/**/*.{mjs,cjs}', 'test/**/*.mjs'], + languageOptions: { + globals: { + ...globals.node + } + } + } +) diff --git a/index.js b/index.js deleted file mode 100644 index 45b59b8..0000000 --- a/index.js +++ /dev/null @@ -1,164 +0,0 @@ -/*! - * vhost - * Copyright(c) 2014 Jonathan Ong - * Copyright(c) 2014-2015 Douglas Christopher Wilson - * MIT Licensed - */ - -'use strict' - -/** - * Module exports. - * @public - */ - -module.exports = vhost - -/** - * Module variables. - * @private - */ - -var ASTERISK_REGEXP = /\*/g -var ASTERISK_REPLACE = '([^.]+)' -var END_ANCHORED_REGEXP = /(?:^|[^\\])(?:\\\\)*\$$/ -var ESCAPE_REGEXP = /([.+?^=!:${}()|[\]/\\])/g -var ESCAPE_REPLACE = '\\$1' - -/** - * Create a vhost middleware. - * - * @param {string|RegExp} hostname - * @param {function} handle - * @return {Function} - * @public - */ - -function vhost (hostname, handle) { - if (!hostname) { - throw new TypeError('argument hostname is required') - } - - if (!handle) { - throw new TypeError('argument handle is required') - } - - if (typeof handle !== 'function') { - throw new TypeError('argument handle must be a function') - } - - // create regular expression for hostname - var regexp = hostregexp(hostname) - - return function vhost (req, res, next) { - var vhostdata = vhostof(req, regexp) - - if (!vhostdata) { - return next() - } - - // populate - req.vhost = vhostdata - - // handle - handle(req, res, next) - } -} - -/** - * Get hostname of request. - * - * @param {object} req - * @return {string} - * @private - */ - -function hostnameof (req) { - var host = req.headers.host - - if (!host) { - return - } - - var offset = host[0] === '[' - ? host.indexOf(']') + 1 - : 0 - var index = host.indexOf(':', offset) - - return index !== -1 - ? host.substring(0, index) - : host -} - -/** - * Determine if object is RegExp. - * - * @param (object} val - * @return {boolean} - * @private - */ - -function isregexp (val) { - return Object.prototype.toString.call(val) === '[object RegExp]' -} - -/** - * Generate RegExp for given hostname value. - * - * @param (string|RegExp} val - * @private - */ - -function hostregexp (val) { - var source = !isregexp(val) - ? String(val).replace(ESCAPE_REGEXP, ESCAPE_REPLACE).replace(ASTERISK_REGEXP, ASTERISK_REPLACE) - : val.source - - // force leading anchor matching - if (source[0] !== '^') { - source = '^' + source - } - - // force trailing anchor matching - if (!END_ANCHORED_REGEXP.test(source)) { - source += '$' - } - - return new RegExp(source, 'i') -} - -/** - * Get the vhost data of the request for RegExp - * - * @param (object} req - * @param (RegExp} regexp - * @return {object} - * @private - */ - -function vhostof (req, regexp) { - var host = req.headers.host - var hostname = hostnameof(req) - - if (!hostname) { - return - } - - var match = regexp.exec(hostname) - - if (!match) { - return - } - - var obj = Object.create(null) - - obj.host = host - obj.hostname = hostname - obj.length = match.length - 1 - - for (var i = 1; i < match.length; i++) { - obj[i - 1] = match[i] - } - - return obj -} diff --git a/package.json b/package.json index 5f60e5f..8f0539d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vhost", "description": "virtual domain hosting", - "version": "3.0.2", + "version": "4.0.0", "contributors": [ "Douglas Christopher Wilson ", "Jonathan Ong (http://jongleberry.com)" @@ -12,30 +12,42 @@ "type": "opencollective", "url": "https://opencollective.com/express" }, - "devDependencies": { - "eslint": "8.32.0", - "eslint-config-standard": "14.1.1", - "eslint-plugin-import": "2.27.5", - "eslint-plugin-markdown": "3.0.0", - "eslint-plugin-node": "11.1.0", - "eslint-plugin-promise": "6.1.1", - "eslint-plugin-standard": "4.1.0", - "mocha": "9.2.2", - "nyc": "15.1.0", - "supertest": "6.3.3" + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } }, + "types": "./dist/index.d.ts", "files": [ "LICENSE", "README.md", - "index.js" + "dist" ], "engines": { - "node": ">= 0.8.0" + "node": ">=24" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.13.1", + "c8": "^10.1.3", + "eslint": "^9.39.4", + "globals": "^17.6.0", + "supertest": "^7.2.2", + "tinybench": "^3.1.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.61.0" }, "scripts": { + "build": "tsc -p tsconfig.json", + "prepublishOnly": "npm run build", "lint": "eslint .", - "test": "mocha --reporter spec --bail --check-leaks test/", - "test-ci": "nyc --reporter=lcovonly --reporter=text npm test", - "test-cov": "nyc --reporter=html --reporter=text npm test" + "test": "node --test --test-force-exit \"test/**/*.mjs\"", + "test-cov": "c8 --reporter=text --reporter=html node --test --test-force-exit \"test/**/*.mjs\"", + "test-ci": "c8 --reporter=lcovonly --reporter=text node --test --test-force-exit \"test/**/*.mjs\"", + "bench": "node bench/index.mjs", + "bench:matrix": "node bench/run-matrix.mjs 8 10000 100000 1000000", + "bench:collect": "node bench/collect.mjs 25 10000 100000 1000000" } } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8e5f223 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,273 @@ +/*! + * vhost + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * Copyright(c) 2025 vhost contributors + * MIT Licensed + */ + +/** + * The object assigned to `req.vhost` when a request matches. + * + * Numeric keys `0..length-1` hold the captured wildcard / RegExp group values. + */ +export interface VHost { + /** The raw `Host` header, including the port if one was present. */ + host: string + /** The `Host` header with any port stripped. */ + hostname: string + /** The number of captured wildcards / RegExp groups. */ + length: number + /** Captured values, indexed `0..length-1`. */ + [index: number]: string +} + +/** + * The minimal request shape the middleware needs. `http.IncomingMessage` and + * the Express / connect request objects are all structurally assignable to it, + * so consumers pass their real request without casting. + */ +export interface VHostRequest { + headers: { host?: string | undefined } + vhost?: VHost +} + +/** A middleware / handler function, framework-agnostic over the response type. */ +export type VHostHandle = ( + req: Req, + res: Res, + next: (err?: unknown) => void +) => void + +const ASTERISK_REGEXP = /\*/g +const ASTERISK_REPLACE = '([^.]+)' +const END_ANCHORED_REGEXP = /(?:^|[^\\])(?:\\\\)*\$$/ +const ESCAPE_REGEXP = /([.+?^=!:${}()|[\]/\\])/g +const ESCAPE_REPLACE = '\\$1' + +/** + * Create a vhost middleware that hands the request to `handle` when the + * incoming `Host` matches `hostname`, otherwise calls `next()`. + * + * @param hostname A literal hostname (with optional `*` wildcards) or a RegExp. + * @param handle The handler invoked as `handle(req, res, next)` on a match. + */ +export default function vhost ( + hostname: string | RegExp, + handle: VHostHandle +): VHostHandle { + if (!hostname) { + throw new TypeError('argument hostname is required') + } + + if (!handle) { + throw new TypeError('argument handle is required') + } + + if (typeof handle !== 'function') { + throw new TypeError('argument handle must be a function') + } + + // Compile the same anchored, case-insensitive RegExp the library has always + // used. A static string (no '*') compiles to a zero-group RegExp. + const regexp = hostregexp(hostname) + + // Fast path: an exact hostname has no captures, so the result is always the + // fixed shape { host, hostname, length: 0 } and matching is a boolean test. + // + // For an *ASCII* literal we can decide most requests without touching the + // regex, while staying byte-for-byte identical to `regexp.test(name)`: + // - Different length => guaranteed non-match. RegExp `i` uses Unicode + // *simple* case folding, which is 1:1 (length-preserving) and never folds + // an astral or multi-char sequence into an ASCII pattern, so a length + // mismatch can never be a match. + // - `name === lowered` => guaranteed match (ASCII, 1:1 fold). + // - otherwise (mixed case) => defer to the identical `regexp.test`. + // This is gated on an ASCII pattern because non-ASCII case folding can change + // length (e.g. 'İ'.toLowerCase() is two code units) and because code points + // such as U+212A (Kelvin) fold to ASCII under the regex but NOT under + // toLowerCase() — so a naive lowercase compare would change matching. Non- + // ASCII patterns therefore fall back wholly to the regex. + const isStatic = typeof hostname === 'string' && hostname.indexOf('*') === -1 + + if (isStatic) { + const asciiPattern = isAscii(hostname) + const lowered = hostname.toLowerCase() + const loweredLen = lowered.length + + const isMatch = asciiPattern + ? (name: string): boolean => { + if (name.length !== loweredLen) return false + return name === lowered || regexp.test(name) + } + : (name: string): boolean => regexp.test(name) + + return function vhost (req, res, next) { + const host = req.headers.host + + if (!host) { + return next() + } + + const name = hostnameof(host) + + if (!name || !isMatch(name)) { + return next() + } + + const obj = Object.create(null) as VHost + obj.host = host + obj.hostname = name + obj.length = 0 + + req.vhost = obj + handle(req, res, next) + } + } + + // Wildcard string path. The result carries the captured groups, so the regex + // (and its capture extraction) is still the matcher — but a match can never be + // shorter than the pattern's literal characters plus one char per `*`, so a + // cheap length check rejects too-short hostnames before running the regex. + // This only ever *rejects*; anything long enough falls through to the + // identical regex path, so behaviour is unchanged. (Replacing the regex with a + // hand-rolled string matcher was tried and measured slower: the contract's + // `Object.create(null)` result allocation dominates and dwarfs any saving from + // avoiding `exec`, so the extra string work was pure overhead — see + // BENCHMARKS.md.) + if (typeof hostname === 'string') { + // Each `*` matches >= 1 character and every other character is a literal, so + // the shortest possible match has exactly `hostname.length` characters (one + // per `*` plus every literal). Shorter hostnames cannot match. + const minLen = hostname.length + + return function vhost (req, res, next) { + const host = req.headers.host + + if (!host) { + return next() + } + + const name = hostnameof(host) + + if (!name || name.length < minLen) { + return next() + } + + const vhostdata = vhostof(host, name, regexp) + + if (!vhostdata) { + return next() + } + + req.vhost = vhostdata + handle(req, res, next) + } + } + + // RegExp hostname: unchanged regex path. + return function vhost (req, res, next) { + const host = req.headers.host + + if (!host) { + return next() + } + + const name = hostnameof(host) + + if (!name) { + return next() + } + + const vhostdata = vhostof(host, name, regexp) + + if (!vhostdata) { + return next() + } + + req.vhost = vhostdata + handle(req, res, next) + } +} + +/** + * Get the hostname (port stripped) from a raw `Host` header value, handling + * IPv6 literals such as `[::1]:8080`. + */ +function hostnameof (host: string): string | undefined { + if (!host) { + return undefined + } + + const offset = host[0] === '[' + ? host.indexOf(']') + 1 + : 0 + const index = host.indexOf(':', offset) + + return index !== -1 + ? host.substring(0, index) + : host +} + +/** Determine whether a value is a RegExp (cross-realm safe). */ +function isregexp (val: unknown): boolean { + return Object.prototype.toString.call(val) === '[object RegExp]' +} + +/** Determine whether every code unit of a string is ASCII (<= 0x7f). */ +function isAscii (str: string): boolean { + for (let i = 0; i < str.length; i++) { + if (str.charCodeAt(i) > 0x7f) { + return false + } + } + return true +} + +/** + * Build the anchored, case-insensitive RegExp for a hostname value. String + * values have their RegExp metacharacters escaped and `*` turned into a + * single-label capture; RegExp values are reused and forced to anchor. + */ +function hostregexp (val: string | RegExp): RegExp { + let source = !isregexp(val) + ? String(val).replace(ESCAPE_REGEXP, ESCAPE_REPLACE).replace(ASTERISK_REGEXP, ASTERISK_REPLACE) + : (val as RegExp).source + + // force leading anchor matching + if (source[0] !== '^') { + source = '^' + source + } + + // force trailing anchor matching + if (!END_ANCHORED_REGEXP.test(source)) { + source += '$' + } + + return new RegExp(source, 'i') +} + +/** + * Match the (already extracted, port-stripped) `hostname` against `regexp` and + * build the `req.vhost` object, copying any capture groups onto numeric keys. + * `host` is the raw header value used for `obj.host`. + */ +function vhostof (host: string, hostname: string, regexp: RegExp): VHost | undefined { + const match = regexp.exec(hostname) + + if (!match) { + return undefined + } + + const obj = Object.create(null) as VHost + + obj.host = host + obj.hostname = hostname + obj.length = match.length - 1 + + for (let i = 1; i < match.length; i++) { + obj[i - 1] = match[i] as string + } + + return obj +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7385258 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2024", + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src"] +} From 5a7006098d7df18b0dd93204dad2745b354f7152 Mon Sep 17 00:00:00 2001 From: Jean Burellier Date: Sat, 13 Jun 2026 09:33:06 +0200 Subject: [PATCH 2/4] test(vhost): port suite to node:test ESM and expand coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the mocha suite with the built-in node:test runner (ESM) and grow it from 19 to 56 cases to lock the behavioural contract. New cases cover: multiple vhosts in series, null-prototype req.vhost, explicit length-0 static/RegExp shapes, case-insensitivity, regexp-metachar escaping, IPv6 with/without port, trailing-dot literals, and the optimization guards — the Unicode case-fold (U+212A must not match /k/i), the 'İ' length-changing fold, wildcard prefix/suffix capture + case-insensitivity, wrong-prefix and wrong-suffix 404s, non-ASCII host via the regex path, and Kelvin in a wildcard suffix. The module is imported via a stable package self-reference so the same suite runs unchanged against the compiled build. 100% function coverage via c8. --- test/.eslintrc.yml | 2 - test/test.js | 240 --------------- test/test.mjs | 724 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 724 insertions(+), 242 deletions(-) delete mode 100644 test/.eslintrc.yml delete mode 100644 test/test.js create mode 100644 test/test.mjs diff --git a/test/.eslintrc.yml b/test/.eslintrc.yml deleted file mode 100644 index 9808c3b..0000000 --- a/test/.eslintrc.yml +++ /dev/null @@ -1,2 +0,0 @@ -env: - mocha: true diff --git a/test/test.js b/test/test.js deleted file mode 100644 index 1e41c8d..0000000 --- a/test/test.js +++ /dev/null @@ -1,240 +0,0 @@ - -var assert = require('assert') -var http = require('http') -var request = require('supertest') -var vhost = require('..') - -describe('vhost(hostname, server)', function () { - it('should route by Host', function (done) { - var vhosts = [] - - vhosts.push(vhost('tobi.com', tobi)) - vhosts.push(vhost('loki.com', loki)) - - var app = createServer(vhosts) - - function tobi (req, res) { res.end('tobi') } - function loki (req, res) { res.end('loki') } - - request(app) - .get('/') - .set('Host', 'tobi.com') - .expect(200, 'tobi', done) - }) - - it('should ignore port in Host', function (done) { - var app = createServer('tobi.com', function (req, res) { - res.end('tobi') - }) - - request(app) - .get('/') - .set('Host', 'tobi.com:8080') - .expect(200, 'tobi', done) - }) - - it('should support IPv6 literal in Host', function (done) { - var app = createServer('[::1]', function (req, res) { - res.end('loopback') - }) - - request(app) - .get('/') - .set('Host', '[::1]:8080') - .expect(200, 'loopback', done) - }) - - it('should 404 unless matched', function (done) { - var vhosts = [] - - vhosts.push(vhost('tobi.com', tobi)) - vhosts.push(vhost('loki.com', loki)) - - var app = createServer(vhosts) - - function tobi (req, res) { res.end('tobi') } - function loki (req, res) { res.end('loki') } - - request(app) - .get('/') - .set('Host', 'ferrets.com') - .expect(404, done) - }) - - it('should 404 without Host header', function (done) { - var vhosts = [] - - vhosts.push(vhost('tobi.com', tobi)) - vhosts.push(vhost('loki.com', loki)) - - var server = createServer(vhosts) - var listeners = server.listeners('request') - - server.removeAllListeners('request') - listeners.unshift(function (req) { req.headers.host = undefined }) - listeners.forEach(function (l) { server.addListener('request', l) }) - - function tobi (req, res) { res.end('tobi') } - function loki (req, res) { res.end('loki') } - - request(server) - .get('/') - .expect(404, 'no vhost for "undefined"', done) - }) - - describe('arguments', function () { - describe('hostname', function () { - it('should be required', function () { - assert.throws(vhost.bind(), /hostname.*required/) - }) - - it('should accept string', function () { - assert.doesNotThrow(vhost.bind(null, 'loki.com', function () {})) - }) - - it('should accept RegExp', function () { - assert.doesNotThrow(vhost.bind(null, /loki\.com/, function () {})) - }) - }) - - describe('handle', function () { - it('should be required', function () { - assert.throws(vhost.bind(null, 'loki.com'), /handle.*required/) - }) - - it('should accept function', function () { - assert.doesNotThrow(vhost.bind(null, 'loki.com', function () {})) - }) - - it('should reject plain object', function () { - assert.throws(vhost.bind(null, 'loki.com', {}), /handle.*function/) - }) - }) - }) - - describe('with string hostname', function () { - it('should support wildcards', function (done) { - var app = createServer('*.ferrets.com', function (req, res) { - res.end('wildcard!') - }) - - request(app) - .get('/') - .set('Host', 'loki.ferrets.com') - .expect(200, 'wildcard!', done) - }) - - it('should restrict wildcards to single part', function (done) { - var app = createServer('*.ferrets.com', function (req, res) { - res.end('wildcard!') - }) - - request(app) - .get('/') - .set('Host', 'foo.loki.ferrets.com') - .expect(404, done) - }) - - it('should treat dot as a dot', function (done) { - var app = createServer('a.b.com', function (req, res) { - res.end('tobi') - }) - - request(app) - .get('/') - .set('Host', 'aXb.com') - .expect(404, done) - }) - - it('should match entire string', function (done) { - var app = createServer('.com', function (req, res) { - res.end('commercial') - }) - - request(app) - .get('/') - .set('Host', 'foo.com') - .expect(404, done) - }) - - it('should populate req.vhost', function (done) { - var app = createServer('user-*.*.com', function (req, res) { - var keys = Object.keys(req.vhost).sort() - var arr = keys.map(function (k) { return [k, req.vhost[k]] }) - res.end(JSON.stringify(arr)) - }) - - request(app) - .get('/') - .set('Host', 'user-bob.foo.com:8080') - .expect(200, '[["0","bob"],["1","foo"],["host","user-bob.foo.com:8080"],["hostname","user-bob.foo.com"],["length",2]]', done) - }) - }) - - describe('with RegExp hostname', function () { - it('should match using RegExp', function (done) { - var app = createServer(/[tl]o[bk]i\.com/, function (req, res) { - res.end('tobi') - }) - - request(app) - .get('/') - .set('Host', 'toki.com') - .expect(200, 'tobi', done) - }) - - it('should match entire hostname', function (done) { - var vhosts = [] - - vhosts.push(vhost(/\.tobi$/, tobi)) - vhosts.push(vhost(/^loki\./, loki)) - - var app = createServer(vhosts) - - function tobi (req, res) { res.end('tobi') } - function loki (req, res) { res.end('loki') } - - request(app) - .get('/') - .set('Host', 'loki.tobi.com') - .expect(404, done) - }) - - it('should populate req.vhost', function (done) { - var app = createServer(/user-(bob|joe)\.([^.]+)\.com/, function (req, res) { - var keys = Object.keys(req.vhost).sort() - var arr = keys.map(function (k) { return [k, req.vhost[k]] }) - res.end(JSON.stringify(arr)) - }) - - request(app) - .get('/') - .set('Host', 'user-bob.foo.com:8080') - .expect(200, '[["0","bob"],["1","foo"],["host","user-bob.foo.com:8080"],["hostname","user-bob.foo.com"],["length",2]]', done) - }) - }) -}) - -function createServer (hostname, server) { - var vhosts = !Array.isArray(hostname) - ? [vhost(hostname, server)] - : hostname - - return http.createServer(function onRequest (req, res) { - var index = 0 - - function next (err) { - var vhost = vhosts[index++] - - if (!vhost || err) { - res.statusCode = err ? (err.status || 500) : 404 - res.end(err ? err.message : 'no vhost for "' + req.headers.host + '"') - return - } - - vhost(req, res, next) - } - - next() - }) -} diff --git a/test/test.mjs b/test/test.mjs new file mode 100644 index 0000000..dc3fbe9 --- /dev/null +++ b/test/test.mjs @@ -0,0 +1,724 @@ +import assert from 'node:assert' +import http from 'node:http' +import { describe, it } from 'node:test' +import request from 'supertest' +import vhost from 'vhost' + +describe('vhost(hostname, server)', function () { + it('should route by Host', function (_, done) { + var vhosts = [] + + vhosts.push(vhost('tobi.com', tobi)) + vhosts.push(vhost('loki.com', loki)) + + var app = createServer(vhosts) + + function tobi (req, res) { res.end('tobi') } + function loki (req, res) { res.end('loki') } + + request(app) + .get('/') + .set('Host', 'tobi.com') + .expect(200, 'tobi', done) + }) + + it('should ignore port in Host', function (_, done) { + var app = createServer('tobi.com', function (req, res) { + res.end('tobi') + }) + + request(app) + .get('/') + .set('Host', 'tobi.com:8080') + .expect(200, 'tobi', done) + }) + + it('should support IPv6 literal in Host', function (_, done) { + var app = createServer('[::1]', function (req, res) { + res.end('loopback') + }) + + request(app) + .get('/') + .set('Host', '[::1]:8080') + .expect(200, 'loopback', done) + }) + + it('should support IPv6 literal in Host without a port', function (_, done) { + var app = createServer('[::1]', function (req, res) { + res.end(JSON.stringify({ host: req.vhost.host, hostname: req.vhost.hostname })) + }) + + request(app) + .get('/') + .set('Host', '[::1]') + .expect(200, '{"host":"[::1]","hostname":"[::1]"}', done) + }) + + it('should keep the port on req.vhost.host but strip it from hostname', function (_, done) { + var app = createServer('tobi.com', function (req, res) { + res.end(JSON.stringify({ host: req.vhost.host, hostname: req.vhost.hostname })) + }) + + request(app) + .get('/') + .set('Host', 'tobi.com:8080') + .expect(200, '{"host":"tobi.com:8080","hostname":"tobi.com"}', done) + }) + + it('should 404 unless matched', function (_, done) { + var vhosts = [] + + vhosts.push(vhost('tobi.com', tobi)) + vhosts.push(vhost('loki.com', loki)) + + var app = createServer(vhosts) + + function tobi (req, res) { res.end('tobi') } + function loki (req, res) { res.end('loki') } + + request(app) + .get('/') + .set('Host', 'ferrets.com') + .expect(404, done) + }) + + it('should 404 without Host header', function (_, done) { + var vhosts = [] + + vhosts.push(vhost('tobi.com', tobi)) + vhosts.push(vhost('loki.com', loki)) + + var server = createServer(vhosts) + var listeners = server.listeners('request') + + server.removeAllListeners('request') + listeners.unshift(function (req) { req.headers.host = undefined }) + listeners.forEach(function (l) { server.addListener('request', l) }) + + function tobi (req, res) { res.end('tobi') } + function loki (req, res) { res.end('loki') } + + request(server) + .get('/') + .expect(404, 'no vhost for "undefined"', done) + }) + + it('should route the second vhost when the first does not match', function (_, done) { + var vhosts = [] + + vhosts.push(vhost('tobi.com', tobi)) + vhosts.push(vhost('loki.com', loki)) + + var app = createServer(vhosts) + + function tobi (req, res) { res.end('tobi') } + function loki (req, res) { res.end('loki') } + + request(app) + .get('/') + .set('Host', 'loki.com') + .expect(200, 'loki', done) + }) + + it('should call handle with (req, res, next)', function (_, done) { + var app = http.createServer(function onRequest (req, res) { + var mw = vhost('tobi.com', function (hreq, hres, hnext) { + assert.strictEqual(hreq, req) + assert.strictEqual(hres, res) + assert.strictEqual(typeof hnext, 'function') + res.end('ok') + }) + + mw(req, res, function () { + res.statusCode = 404 + res.end('not handled') + }) + }) + + request(app) + .get('/') + .set('Host', 'tobi.com') + .expect(200, 'ok', done) + }) + + it('should leave req.vhost undefined when no Host header', function (_, done) { + var app = http.createServer(function onRequest (req, res) { + req.headers.host = undefined + + var mw = vhost('tobi.com', function (req, res) { + res.end('handled') + }) + + mw(req, res, function () { + res.end(String(req.vhost)) + }) + }) + + request(app) + .get('/') + .expect(200, 'undefined', done) + }) + + describe('arguments', function () { + describe('hostname', function () { + it('should be required', function () { + assert.throws(vhost.bind(), /hostname.*required/) + }) + + it('should reject the empty string', function () { + assert.throws(vhost.bind(null, '', function () {}), /hostname.*required/) + }) + + it('should accept string', function () { + assert.doesNotThrow(vhost.bind(null, 'loki.com', function () {})) + }) + + it('should accept RegExp', function () { + assert.doesNotThrow(vhost.bind(null, /loki\.com/, function () {})) + }) + }) + + describe('handle', function () { + it('should be required', function () { + assert.throws(vhost.bind(null, 'loki.com'), /handle.*required/) + }) + + it('should accept function', function () { + assert.doesNotThrow(vhost.bind(null, 'loki.com', function () {})) + }) + + it('should reject plain object', function () { + assert.throws(vhost.bind(null, 'loki.com', {}), /handle.*function/) + }) + }) + }) + + describe('with string hostname', function () { + it('should support wildcards', function (_, done) { + var app = createServer('*.ferrets.com', function (req, res) { + res.end('wildcard!') + }) + + request(app) + .get('/') + .set('Host', 'loki.ferrets.com') + .expect(200, 'wildcard!', done) + }) + + it('should restrict wildcards to single part', function (_, done) { + var app = createServer('*.ferrets.com', function (req, res) { + res.end('wildcard!') + }) + + request(app) + .get('/') + .set('Host', 'foo.loki.ferrets.com') + .expect(404, done) + }) + + it('should require a wildcard to match at least one character', function (_, done) { + var app = createServer('*.example.com', function (req, res) { + res.end('wildcard!') + }) + + request(app) + .get('/') + .set('Host', '.example.com') + .expect(404, done) + }) + + it('should capture a single wildcard', function (_, done) { + var app = createServer('*.example.com', function (req, res) { + res.end(JSON.stringify([req.vhost.length, req.vhost[0]])) + }) + + request(app) + .get('/') + .set('Host', 'foo.example.com') + .expect(200, '[1,"foo"]', done) + }) + + it('should capture a wildcard with both a prefix and a suffix', function (_, done) { + var app = createServer('user-*.example.com', function (req, res) { + res.end(JSON.stringify([req.vhost.length, req.vhost[0]])) + }) + + request(app) + .get('/') + .set('Host', 'user-bob.example.com') + .expect(200, '[1,"bob"]', done) + }) + + it('should 404 a single-wildcard host with the wrong suffix', function (_, done) { + var app = createServer('*.example.com', function (req, res) { + res.end('wildcard!') + }) + + request(app) + .get('/') + .set('Host', 'foo.example.org') + .expect(404, done) + }) + + it('should 404 a single-wildcard host with the wrong prefix', function (_, done) { + var app = createServer('user-*.example.com', function (req, res) { + res.end('wildcard!') + }) + + request(app) + .get('/') + .set('Host', 'admin-bob.example.com') + .expect(404, done) + }) + + it('should preserve wildcard case-insensitivity on prefix and suffix', function (_, done) { + var app = createServer('user-*.example.com', function (req, res) { + res.end(req.vhost[0]) + }) + + request(app) + .get('/') + .set('Host', 'USER-Bob.EXAMPLE.COM') + .expect(200, 'Bob', done) + }) + + it('should match a non-ASCII host against a wildcard via the regex path', function () { + // Non-ASCII bytes in the literal region defer to the regex (proper + // Unicode folding). Invoked directly: raw Unicode is not a valid Host + // header. café-* with host "café-bob.example" must capture "bob". + var captured + var mw = vhost('café-*.example', function (req) { captured = req.vhost }) + var req = { headers: { host: 'café-bob.example' } } + mw(req, {}, function () {}) + + assert.ok(captured, 'should match non-ASCII prefix via regex') + assert.strictEqual(captured.length, 1) + assert.strictEqual(captured[0], 'bob') + }) + + it('should fold a wildcard suffix like the i-flag, not toLowerCase', function () { + // U+212A KELVIN in the host suffix region must NOT match ASCII "k" — the + // fast path defers to the regex on the non-ASCII byte. (Direct call: the + // byte is rejected by the HTTP client.) + var kelvin = 'foo.' + String.fromCodePoint(0x212a) + '.com' + var handled = false + var mw = vhost('*.k.com', function () { handled = true }) + var req = { headers: { host: kelvin } } + mw(req, {}, function () {}) + + assert.strictEqual(handled, false, 'Kelvin suffix must not match ASCII k') + assert.strictEqual(req.vhost, undefined) + }) + + it('should treat dot as a dot', function (_, done) { + var app = createServer('a.b.com', function (req, res) { + res.end('tobi') + }) + + request(app) + .get('/') + .set('Host', 'aXb.com') + .expect(404, done) + }) + + it('should match a trailing dot literally', function (_, done) { + var app = createServer('a.b.', function (req, res) { + res.end('trailing dot') + }) + + request(app) + .get('/') + .set('Host', 'a.b.') + .expect(200, 'trailing dot', done) + }) + + it('should not match a trailing-dot pattern without the dot', function (_, done) { + var app = createServer('a.b.', function (req, res) { + res.end('trailing dot') + }) + + request(app) + .get('/') + .set('Host', 'a.b') + .expect(404, done) + }) + + it('should match entire string', function (_, done) { + var app = createServer('.com', function (req, res) { + res.end('commercial') + }) + + request(app) + .get('/') + .set('Host', 'foo.com') + .expect(404, done) + }) + + it('should treat "+" as a literal', function (_, done) { + var app = createServer('a+b.com', function (req, res) { + res.end('plus') + }) + + request(app) + .get('/') + .set('Host', 'a+b.com') + .expect(200, 'plus', done) + }) + + it('should not interpret "+" as a quantifier', function (_, done) { + var app = createServer('a+b.com', function (req, res) { + res.end('plus') + }) + + request(app) + .get('/') + .set('Host', 'aaab.com') + .expect(404, done) + }) + + it('should escape regexp metacharacters', function (_, done) { + var app = createServer('a(b)?[c].com', function (req, res) { + res.end('meta') + }) + + request(app) + .get('/') + .set('Host', 'a(b)?[c].com') + .expect(200, 'meta', done) + }) + + it('should match case-insensitively', function (_, done) { + var app = createServer('mail.example.com', function (req, res) { + res.end(req.vhost.hostname) + }) + + request(app) + .get('/') + .set('Host', 'MAIL.EXAMPLE.COM') + .expect(200, 'MAIL.EXAMPLE.COM', done) + }) + + it('should fold case like the i-flag, not toLowerCase', function () { + // U+212A KELVIN SIGN lowercases to "k" via String#toLowerCase but does + // NOT match /k/i. A toLowerCase-based fast path would wrongly match. + // Node's HTTP client rejects this byte in a real Host header, so the + // middleware is invoked directly with a fabricated request. + var kelvin = String.fromCodePoint(0x212a) + '.com' + var handled = false + var nexted = false + + var mw = vhost('k.com', function () { handled = true }) + var req = { headers: { host: kelvin } } + + mw(req, {}, function () { nexted = true }) + + assert.strictEqual(handled, false, 'handle must not run for Kelvin sign') + assert.strictEqual(nexted, true, 'next must be called') + assert.strictEqual(req.vhost, undefined) + }) + + it('should not match a length-changing case fold', function () { + // U+0130 LATIN CAPITAL I WITH DOT ABOVE lowercases to "i" + a combining + // dot (two code units), so it must not match the single-char "i". This + // guards the ASCII fast path's length check. Invoked directly because the + // byte is rejected by the HTTP client. + var dotted = String.fromCodePoint(0x0130) + '.com' + var handled = false + + var mw = vhost('i.com', function () { handled = true }) + var req = { headers: { host: dotted } } + + mw(req, {}, function () {}) + + assert.strictEqual(handled, false, 'İ must not match ASCII i') + assert.strictEqual(req.vhost, undefined) + }) + + it('should match a mixed-case static host (fast-path regex fallback)', function (_, done) { + var app = createServer('mail.example.com', function (req, res) { + res.end('hit:' + req.vhost.length) + }) + + request(app) + .get('/') + .set('Host', 'Mail.Example.Com') + .expect(200, 'hit:0', done) + }) + + it('should match a non-ASCII static host via the regex path', function () { + // Non-ASCII literal patterns bypass the ASCII fast path and match through + // the regex, case-insensitively. Invoked directly: the raw Unicode bytes + // are not a valid HTTP Host header. + var mw = vhost('café.example', function (req, res) { res.end = res }) + var lower = { headers: { host: 'café.example' } } + var upper = { headers: { host: 'CAFÉ.example' } } + var miss = { headers: { host: 'cafe.example' } } + var lowerNexted = false + var upperNexted = false + var missNexted = false + + mw(lower, {}, function () { lowerNexted = true }) + mw(upper, {}, function () { upperNexted = true }) + mw(miss, {}, function () { missNexted = true }) + + assert.strictEqual(lowerNexted, false, 'exact non-ASCII host should match') + assert.strictEqual(lower.vhost.hostname, 'café.example') + assert.strictEqual(upperNexted, false, 'non-ASCII host matches case-insensitively') + assert.strictEqual(missNexted, true, 'ASCII "cafe" must not match "café"') + }) + + it('should not match when Host is only a port', function (_, done) { + var app = createServer('tobi.com', function (req, res) { + res.end('tobi') + }) + + request(app) + .get('/') + .set('Host', ':8080') + .expect(404, done) + }) + + it('should preserve case and port on a uppercase Host with port', function (_, done) { + var app = createServer('tobi.com', function (req, res) { + res.end(JSON.stringify({ host: req.vhost.host, hostname: req.vhost.hostname })) + }) + + request(app) + .get('/') + .set('Host', 'TOBI.COM:1234') + .expect(200, '{"host":"TOBI.COM:1234","hostname":"TOBI.COM"}', done) + }) + + it('should give req.vhost a null prototype', function (_, done) { + var app = createServer('tobi.com', function (req, res) { + res.end(String(Object.getPrototypeOf(req.vhost))) + }) + + request(app) + .get('/') + .set('Host', 'tobi.com') + .expect(200, 'null', done) + }) + + it('should populate req.vhost with no captures for a static hostname', function (_, done) { + var app = createServer('tobi.com', function (req, res) { + res.end(JSON.stringify({ + keys: Object.keys(req.vhost).sort(), + length: req.vhost.length, + zero: req.vhost[0] + })) + }) + + request(app) + .get('/') + .set('Host', 'tobi.com') + .expect(200, '{"keys":["host","hostname","length"],"length":0}', done) + }) + + it('should populate req.vhost', function (_, done) { + var app = createServer('user-*.*.com', function (req, res) { + var keys = Object.keys(req.vhost).sort() + var arr = keys.map(function (k) { return [k, req.vhost[k]] }) + res.end(JSON.stringify(arr)) + }) + + request(app) + .get('/') + .set('Host', 'user-bob.foo.com:8080') + .expect(200, '[["0","bob"],["1","foo"],["host","user-bob.foo.com:8080"],["hostname","user-bob.foo.com"],["length",2]]', done) + }) + + it('should 404 a multi-wildcard host that does not match', function (_, done) { + var app = createServer('*.*.com', function (req, res) { + res.end('multi') + }) + + request(app) + .get('/') + .set('Host', 'only-one-label') + .expect(404, done) + }) + + it('should call next() for a multi-wildcard with no Host header', function (_, done) { + var app = http.createServer(function onRequest (req, res) { + req.headers.host = undefined + + var mw = vhost('*.*.com', function (req, res) { + res.end('handled') + }) + + mw(req, res, function () { + res.end('next:' + String(req.vhost)) + }) + }) + + request(app) + .get('/') + .expect(200, 'next:undefined', done) + }) + + it('should call next() for a wildcard with no Host header', function (_, done) { + var app = http.createServer(function onRequest (req, res) { + req.headers.host = undefined + + var mw = vhost('*.example.com', function (req, res) { + res.end('handled') + }) + + mw(req, res, function () { + res.end('next:' + String(req.vhost)) + }) + }) + + request(app) + .get('/') + .expect(200, 'next:undefined', done) + }) + }) + + describe('with RegExp hostname', function () { + it('should match using RegExp', function (_, done) { + var app = createServer(/[tl]o[bk]i\.com/, function (req, res) { + res.end('tobi') + }) + + request(app) + .get('/') + .set('Host', 'toki.com') + .expect(200, 'tobi', done) + }) + + it('should match entire hostname', function (_, done) { + var vhosts = [] + + vhosts.push(vhost(/\.tobi$/, tobi)) + vhosts.push(vhost(/^loki\./, loki)) + + var app = createServer(vhosts) + + function tobi (req, res) { res.end('tobi') } + function loki (req, res) { res.end('loki') } + + request(app) + .get('/') + .set('Host', 'loki.tobi.com') + .expect(404, done) + }) + + it('should call next() without a Host header', function (_, done) { + var app = http.createServer(function onRequest (req, res) { + req.headers.host = undefined + + var mw = vhost(/foo\.com/, function (req, res) { + res.end('handled') + }) + + mw(req, res, function () { + res.end('next:' + String(req.vhost)) + }) + }) + + request(app) + .get('/') + .expect(200, 'next:undefined', done) + }) + + it('should call next() when the Host has no hostname part', function (_, done) { + var app = createServer(/foo\.com/, function (req, res) { + res.end('foo') + }) + + request(app) + .get('/') + .set('Host', ':8080') + .expect(404, done) + }) + + it('should anchor a RegExp already ending in $', function (_, done) { + var app = createServer(/foo\.com$/, function (req, res) { + res.end('foo') + }) + + request(app) + .get('/') + .set('Host', 'foo.com') + .expect(200, 'foo', done) + }) + + it('should add an anchor after an escaped trailing $', function (_, done) { + // /foo\$/ ends in an escaped dollar (a literal "$"), so END_ANCHORED_REGEXP + // does NOT consider it anchored and a real "$" anchor is appended. + var app = createServer(/foo\$/, function (req, res) { + res.end('dollar') + }) + + request(app) + .get('/') + .set('Host', 'foo$') + .expect(200, 'dollar', done) + }) + + it('should not double-anchor a RegExp starting with ^', function (_, done) { + var app = createServer(/^foo\.com/, function (req, res) { + res.end('foo') + }) + + request(app) + .get('/') + .set('Host', 'foo.com') + .expect(200, 'foo', done) + }) + + it('should report length 0 for a RegExp with no capture groups', function (_, done) { + var app = createServer(/foo\.com/, function (req, res) { + res.end(JSON.stringify({ + keys: Object.keys(req.vhost).sort(), + length: req.vhost.length + })) + }) + + request(app) + .get('/') + .set('Host', 'foo.com') + .expect(200, '{"keys":["host","hostname","length"],"length":0}', done) + }) + + it('should populate req.vhost', function (_, done) { + var app = createServer(/user-(bob|joe)\.([^.]+)\.com/, function (req, res) { + var keys = Object.keys(req.vhost).sort() + var arr = keys.map(function (k) { return [k, req.vhost[k]] }) + res.end(JSON.stringify(arr)) + }) + + request(app) + .get('/') + .set('Host', 'user-bob.foo.com:8080') + .expect(200, '[["0","bob"],["1","foo"],["host","user-bob.foo.com:8080"],["hostname","user-bob.foo.com"],["length",2]]', done) + }) + }) +}) + +function createServer (hostname, server) { + var vhosts = !Array.isArray(hostname) + ? [vhost(hostname, server)] + : hostname + + return http.createServer(function onRequest (req, res) { + var index = 0 + + function next (err) { + var vhost = vhosts[index++] + + if (!vhost || err) { + res.statusCode = err ? (err.status || 500) : 404 + res.end(err ? err.message : 'no vhost for "' + req.headers.host + '"') + return + } + + vhost(req, res, next) + } + + next() + }) +} From 53f41c7637a09d039d083e14ef55c5d8969a26f8 Mon Sep 17 00:00:00 2001 From: Jean Burellier Date: Sat, 13 Jun 2026 09:33:15 +0200 Subject: [PATCH 3/4] docs(vhost): update README and HISTORY for the v4 ESM rewrite Switch README examples from require() to import, note the ESM-only / Node 24+ requirement and bundled types, and document the exported VHost type. Add the 4.0.0 HISTORY entry. --- HISTORY.md | 10 ++++++++++ README.md | 57 +++++++++++++++++++++++++++++++++--------------------- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index b94cbbd..dc7077e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,13 @@ +4.0.0 / 2026-06-10 +================== + + * Rewrite in TypeScript; ship ESM only with bundled type declarations + * Drop support for Node.js below 24; require Node.js 24 or newer + * **Breaking:** package is now ESM (`import vhost from 'vhost'`); `require()` is + no longer supported + * perf: fast path for static (non-wildcard) hostnames avoids capture allocation + * No change to matching behavior or the `req.vhost` contract + 3.0.2 / 2015-10-12 ================== diff --git a/README.md b/README.md index 422e67f..6e57a24 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,13 @@ $ npm install vhost ``` +This package is ESM-only and requires Node.js **24 or newer**. TypeScript type +declarations are bundled. + ## API ```js -var vhost = require('vhost') +import vhost from 'vhost' ``` ### vhost(hostname, handle) @@ -34,9 +37,9 @@ corresponding to each wildcard (or capture group if RegExp object provided) and `hostname` that was matched. ```js -var connect = require('connect') -var vhost = require('vhost') -var app = connect() +import connect from 'connect' +import vhost from 'vhost' +const app = connect() app.use(vhost('*.*.example.com', function handle (req, res, next) { // for match of "foo.bar.example.com:8080" against "*.*.example.com": @@ -48,25 +51,35 @@ app.use(vhost('*.*.example.com', function handle (req, res, next) { })) ``` +### TypeScript + +The package ships type declarations. The shape assigned to `req.vhost` is exported as +`VHost`, and `vhost()` is generic over the request and response types so it works with +raw `http`, connect, or Express handlers without casting: + +```ts +import vhost, { type VHost } from 'vhost' +``` + ## Examples ### using with connect for static serving ```js -var connect = require('connect') -var serveStatic = require('serve-static') -var vhost = require('vhost') +import connect from 'connect' +import serveStatic from 'serve-static' +import vhost from 'vhost' -var mailapp = connect() +const mailapp = connect() // add middlewares to mailapp for mail.example.com // create app to serve static files on subdomain -var staticapp = connect() +const staticapp = connect() staticapp.use(serveStatic('public')) // create main app -var app = connect() +const app = connect() // add vhost routing to main app for mail app.use(vhost('mail.example.com', mailapp)) @@ -83,19 +96,19 @@ app.listen(3000) ### using with connect for user subdomains ```js -var connect = require('connect') -var serveStatic = require('serve-static') -var vhost = require('vhost') +import connect from 'connect' +import serveStatic from 'serve-static' +import vhost from 'vhost' -var mainapp = connect() +const mainapp = connect() // add middlewares to mainapp for the main web site // create app that will server user content from public/{username}/ -var userapp = connect() +const userapp = connect() userapp.use(function (req, res, next) { - var username = req.vhost[0] // username is the "*" + const username = req.vhost[0] // username is the "*" // pretend request was for /{username}/* for file serving req.originalUrl = req.url @@ -106,7 +119,7 @@ userapp.use(function (req, res, next) { userapp.use(serveStatic('public')) // create main app -var app = connect() +const app = connect() // add vhost routing for main app app.use(vhost('userpages.local', mainapp)) @@ -121,12 +134,12 @@ app.listen(3000) ### using with any generic request handler ```js -var connect = require('connect') -var http = require('http') -var vhost = require('vhost') +import connect from 'connect' +import http from 'node:http' +import vhost from 'vhost' // create main app -var app = connect() +const app = connect() app.use(vhost('mail.example.com', function (req, res) { // handle req + res belonging to mail.example.com @@ -135,7 +148,7 @@ app.use(vhost('mail.example.com', function (req, res) { })) // an external api server in any framework -var httpServer = http.createServer(function (req, res) { +const httpServer = http.createServer(function (req, res) { res.setHeader('Content-Type', 'text/plain') res.end('hello from the api!') }) From 46441d717a8e37084020d22be296d44731398402 Mon Sep 17 00:00:00 2001 From: Jean Burellier Date: Sat, 13 Jun 2026 09:40:39 +0200 Subject: [PATCH 4/4] bench(vhost): add benchmark harness, stored results, and BENCHMARKS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the original v3 implementation at bench/v3-baseline.cjs so old-vs-new stays reproducible. Add: - bench/index.mjs quick single-process snapshot (tinybench) - bench/session.mjs one fresh-process session, fixed-iteration hrtime - bench/run-matrix.mjs N sessions × several lengths, mean/min/max/sd - bench/collect.mjs persists raw + summary per run to bench/results/ - bench/{,run-}*experiments.mjs variant studies, each fuzz-checked for identical captures against the regex (incl. the rejected wildcard string matcher and the object-allocation lesson) Store a 25-session × {10k,100k,1M} run and document method + results in BENCHMARKS.md (medians reported alongside means since OS-scheduler spikes inflate the mean at 1M). --- BENCHMARKS.md | 115 ++ bench/collect.mjs | 123 ++ bench/experiments.mjs | 231 ++++ bench/index.mjs | 68 ++ .../2026-06-13T07-13-20-615Z/meta.json | 26 + .../2026-06-13T07-13-20-615Z/raw-10000.json | 1031 +++++++++++++++++ .../2026-06-13T07-13-20-615Z/raw-100000.json | 1031 +++++++++++++++++ .../2026-06-13T07-13-20-615Z/raw-1000000.json | 1031 +++++++++++++++++ .../2026-06-13T07-13-20-615Z/summary.json | 549 +++++++++ .../2026-06-13T07-13-20-615Z/summary.md | 50 + bench/results/README.md | 32 + bench/run-experiments.mjs | 44 + bench/run-matrix.mjs | 98 ++ bench/run-wildcard-experiments.mjs | 48 + bench/session.mjs | 75 ++ bench/v3-baseline.cjs | 164 +++ bench/wildcard-experiments.mjs | 415 +++++++ 17 files changed, 5131 insertions(+) create mode 100644 BENCHMARKS.md create mode 100644 bench/collect.mjs create mode 100644 bench/experiments.mjs create mode 100644 bench/index.mjs create mode 100644 bench/results/2026-06-13T07-13-20-615Z/meta.json create mode 100644 bench/results/2026-06-13T07-13-20-615Z/raw-10000.json create mode 100644 bench/results/2026-06-13T07-13-20-615Z/raw-100000.json create mode 100644 bench/results/2026-06-13T07-13-20-615Z/raw-1000000.json create mode 100644 bench/results/2026-06-13T07-13-20-615Z/summary.json create mode 100644 bench/results/2026-06-13T07-13-20-615Z/summary.md create mode 100644 bench/results/README.md create mode 100644 bench/run-experiments.mjs create mode 100644 bench/run-matrix.mjs create mode 100644 bench/run-wildcard-experiments.mjs create mode 100644 bench/session.mjs create mode 100644 bench/v3-baseline.cjs create mode 100644 bench/wildcard-experiments.mjs diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 0000000..5e6c08e --- /dev/null +++ b/BENCHMARKS.md @@ -0,0 +1,115 @@ +# vhost benchmarks + +Measures the per-request cost of the middleware returned by `vhost(...)`: hostname +matching plus `req.vhost` population. Handlers are no-ops so only the library's work +is timed. + +```sh +# quick single-process snapshot (tinybench) +node bench/index.mjs + +# rigorous comparison: N independent sessions × several iteration lengths +node bench/run-matrix.mjs 8 10000 100000 1000000 +``` + +Lower ns/op is better. Numbers are machine- and load-dependent — what matters is the +relative change between versions measured on the same machine. Per-op figures are +comparable across the CJS baseline and the ESM build because module-load cost is paid +once per process, not per operation. + +The v3 implementation is pinned at `bench/v3-baseline.cjs` (a copy of the original +`index.js`) so the old-vs-new comparison stays reproducible after the rewrite. + +Environment: Node.js v24.15.0, darwin, arm64 (Apple Silicon). + +## Baseline — v3.0.2 (`index.js`, CommonJS) + +Every request runs `RegExp.exec` and, on a match, allocates a capture array and loops +to copy groups onto `req.vhost` — even for an exact static hostname with no captures. + +| scenario | ops/sec | ns/op | +| --------------- | -----------: | ----: | +| static match | 11,666,664 | 88.5 | +| static no-match | 23,946,474 | 40.9 | +| wildcard match | 6,779,127 | 160.7 | +| regexp match | 5,896,864 | 195.2 | +| no Host header | 35,641,036 | 21.2 | + +`static match` is the hot path optimized in v4: an exact hostname like +`mail.example.com`, the overwhelmingly common real-world usage. + +## v4.0.0 (`dist/index.js`, TypeScript, ESM) + +For a static (no-`*`) hostname the middleware writes `req.vhost` directly with +`length: 0`, skipping `exec`'s capture array, the group-copy loop, and the result +allocation on a miss. Matching uses an **ASCII-gated decision** that is provably +identical to `regexp.test()`: + +- For an ASCII literal pattern, the precompiled lowercase form `lowered` and its + length are cached. At request time: + - `name.length !== lowered.length` → **definite non-match**. RegExp `i` uses Unicode + *simple* case folding, which is 1:1 (length-preserving) and never folds a multi-char + or astral sequence into an ASCII pattern character, so a length mismatch can never be + a match. This rejects misses without touching the regex. + - `name === lowered` → **definite match** (ASCII, 1:1 fold). + - otherwise (mixed case) → defer to the **identical** `regexp.test(name)`. +- For a non-ASCII literal pattern, matching defers wholly to `regexp.test`, because + non-ASCII folds can change length (e.g. `'İ'.toLowerCase()` is two code units) and + code points such as U+212A (Kelvin) fold to ASCII under the regex but not under + `toLowerCase()`. A naive lowercase compare would change matching — so it is never used. + +Wildcard string hostnames keep the `exec` + capture path but gain a cheap, always-safe +minimum-length reject: a match can never be shorter than the pattern's literal characters +plus one character per `*`, so a too-short hostname is rejected before the regex runs (it +only ever rejects; long-enough hosts fall through to the identical regex). RegExp hostnames +are unchanged. The contract is byte-for-byte preserved: the full 56-case suite passes +unchanged against the build, including regression tests for the Kelvin sign, the `İ` +length-changing fold, and wildcard prefix/suffix capture + case-insensitivity. + +### Multi-session matrix: v3 baseline vs v4 + +`node bench/collect.mjs 25 10000 100000 1000000` — 25 independent sessions (one fresh +`node` process each, so independent JIT/GC state) per length. The table below is the +**100,000-iteration** length, which had the lowest variance in this run; the full sweep +(all three lengths, raw per-session data) is stored under `bench/results/`. `old`/`new` +are mean ns/op; the **median** is shown too because a single OS-scheduler spike can inflate +the mean/max (prefer the median when `±sd%` is high). `speedup` is `old mean ÷ new mean`. +The `static`/`wildcard` scenarios use a lowercase Host — the realistic common case. + +| scenario | old mean | new mean | new median | speedup | ±sd% | +| -------------------------- | -------: | -------: | ---------: | ------: | ---: | +| static match | 84.5 | 61.8 | 61.4 | 1.37× | 3.4% | +| static no-match | 27.5 | 14.3 | 14.2 | 1.92× | 5.0% | +| wildcard match | 149.7 | 149.6 | 149.5 | 1.00× | 2.0% | +| wildcard match (prefix) | 151.0 | 151.7 | 150.8 | 1.00× | 3.3% | +| wildcard no-match (short) | 29.7 | 13.5 | 13.4 | 2.20× | 2.7% | +| wildcard no-match (suffix) | 33.6 | 33.3 | 33.1 | 1.01× | 2.0% | +| multi-star match | 163.2 | 163.2 | 163.0 | 1.00× | 1.7% | +| regexp match | 166.6 | 166.1 | 165.3 | 1.00× | 1.8% | +| no Host header | 7.3 | 5.9 | 5.9 | 1.24× | 1.8% | + +**Reading the results.** + +- **Static** match **~1.37×**, no-match **~1.9×**, no-Host **~1.24×** — the ASCII-gated + decision (above) deciding most requests without the regex. +- **Wildcard short no-match ~2.2×** — the minimum-length reject fires before the regex. +- **Wildcard match / prefix / suffix / multi-star, and RegExp: ~1.0×** — neutral, no + regression. The regex still does the matching and capture here; only too-short hosts are + short-circuited. (At 1,000,000 iterations a couple of these means dip to ~0.93–0.94× from + single OS-scheduler spikes — their medians stay 1.00–1.01×, which is why medians are + reported alongside means.) + +### What did *not* work on the wildcard path + +A hand-rolled single-`*` string matcher (decompose `PREFIX*SUFFIX`, compare prefix/suffix +with an ASCII case-fold loop, slice out the capture, fall back to the regex for +multi-`*`/non-ASCII) *looked* ~1.5× faster on matches in isolation — but that was a +**measurement artifact**: the prototype built `req.vhost` as a fast object literal, whereas +the contract requires `Object.create(null)` populated incrementally. Once the prototype +used the real allocation, the matcher came out **0.62–0.71× (30–38% slower)** on matches, +because the null-prototype result allocation dominates the per-request cost and dwarfs any +saving from skipping `exec`. So the wildcard change is limited to the always-safe +minimum-length reject. The experiment lives in `bench/wildcard-experiments.mjs` (variants +WV1–WV4, each fuzz-checked for identical captures against the regex) with the lesson +documented at the top of the file. + diff --git a/bench/collect.mjs b/bench/collect.mjs new file mode 100644 index 0000000..59ca253 --- /dev/null +++ b/bench/collect.mjs @@ -0,0 +1,123 @@ +// Collect a full benchmark run and persist it for later review. +// +// node bench/collect.mjs [sessions] [lengths...] +// node bench/collect.mjs 25 10000 100000 1000000 # defaults +// +// Spawns `sessions` independent `node bench/session.mjs ` processes per +// length (one process = one independent JIT/GC session) comparing the pinned v3 +// baseline against the current build. Writes, into bench/results//: +// +// meta.json - node version, platform, sessions, lengths, timestamp +// raw-.json - every session's ns/op for every scenario (old + new) +// summary.json - aggregated mean/min/max/sd/median/speedup per scenario +// summary.md - the same as human-readable tables +// +// The run-id is a UTC timestamp so repeated runs accumulate instead of clobber. + +import { execFileSync } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const sessionScript = join(__dirname, 'session.mjs') + +const argv = process.argv.slice(2) +const sessions = Number(argv[0] || 25) +const lengths = (argv.length > 1 ? argv.slice(1) : ['10000', '100000', '1000000']).map(Number) + +const runId = new Date().toISOString().replace(/[:.]/g, '-') +const outDir = join(__dirname, 'results', runId) +mkdirSync(outDir, { recursive: true }) + +function runSession (length) { + const stdout = execFileSync(process.execPath, [sessionScript, String(length)], { encoding: 'utf8' }) + return JSON.parse(stdout.trim().split('\n').pop()) +} + +// Discover the scenario list from the session itself so this stays in sync with +// bench/session.mjs without a duplicated hardcoded list. +const SCENARIOS = Object.keys(runSession(lengths[0]).scenarios) + +function aggregate (values) { + const sorted = [...values].sort((a, b) => a - b) + const n = sorted.length + const mean = sorted.reduce((a, b) => a + b, 0) / n + const variance = sorted.reduce((a, b) => a + (b - mean) ** 2, 0) / n + const median = n % 2 + ? sorted[(n - 1) / 2] + : (sorted[n / 2 - 1] + sorted[n / 2]) / 2 + return { + mean, + median, + min: sorted[0], + max: sorted[n - 1], + sd: Math.sqrt(variance), + sdPct: (Math.sqrt(variance) / mean) * 100 + } +} + +const meta = { + runId, + timestamp: new Date().toISOString(), + node: process.version, + platform: process.platform, + arch: process.arch, + sessions, + lengths, + scenarios: SCENARIOS, + baseline: 'bench/v3-baseline.cjs (v3.0.2)', + candidate: 'package "vhost" entry (current build)' +} +writeFileSync(join(outDir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n') + +const summary = { meta, results: {} } +const mdLines = [ + `# Benchmark run ${runId}`, + '', + `- Node: ${process.version} · ${process.platform}/${process.arch}`, + `- Sessions per length: **${sessions}** (independent processes)`, + `- Baseline: \`bench/v3-baseline.cjs\` (v3.0.2) · Candidate: current build`, + '', + 'ns/op, lower is better. `speedup` = old mean ÷ new mean.', + '' +] + +for (const length of lengths) { + const runs = [] + for (let i = 0; i < sessions; i++) { + runs.push(runSession(length)) + process.stderr.write(`\r${length} iters: session ${i + 1}/${sessions} `) + } + process.stderr.write('\n') + + // Persist every raw session for this length. + writeFileSync( + join(outDir, `raw-${length}.json`), + JSON.stringify({ length, sessions, runs }, null, 2) + '\n' + ) + + summary.results[length] = {} + mdLines.push(`## ${length.toLocaleString()} iterations / scenario`, '') + mdLines.push('| scenario | old mean | new mean | new median | new min | new max | speedup | new ±sd% |') + mdLines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') + + for (const s of SCENARIOS) { + const oldAgg = aggregate(runs.map((r) => r.scenarios[s].old)) + const newAgg = aggregate(runs.map((r) => r.scenarios[s].new)) + const speedup = oldAgg.mean / newAgg.mean + summary.results[length][s] = { old: oldAgg, new: newAgg, speedup } + mdLines.push( + `| ${s} | ${oldAgg.mean.toFixed(1)} | ${newAgg.mean.toFixed(1)} | ${newAgg.median.toFixed(1)} | ` + + `${newAgg.min.toFixed(1)} | ${newAgg.max.toFixed(1)} | ${speedup.toFixed(2)}× | ${newAgg.sdPct.toFixed(1)}% |` + ) + } + mdLines.push('') +} + +writeFileSync(join(outDir, 'summary.json'), JSON.stringify(summary, null, 2) + '\n') +writeFileSync(join(outDir, 'summary.md'), mdLines.join('\n') + '\n') + +// Console echo of the summary tables. +console.log(mdLines.join('\n')) +console.log(`\nSaved to bench/results/${runId}/`) diff --git a/bench/experiments.mjs b/bench/experiments.mjs new file mode 100644 index 0000000..86b9544 --- /dev/null +++ b/bench/experiments.mjs @@ -0,0 +1,231 @@ +// Experiment: can the static-hostname match be made faster while staying +// byte-for-byte behavior-identical? Each variant builds the SAME anchored, +// case-insensitive regexp as the shipped code and only changes how a static +// (no-'*') hostname decides "match or not". Run: +// +// node bench/experiments.mjs +// +// Emits one JSON line of ns/op per variant. bench/run-experiments.mjs spawns +// sessions and aggregates. Correctness is asserted up front (cheap) so a +// behavior-breaking variant fails loudly instead of posting a fast-but-wrong +// number. + +import assert from 'node:assert' + +const length = Number(process.argv[2] || 1000000) + +const ASTERISK_REGEXP = /\*/g +const ASTERISK_REPLACE = '([^.]+)' +const END_ANCHORED_REGEXP = /(?:^|[^\\])(?:\\\\)*\$$/ +const ESCAPE_REGEXP = /([.+?^=!:${}()|[\]/\\])/g +const ESCAPE_REPLACE = '\\$1' + +function isregexp (val) { + return Object.prototype.toString.call(val) === '[object RegExp]' +} + +function isAscii (str) { + for (let i = 0; i < str.length; i++) { + if (str.charCodeAt(i) > 0x7f) return false + } + return true +} + +function hostregexp (val) { + let source = !isregexp(val) + ? String(val).replace(ESCAPE_REGEXP, ESCAPE_REPLACE).replace(ASTERISK_REGEXP, ASTERISK_REPLACE) + : val.source + if (source[0] !== '^') source = '^' + source + if (!END_ANCHORED_REGEXP.test(source)) source += '$' + return new RegExp(source, 'i') +} + +function hostnameof (host) { + if (!host) return undefined + const offset = host[0] === '[' ? host.indexOf(']') + 1 : 0 + const index = host.indexOf(':', offset) + return index !== -1 ? host.substring(0, index) : host +} + +// --- Variant V0: shipped behavior (regexp.test) ------------------------- +function makeV0 (hostname, handle) { + const regexp = hostregexp(hostname) + return function (req, res, next) { + const host = req.headers.host + if (!host) return next() + const name = hostnameof(host) + if (!name || !regexp.test(name)) return next() + const obj = Object.create(null) + obj.host = host + obj.hostname = name + obj.length = 0 + req.vhost = obj + handle(req, res, next) + } +} + +// --- Variant V1: `name === lowered` accept-prefilter, else regexp ------- +// Only ever ACCEPTS early; any non-equal input falls through to the identical +// regexp.test, so behavior is provably preserved. +function makeV1 (hostname, handle) { + const regexp = hostregexp(hostname) + const lowered = hostname.toLowerCase() + return function (req, res, next) { + const host = req.headers.host + if (!host) return next() + const name = hostnameof(host) + if (!name) return next() + if (name !== lowered && !regexp.test(name)) return next() + const obj = Object.create(null) + obj.host = host + obj.hostname = name + obj.length = 0 + req.vhost = obj + handle(req, res, next) + } +} + +// --- Variant V2: single-pass ASCII fold compare, regex fallback --------- +// Length mismatch is a proven non-match (simple folding is 1:1, no astral char +// folds to ASCII). Non-ASCII code unit at a pattern position -> defer to regex. +function makeV2 (hostname, handle) { + const regexp = hostregexp(hostname) + const lowered = hostname.toLowerCase() + const asciiPattern = isAscii(hostname) + const llen = lowered.length + + function matches (name) { + if (!asciiPattern) return regexp.test(name) + if (name.length !== llen) return false + for (let i = 0; i < llen; i++) { + let c = name.charCodeAt(i) + if (c > 127) return regexp.test(name) // could fold to ASCII (e.g. Kelvin) + if (c >= 65 && c <= 90) c += 32 + if (c !== lowered.charCodeAt(i)) return false + } + return true + } + + return function (req, res, next) { + const host = req.headers.host + if (!host) return next() + const name = hostnameof(host) + if (!name || !matches(name)) return next() + const obj = Object.create(null) + obj.host = host + obj.hostname = name + obj.length = 0 + req.vhost = obj + handle(req, res, next) + } +} + +// --- Variant V3: ASCII-gated length-check + exact-equal + regex fallback - +// Combines V1's lowercase-hit win and V2's no-match win without V2's per-char +// loop on the matching path. ALL shortcuts gated on an ASCII pattern: only then +// is simple case folding guaranteed length-preserving (no `İ`->`i̇`, no astral +// folds) and is `name === lowered` a safe accept. Non-ASCII patterns defer +// wholly to regexp.test. +function makeV3 (hostname, handle) { + const regexp = hostregexp(hostname) + const asciiPattern = isAscii(hostname) + const lowered = asciiPattern ? hostname.toLowerCase() : null + const llen = lowered === null ? -1 : lowered.length + + function matches (name) { + if (!asciiPattern) return regexp.test(name) + if (name.length !== llen) return false // length-preserving fold => proven miss + if (name === lowered) return true // proven hit (ASCII, 1:1 fold) + return regexp.test(name) // mixed case: identical fallback + } + + return function (req, res, next) { + const host = req.headers.host + if (!host) return next() + const name = hostnameof(host) + if (!name || !matches(name)) return next() + const obj = Object.create(null) + obj.host = host + obj.hostname = name + obj.length = 0 + req.vhost = obj + handle(req, res, next) + } +} + +const VARIANTS = { V0: makeV0, V1: makeV1, V2: makeV2, V3: makeV3 } + +// --- Correctness gate: every variant must agree with V0 on a battery ----- +const KELVIN = String.fromCodePoint(0x212a) // U+212A folds to ASCII 'k' +const DOTLESS_I_UP = String.fromCodePoint(0x0130) // 'İ' .toLowerCase() => 'i̇' (2 chars!) +const CASES = [ + ['mail.example.com', 'mail.example.com', true], + ['mail.example.com', 'MAIL.EXAMPLE.COM', true], + ['mail.example.com', 'Mail.Example.Com', true], + ['mail.example.com', 'mail.example.com:8080', true], + ['mail.example.com', 'other.example.com', false], + ['mail.example.com', 'mail.example.co', false], + ['mail.example.com', 'mail.example.comX', false], + ['k.com', KELVIN + '.com', false], // Kelvin must NOT match ASCII k + ['k.com', 'k.com', true], + // Length-changing fold: an ungated `name===pattern.toLowerCase()` prefilter + // would build a 2-char `lowered` and could mis-handle this; the regex (and any + // length-gated variant) treats it as a non-match against the 1-char pattern. + ['i.com', DOTLESS_I_UP + '.com', false], + ['[::1]', '[::1]', true], + ['[::1]', '[::1]:8080', true], + ['a+b.com', 'a+b.com', true], + ['a+b.com', 'aaab.com', false], + ['café.example', 'café.example', true], // non-ASCII pattern + ['café.example', 'CAFÉ.example', true] // non-ASCII, case-insensitive via regex +] + +for (const [name, key] of Object.entries(VARIANTS)) { + for (const [pattern, host, expectMatch] of CASES) { + const req = { headers: { host } } + let handled = false + const mw = key(pattern, () => { handled = true }) + mw(req, {}, () => {}) + assert.strictEqual( + handled, expectMatch, + `${name}: pattern=${JSON.stringify(pattern)} host=${JSON.stringify(host)} expected match=${expectMatch}` + ) + } +} + +// --- Timing ------------------------------------------------------------ +const res = {} +const noop = () => {} +const handle = () => {} +const reqWith = (host) => ({ headers: { host } }) + +// Two representative request shapes for the static path: an already-lowercase +// host (the realistic common case) and a mixed-case host (regex still needed). +const SCENARIOS = { + 'static lower hit': (mk) => [mk('mail.example.com', handle), reqWith('mail.example.com')], + 'static mixed hit': (mk) => [mk('mail.example.com', handle), reqWith('MAIL.example.com')], + 'static no-match': (mk) => [mk('mail.example.com', handle), reqWith('other.example.com')] +} + +function timeOne (mw, req) { + const t0 = process.hrtime.bigint() + for (let i = 0; i < length; i++) { + req.vhost = undefined + mw(req, res, noop) + } + return Number(process.hrtime.bigint() - t0) / length +} + +// warm +for (const make of Object.values(VARIANTS)) { + for (const build of Object.values(SCENARIOS)) timeOne(...build(make)) +} + +const out = { length, results: {} } +for (const [vname, make] of Object.entries(VARIANTS)) { + out.results[vname] = {} + for (const [sname, build] of Object.entries(SCENARIOS)) { + out.results[vname][sname] = timeOne(...build(make)) + } +} +process.stdout.write(JSON.stringify(out) + '\n') diff --git a/bench/index.mjs b/bench/index.mjs new file mode 100644 index 0000000..53cefde --- /dev/null +++ b/bench/index.mjs @@ -0,0 +1,68 @@ +import { Bench } from 'tinybench' +import vhost from 'vhost' + +// A no-op response object and next; handlers must not do real I/O so we +// measure only the middleware's matching + req.vhost population cost. +const res = {} +function noop () {} +function handle () {} + +// Build a fresh request per scenario. Reusing one object is fine because the +// middleware only reads req.headers.host and writes req.vhost. +function reqWith (host) { + return { headers: { host } } +} + +// Middlewares under test. +const staticMw = vhost('mail.example.com', handle) +const wildcardMw = vhost('*.example.com', handle) +const regexpMw = vhost(/user-(bob|joe)\.([^.]+)\.example\.com/, handle) + +// Pre-built requests. +const staticHit = reqWith('mail.example.com') +const staticMiss = reqWith('other.example.com') +const wildcardHit = reqWith('foo.example.com') +const regexpHit = reqWith('user-bob.team.example.com') +const noHost = reqWith(undefined) + +const bench = new Bench({ time: 1000, warmupTime: 200, warmupIterations: 100 }) + +bench + .add('static match', () => { + staticHit.vhost = undefined + staticMw(staticHit, res, noop) + }) + .add('static no-match', () => { + staticMiss.vhost = undefined + staticMw(staticMiss, res, noop) + }) + .add('wildcard match', () => { + wildcardHit.vhost = undefined + wildcardMw(wildcardHit, res, noop) + }) + .add('regexp match', () => { + regexpHit.vhost = undefined + regexpMw(regexpHit, res, noop) + }) + .add('no Host header', () => { + noHost.vhost = undefined + staticMw(noHost, res, noop) + }) + +await bench.run() + +const rows = bench.tasks.map((t) => ({ + scenario: t.name, + 'ops/sec': Math.round(t.result.hz).toLocaleString('en-US'), + 'ns/op': (t.result.mean * 1e6).toFixed(1), + samples: t.result.samples.length +})) + +console.table(rows) + +// Emit a machine-readable line so a wrapper can capture results if desired. +console.log('\nJSON:' + JSON.stringify(bench.tasks.map((t) => ({ + scenario: t.name, + hz: t.result.hz, + nsPerOp: t.result.mean * 1e6 +})))) diff --git a/bench/results/2026-06-13T07-13-20-615Z/meta.json b/bench/results/2026-06-13T07-13-20-615Z/meta.json new file mode 100644 index 0000000..321cd06 --- /dev/null +++ b/bench/results/2026-06-13T07-13-20-615Z/meta.json @@ -0,0 +1,26 @@ +{ + "runId": "2026-06-13T07-13-20-615Z", + "timestamp": "2026-06-13T07:13:20.688Z", + "node": "v24.15.0", + "platform": "darwin", + "arch": "arm64", + "sessions": 25, + "lengths": [ + 10000, + 100000, + 1000000 + ], + "scenarios": [ + "static match", + "static no-match", + "wildcard match", + "wildcard match (prefix)", + "wildcard no-match (short)", + "wildcard no-match (suffix)", + "multi-star match", + "regexp match", + "no Host" + ], + "baseline": "bench/v3-baseline.cjs (v3.0.2)", + "candidate": "package \"vhost\" entry (current build)" +} diff --git a/bench/results/2026-06-13T07-13-20-615Z/raw-10000.json b/bench/results/2026-06-13T07-13-20-615Z/raw-10000.json new file mode 100644 index 0000000..989f386 --- /dev/null +++ b/bench/results/2026-06-13T07-13-20-615Z/raw-10000.json @@ -0,0 +1,1031 @@ +{ + "length": 10000, + "sessions": 25, + "runs": [ + { + "length": 10000, + "scenarios": { + "static match": { + "old": 87.2542, + "new": 72.5667 + }, + "static no-match": { + "old": 27.9916, + "new": 22.9875 + }, + "wildcard match": { + "old": 161.7667, + "new": 152.5459 + }, + "wildcard match (prefix)": { + "old": 152.9958, + "new": 151.2375 + }, + "wildcard no-match (short)": { + "old": 28.5791, + "new": 12.6833 + }, + "wildcard no-match (suffix)": { + "old": 32.7042, + "new": 32.3916 + }, + "multi-star match": { + "old": 161.6208, + "new": 162.65 + }, + "regexp match": { + "old": 168.0417, + "new": 182.55 + }, + "no Host": { + "old": 7.0792, + "new": 8.4125 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 83.0708, + "new": 74.9042 + }, + "static no-match": { + "old": 28.6042, + "new": 23.05 + }, + "wildcard match": { + "old": 158.9084, + "new": 149.4625 + }, + "wildcard match (prefix)": { + "old": 165.6375, + "new": 152.6167 + }, + "wildcard no-match (short)": { + "old": 29.2667, + "new": 12.6084 + }, + "wildcard no-match (suffix)": { + "old": 33.1667, + "new": 60.8792 + }, + "multi-star match": { + "old": 167.2542, + "new": 182.0333 + }, + "regexp match": { + "old": 170.2959, + "new": 175.5292 + }, + "no Host": { + "old": 7.0291, + "new": 5.3334 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 82.8458, + "new": 69.7917 + }, + "static no-match": { + "old": 29.0084, + "new": 22.2958 + }, + "wildcard match": { + "old": 151.2459, + "new": 144.5792 + }, + "wildcard match (prefix)": { + "old": 152.9625, + "new": 145.9917 + }, + "wildcard no-match (short)": { + "old": 28.9709, + "new": 12.7334 + }, + "wildcard no-match (suffix)": { + "old": 34.45, + "new": 35.4083 + }, + "multi-star match": { + "old": 168.3083, + "new": 160.7208 + }, + "regexp match": { + "old": 168.1875, + "new": 175.3625 + }, + "no Host": { + "old": 7.05, + "new": 7.8875 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 79.0292, + "new": 68.0667 + }, + "static no-match": { + "old": 28.3584, + "new": 22.275 + }, + "wildcard match": { + "old": 149.7208, + "new": 144.5625 + }, + "wildcard match (prefix)": { + "old": 149.2167, + "new": 146.6 + }, + "wildcard no-match (short)": { + "old": 28.7333, + "new": 12.5708 + }, + "wildcard no-match (suffix)": { + "old": 32.7083, + "new": 32.6708 + }, + "multi-star match": { + "old": 164.7292, + "new": 163.1416 + }, + "regexp match": { + "old": 170.5959, + "new": 191.8459 + }, + "no Host": { + "old": 7.05, + "new": 5.6792 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 82.4833, + "new": 71.2709 + }, + "static no-match": { + "old": 29.35, + "new": 23.05 + }, + "wildcard match": { + "old": 161.4042, + "new": 144.7709 + }, + "wildcard match (prefix)": { + "old": 148.3167, + "new": 147.2291 + }, + "wildcard no-match (short)": { + "old": 28.6709, + "new": 12.6042 + }, + "wildcard no-match (suffix)": { + "old": 32.7, + "new": 32.375 + }, + "multi-star match": { + "old": 162.3125, + "new": 162.2 + }, + "regexp match": { + "old": 165.9625, + "new": 183.25 + }, + "no Host": { + "old": 6.8375, + "new": 7.7542 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 78.7917, + "new": 67.6459 + }, + "static no-match": { + "old": 28.55, + "new": 22.8667 + }, + "wildcard match": { + "old": 154.625, + "new": 149.2875 + }, + "wildcard match (prefix)": { + "old": 162.0458, + "new": 169.8791 + }, + "wildcard no-match (short)": { + "old": 31.0041, + "new": 13.8333 + }, + "wildcard no-match (suffix)": { + "old": 35.4041, + "new": 34.5375 + }, + "multi-star match": { + "old": 182.3791, + "new": 172.8916 + }, + "regexp match": { + "old": 172.2875, + "new": 177.0125 + }, + "no Host": { + "old": 6.9916, + "new": 8.0917 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 78.5, + "new": 67.8167 + }, + "static no-match": { + "old": 28.0666, + "new": 22.2334 + }, + "wildcard match": { + "old": 148.5583, + "new": 142.8667 + }, + "wildcard match (prefix)": { + "old": 145.7708, + "new": 145.0667 + }, + "wildcard no-match (short)": { + "old": 29.55, + "new": 12.6625 + }, + "wildcard no-match (suffix)": { + "old": 32.6917, + "new": 32.3209 + }, + "multi-star match": { + "old": 158.1625, + "new": 156.5208 + }, + "regexp match": { + "old": 160.975, + "new": 167.1708 + }, + "no Host": { + "old": 8.2292, + "new": 5.4375 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 81.5167, + "new": 69.9292 + }, + "static no-match": { + "old": 27.9167, + "new": 23.3209 + }, + "wildcard match": { + "old": 154.9375, + "new": 144.8 + }, + "wildcard match (prefix)": { + "old": 151.5208, + "new": 149.925 + }, + "wildcard no-match (short)": { + "old": 28.6125, + "new": 12.6041 + }, + "wildcard no-match (suffix)": { + "old": 33.8333, + "new": 32.5375 + }, + "multi-star match": { + "old": 161.8041, + "new": 159.9 + }, + "regexp match": { + "old": 165.6292, + "new": 174.6792 + }, + "no Host": { + "old": 7.0042, + "new": 7.7167 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 86.0791, + "new": 70.8625 + }, + "static no-match": { + "old": 28.5542, + "new": 22.2916 + }, + "wildcard match": { + "old": 153.7166, + "new": 144.8291 + }, + "wildcard match (prefix)": { + "old": 159.1792, + "new": 151.8833 + }, + "wildcard no-match (short)": { + "old": 30.4459, + "new": 12.7542 + }, + "wildcard no-match (suffix)": { + "old": 34.5625, + "new": 35.2667 + }, + "multi-star match": { + "old": 164.8958, + "new": 161.7041 + }, + "regexp match": { + "old": 169.7, + "new": 182.6666 + }, + "no Host": { + "old": 6.7791, + "new": 5.4458 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 80.7208, + "new": 69.5708 + }, + "static no-match": { + "old": 28.1583, + "new": 22.2542 + }, + "wildcard match": { + "old": 151.475, + "new": 146.6417 + }, + "wildcard match (prefix)": { + "old": 152.875, + "new": 148.575 + }, + "wildcard no-match (short)": { + "old": 29.1041, + "new": 12.9 + }, + "wildcard no-match (suffix)": { + "old": 33.2041, + "new": 32.925 + }, + "multi-star match": { + "old": 167.9583, + "new": 160.6292 + }, + "regexp match": { + "old": 163.8167, + "new": 170.2875 + }, + "no Host": { + "old": 6.7458, + "new": 5.4334 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 81.225, + "new": 69.4125 + }, + "static no-match": { + "old": 28.6667, + "new": 22.5542 + }, + "wildcard match": { + "old": 150.1625, + "new": 144.5459 + }, + "wildcard match (prefix)": { + "old": 148.6917, + "new": 145.7542 + }, + "wildcard no-match (short)": { + "old": 28.6209, + "new": 12.6334 + }, + "wildcard no-match (suffix)": { + "old": 32.6958, + "new": 32.6958 + }, + "multi-star match": { + "old": 158.8, + "new": 168.075 + }, + "regexp match": { + "old": 166.3166, + "new": 173.6625 + }, + "no Host": { + "old": 6.6292, + "new": 5.4 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 81.4333, + "new": 68.4375 + }, + "static no-match": { + "old": 28.8583, + "new": 22.3 + }, + "wildcard match": { + "old": 149.9125, + "new": 143.8458 + }, + "wildcard match (prefix)": { + "old": 147.4458, + "new": 145.9125 + }, + "wildcard no-match (short)": { + "old": 28.9208, + "new": 12.8166 + }, + "wildcard no-match (suffix)": { + "old": 32.7625, + "new": 32.3916 + }, + "multi-star match": { + "old": 159.3208, + "new": 158.4583 + }, + "regexp match": { + "old": 161.6042, + "new": 170.0083 + }, + "no Host": { + "old": 8.6375, + "new": 5.3625 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 78.45, + "new": 66.6083 + }, + "static no-match": { + "old": 28.0458, + "new": 22.2584 + }, + "wildcard match": { + "old": 149.85, + "new": 142.4375 + }, + "wildcard match (prefix)": { + "old": 147.7542, + "new": 153.1834 + }, + "wildcard no-match (short)": { + "old": 30.3333, + "new": 13.35 + }, + "wildcard no-match (suffix)": { + "old": 33.2292, + "new": 33.3917 + }, + "multi-star match": { + "old": 160.8709, + "new": 156.9 + }, + "regexp match": { + "old": 162.0666, + "new": 173.6208 + }, + "no Host": { + "old": 7, + "new": 5.6209 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 84.3375, + "new": 73 + }, + "static no-match": { + "old": 29.7333, + "new": 23.7292 + }, + "wildcard match": { + "old": 180.55, + "new": 150.775 + }, + "wildcard match (prefix)": { + "old": 153.3125, + "new": 147.9708 + }, + "wildcard no-match (short)": { + "old": 28.9208, + "new": 12.6542 + }, + "wildcard no-match (suffix)": { + "old": 33.6042, + "new": 33.525 + }, + "multi-star match": { + "old": 165.7083, + "new": 161.2833 + }, + "regexp match": { + "old": 166.175, + "new": 179.8375 + }, + "no Host": { + "old": 6.8167, + "new": 7.3042 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 82.1625, + "new": 75.9375 + }, + "static no-match": { + "old": 28.0375, + "new": 22.4708 + }, + "wildcard match": { + "old": 155.6, + "new": 147.9584 + }, + "wildcard match (prefix)": { + "old": 149.7083, + "new": 148.6833 + }, + "wildcard no-match (short)": { + "old": 31.2417, + "new": 13.0167 + }, + "wildcard no-match (suffix)": { + "old": 35.6375, + "new": 60.4208 + }, + "multi-star match": { + "old": 164, + "new": 184.9666 + }, + "regexp match": { + "old": 165.3667, + "new": 169.8959 + }, + "no Host": { + "old": 6.9, + "new": 5.3125 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 79.7708, + "new": 69.8166 + }, + "static no-match": { + "old": 27.9125, + "new": 22.7666 + }, + "wildcard match": { + "old": 151.5542, + "new": 145.8625 + }, + "wildcard match (prefix)": { + "old": 147.9167, + "new": 146.5916 + }, + "wildcard no-match (short)": { + "old": 29.675, + "new": 13.0333 + }, + "wildcard no-match (suffix)": { + "old": 33.4708, + "new": 33.4833 + }, + "multi-star match": { + "old": 163.2375, + "new": 161.2292 + }, + "regexp match": { + "old": 168.15, + "new": 176.425 + }, + "no Host": { + "old": 7.0125, + "new": 8.1125 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 86.9042, + "new": 74.2916 + }, + "static no-match": { + "old": 28.8125, + "new": 22.3584 + }, + "wildcard match": { + "old": 152.9208, + "new": 156.6875 + }, + "wildcard match (prefix)": { + "old": 157.0208, + "new": 154.0792 + }, + "wildcard no-match (short)": { + "old": 28.6791, + "new": 12.7 + }, + "wildcard no-match (suffix)": { + "old": 33.8833, + "new": 32.6958 + }, + "multi-star match": { + "old": 163.4625, + "new": 168.3416 + }, + "regexp match": { + "old": 164.7375, + "new": 175.4 + }, + "no Host": { + "old": 6.8584, + "new": 5.6333 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 85.6083, + "new": 68.4042 + }, + "static no-match": { + "old": 29.3083, + "new": 22.8417 + }, + "wildcard match": { + "old": 153.7208, + "new": 144.2458 + }, + "wildcard match (prefix)": { + "old": 147.0209, + "new": 146.0417 + }, + "wildcard no-match (short)": { + "old": 30.3167, + "new": 13.3667 + }, + "wildcard no-match (suffix)": { + "old": 33.6458, + "new": 33.025 + }, + "multi-star match": { + "old": 162.6167, + "new": 159.7333 + }, + "regexp match": { + "old": 166.075, + "new": 185.4792 + }, + "no Host": { + "old": 7.2959, + "new": 5.8458 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 80.7208, + "new": 70.4667 + }, + "static no-match": { + "old": 29.3625, + "new": 23.2625 + }, + "wildcard match": { + "old": 165.9708, + "new": 145.675 + }, + "wildcard match (prefix)": { + "old": 149.8334, + "new": 146.3834 + }, + "wildcard no-match (short)": { + "old": 28.6833, + "new": 12.6042 + }, + "wildcard no-match (suffix)": { + "old": 33.4792, + "new": 32.3583 + }, + "multi-star match": { + "old": 161.025, + "new": 162.2625 + }, + "regexp match": { + "old": 163.3, + "new": 177.0375 + }, + "no Host": { + "old": 6.7541, + "new": 5.4209 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 81.2208, + "new": 70.5708 + }, + "static no-match": { + "old": 28.7875, + "new": 23.0875 + }, + "wildcard match": { + "old": 158.0416, + "new": 144.9542 + }, + "wildcard match (prefix)": { + "old": 149.4542, + "new": 146.9292 + }, + "wildcard no-match (short)": { + "old": 28.65, + "new": 12.5792 + }, + "wildcard no-match (suffix)": { + "old": 32.7083, + "new": 32.375 + }, + "multi-star match": { + "old": 159.9792, + "new": 166.4125 + }, + "regexp match": { + "old": 166.7541, + "new": 175.0917 + }, + "no Host": { + "old": 7, + "new": 5.525 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 83.4625, + "new": 73.6375 + }, + "static no-match": { + "old": 29.0125, + "new": 23.05 + }, + "wildcard match": { + "old": 159.5417, + "new": 148.1792 + }, + "wildcard match (prefix)": { + "old": 151.9, + "new": 161.525 + }, + "wildcard no-match (short)": { + "old": 29.1416, + "new": 12.875 + }, + "wildcard no-match (suffix)": { + "old": 33.2125, + "new": 32.4625 + }, + "multi-star match": { + "old": 165.5708, + "new": 161.9917 + }, + "regexp match": { + "old": 167.8, + "new": 194.8666 + }, + "no Host": { + "old": 6.8375, + "new": 5.4416 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 80.9417, + "new": 68.7125 + }, + "static no-match": { + "old": 33.5834, + "new": 22.8334 + }, + "wildcard match": { + "old": 152.5542, + "new": 142.8375 + }, + "wildcard match (prefix)": { + "old": 146.9709, + "new": 152.4917 + }, + "wildcard no-match (short)": { + "old": 28.5792, + "new": 12.5167 + }, + "wildcard no-match (suffix)": { + "old": 32.7042, + "new": 32.3875 + }, + "multi-star match": { + "old": 158.2041, + "new": 162.85 + }, + "regexp match": { + "old": 171.7834, + "new": 178.05 + }, + "no Host": { + "old": 7.0333, + "new": 5.6333 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 83.9709, + "new": 67.725 + }, + "static no-match": { + "old": 27.925, + "new": 22.775 + }, + "wildcard match": { + "old": 158.3125, + "new": 159.5458 + }, + "wildcard match (prefix)": { + "old": 150.725, + "new": 147.6542 + }, + "wildcard no-match (short)": { + "old": 29.2792, + "new": 12.9167 + }, + "wildcard no-match (suffix)": { + "old": 33.4291, + "new": 33.0792 + }, + "multi-star match": { + "old": 165.9917, + "new": 167.4625 + }, + "regexp match": { + "old": 168.3167, + "new": 177.9833 + }, + "no Host": { + "old": 6.7375, + "new": 5.4042 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 80.4667, + "new": 69.9666 + }, + "static no-match": { + "old": 28.8542, + "new": 22.375 + }, + "wildcard match": { + "old": 151.4958, + "new": 146.7625 + }, + "wildcard match (prefix)": { + "old": 148.9417, + "new": 146.625 + }, + "wildcard no-match (short)": { + "old": 28.5917, + "new": 12.725 + }, + "wildcard no-match (suffix)": { + "old": 32.7, + "new": 32.3334 + }, + "multi-star match": { + "old": 159.9041, + "new": 160.9583 + }, + "regexp match": { + "old": 163.8208, + "new": 174.3125 + }, + "no Host": { + "old": 8.25, + "new": 5.3417 + } + } + }, + { + "length": 10000, + "scenarios": { + "static match": { + "old": 80.5083, + "new": 68.7708 + }, + "static no-match": { + "old": 28.2417, + "new": 22.225 + }, + "wildcard match": { + "old": 149.5958, + "new": 144.8584 + }, + "wildcard match (prefix)": { + "old": 149.7167, + "new": 147.0208 + }, + "wildcard no-match (short)": { + "old": 29.0625, + "new": 12.7042 + }, + "wildcard no-match (suffix)": { + "old": 33.1583, + "new": 32.4083 + }, + "multi-star match": { + "old": 158.9417, + "new": 160.9792 + }, + "regexp match": { + "old": 169.9709, + "new": 175.7583 + }, + "no Host": { + "old": 6.7708, + "new": 6.675 + } + } + } + ] +} diff --git a/bench/results/2026-06-13T07-13-20-615Z/raw-100000.json b/bench/results/2026-06-13T07-13-20-615Z/raw-100000.json new file mode 100644 index 0000000..fb0b19b --- /dev/null +++ b/bench/results/2026-06-13T07-13-20-615Z/raw-100000.json @@ -0,0 +1,1031 @@ +{ + "length": 100000, + "sessions": 25, + "runs": [ + { + "length": 100000, + "scenarios": { + "static match": { + "old": 84.3575, + "new": 60.06333 + }, + "static no-match": { + "old": 27.68583, + "new": 14.23125 + }, + "wildcard match": { + "old": 147.46375, + "new": 146.88083 + }, + "wildcard match (prefix)": { + "old": 147.8625, + "new": 153.3525 + }, + "wildcard no-match (short)": { + "old": 29.98125, + "new": 13.58125 + }, + "wildcard no-match (suffix)": { + "old": 34.16292, + "new": 33.07833 + }, + "multi-star match": { + "old": 163.54458, + "new": 163.0975 + }, + "regexp match": { + "old": 162.94625, + "new": 162.16791 + }, + "no Host": { + "old": 7.35416, + "new": 5.81041 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 80.98917, + "new": 58.66792 + }, + "static no-match": { + "old": 27.27167, + "new": 13.99958 + }, + "wildcard match": { + "old": 146.76625, + "new": 146.04375 + }, + "wildcard match (prefix)": { + "old": 157.47792, + "new": 150.78292 + }, + "wildcard no-match (short)": { + "old": 29.74958, + "new": 13.54542 + }, + "wildcard no-match (suffix)": { + "old": 33.69125, + "new": 33.25292 + }, + "multi-star match": { + "old": 162.77958, + "new": 160.84708 + }, + "regexp match": { + "old": 164.47958, + "new": 161.51125 + }, + "no Host": { + "old": 7.2325, + "new": 5.84042 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 85.75042, + "new": 63.70625 + }, + "static no-match": { + "old": 27.0825, + "new": 13.93125 + }, + "wildcard match": { + "old": 149.715, + "new": 151.05541 + }, + "wildcard match (prefix)": { + "old": 152.37834, + "new": 152.18959 + }, + "wildcard no-match (short)": { + "old": 29.00833, + "new": 13.16333 + }, + "wildcard no-match (suffix)": { + "old": 33.05625, + "new": 32.73958 + }, + "multi-star match": { + "old": 163.38458, + "new": 165.91042 + }, + "regexp match": { + "old": 167.54791, + "new": 170.62208 + }, + "no Host": { + "old": 7.57167, + "new": 6.08041 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 85.07209, + "new": 63.215 + }, + "static no-match": { + "old": 27.85166, + "new": 14.20375 + }, + "wildcard match": { + "old": 160.36958, + "new": 151.21 + }, + "wildcard match (prefix)": { + "old": 149.45583, + "new": 147.16375 + }, + "wildcard no-match (short)": { + "old": 29.11375, + "new": 13.44458 + }, + "wildcard no-match (suffix)": { + "old": 33.49792, + "new": 32.79167 + }, + "multi-star match": { + "old": 159.92334, + "new": 163.78667 + }, + "regexp match": { + "old": 166.69584, + "new": 163.98958 + }, + "no Host": { + "old": 7.1925, + "new": 5.79 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 82.73458, + "new": 59.20416 + }, + "static no-match": { + "old": 27.075, + "new": 14.4375 + }, + "wildcard match": { + "old": 146.26375, + "new": 150.14417 + }, + "wildcard match (prefix)": { + "old": 149.64708, + "new": 147.33959 + }, + "wildcard no-match (short)": { + "old": 30.30459, + "new": 13.46958 + }, + "wildcard no-match (suffix)": { + "old": 33.09417, + "new": 32.995 + }, + "multi-star match": { + "old": 162.4275, + "new": 160.56625 + }, + "regexp match": { + "old": 166.82417, + "new": 165.43375 + }, + "no Host": { + "old": 7.31125, + "new": 5.87584 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 87.69875, + "new": 60.78083 + }, + "static no-match": { + "old": 27.43125, + "new": 14.54125 + }, + "wildcard match": { + "old": 147.165, + "new": 149.34166 + }, + "wildcard match (prefix)": { + "old": 149.51, + "new": 148.90375 + }, + "wildcard no-match (short)": { + "old": 29.10542, + "new": 13.19958 + }, + "wildcard no-match (suffix)": { + "old": 33.08834, + "new": 33.38958 + }, + "multi-star match": { + "old": 162.32667, + "new": 163.62084 + }, + "regexp match": { + "old": 164.52833, + "new": 165.18084 + }, + "no Host": { + "old": 7.22333, + "new": 5.90917 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 84.51875, + "new": 64.09792 + }, + "static no-match": { + "old": 26.90875, + "new": 14.21625 + }, + "wildcard match": { + "old": 148.40292, + "new": 148.64791 + }, + "wildcard match (prefix)": { + "old": 152.32041, + "new": 151.25917 + }, + "wildcard no-match (short)": { + "old": 29.27083, + "new": 14.79083 + }, + "wildcard no-match (suffix)": { + "old": 34.12458, + "new": 32.895 + }, + "multi-star match": { + "old": 162.95958, + "new": 160.72792 + }, + "regexp match": { + "old": 166.50708, + "new": 164.96167 + }, + "no Host": { + "old": 7.29833, + "new": 5.78667 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 83.32875, + "new": 61.00666 + }, + "static no-match": { + "old": 27.65834, + "new": 13.9075 + }, + "wildcard match": { + "old": 147.77833, + "new": 149.62833 + }, + "wildcard match (prefix)": { + "old": 152.14833, + "new": 151.52375 + }, + "wildcard no-match (short)": { + "old": 29.14375, + "new": 13.52959 + }, + "wildcard no-match (suffix)": { + "old": 33.37667, + "new": 33.18209 + }, + "multi-star match": { + "old": 161.70583, + "new": 161.70292 + }, + "regexp match": { + "old": 170.15167, + "new": 166.29375 + }, + "no Host": { + "old": 7.2475, + "new": 5.79291 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 83.54083, + "new": 61.36458 + }, + "static no-match": { + "old": 26.93917, + "new": 13.8475 + }, + "wildcard match": { + "old": 150.2625, + "new": 150.93167 + }, + "wildcard match (prefix)": { + "old": 150.15375, + "new": 154.32584 + }, + "wildcard no-match (short)": { + "old": 29.4275, + "new": 13.82541 + }, + "wildcard no-match (suffix)": { + "old": 33.42167, + "new": 33.31875 + }, + "multi-star match": { + "old": 163.13625, + "new": 162.83292 + }, + "regexp match": { + "old": 166.02625, + "new": 168.54167 + }, + "no Host": { + "old": 7.27958, + "new": 5.93167 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 88.98917, + "new": 64.65791 + }, + "static no-match": { + "old": 28.97083, + "new": 17.64833 + }, + "wildcard match": { + "old": 156.33083, + "new": 153.87042 + }, + "wildcard match (prefix)": { + "old": 165.64375, + "new": 173.01875 + }, + "wildcard no-match (short)": { + "old": 36.32417, + "new": 14.14875 + }, + "wildcard no-match (suffix)": { + "old": 35.58208, + "new": 36.15084 + }, + "multi-star match": { + "old": 175.58041, + "new": 172.70375 + }, + "regexp match": { + "old": 173.26667, + "new": 174.70708 + }, + "no Host": { + "old": 7.61125, + "new": 6.09583 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 84.55791, + "new": 59.80833 + }, + "static no-match": { + "old": 27.56709, + "new": 14.26125 + }, + "wildcard match": { + "old": 150.5275, + "new": 149.52583 + }, + "wildcard match (prefix)": { + "old": 150.23792, + "new": 150.785 + }, + "wildcard no-match (short)": { + "old": 29.58208, + "new": 13.22334 + }, + "wildcard no-match (suffix)": { + "old": 33.26625, + "new": 33.32083 + }, + "multi-star match": { + "old": 159.43042, + "new": 161.55167 + }, + "regexp match": { + "old": 162.98792, + "new": 161.22375 + }, + "no Host": { + "old": 7.17875, + "new": 5.82208 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 85.71333, + "new": 61.35 + }, + "static no-match": { + "old": 27.78791, + "new": 13.89416 + }, + "wildcard match": { + "old": 150.01208, + "new": 149.67833 + }, + "wildcard match (prefix)": { + "old": 149.91083, + "new": 152.47375 + }, + "wildcard no-match (short)": { + "old": 29.17834, + "new": 13.36 + }, + "wildcard no-match (suffix)": { + "old": 33.2575, + "new": 33.63083 + }, + "multi-star match": { + "old": 161.21958, + "new": 164.08375 + }, + "regexp match": { + "old": 164.68833, + "new": 166.40292 + }, + "no Host": { + "old": 7.37459, + "new": 5.96167 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 84.31167, + "new": 61.03916 + }, + "static no-match": { + "old": 27.33875, + "new": 14.14875 + }, + "wildcard match": { + "old": 149.00292, + "new": 148.085 + }, + "wildcard match (prefix)": { + "old": 151.02708, + "new": 150.33625 + }, + "wildcard no-match (short)": { + "old": 30.40542, + "new": 13.44125 + }, + "wildcard no-match (suffix)": { + "old": 33.8925, + "new": 33.07375 + }, + "multi-star match": { + "old": 163.56291, + "new": 164.21459 + }, + "regexp match": { + "old": 169.30334, + "new": 164.34375 + }, + "no Host": { + "old": 7.27375, + "new": 5.87625 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 84.08583, + "new": 59.50458 + }, + "static no-match": { + "old": 27.225, + "new": 14.00625 + }, + "wildcard match": { + "old": 145.52583, + "new": 146.795 + }, + "wildcard match (prefix)": { + "old": 146.76084, + "new": 146.35625 + }, + "wildcard no-match (short)": { + "old": 29.08625, + "new": 13.20375 + }, + "wildcard no-match (suffix)": { + "old": 33.31084, + "new": 32.76459 + }, + "multi-star match": { + "old": 157.675, + "new": 162.47959 + }, + "regexp match": { + "old": 164.17292, + "new": 164.94834 + }, + "no Host": { + "old": 7.40458, + "new": 6.01584 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 82.97292, + "new": 61.21833 + }, + "static no-match": { + "old": 27.81125, + "new": 14.24584 + }, + "wildcard match": { + "old": 148.355, + "new": 147.60125 + }, + "wildcard match (prefix)": { + "old": 148.75, + "new": 148.23709 + }, + "wildcard no-match (short)": { + "old": 29.14541, + "new": 13.26625 + }, + "wildcard no-match (suffix)": { + "old": 34.02125, + "new": 33.365 + }, + "multi-star match": { + "old": 164.79375, + "new": 164.12 + }, + "regexp match": { + "old": 165.23459, + "new": 167.5375 + }, + "no Host": { + "old": 7.18125, + "new": 5.78834 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 84.21375, + "new": 62.62875 + }, + "static no-match": { + "old": 27.22542, + "new": 14.32167 + }, + "wildcard match": { + "old": 149.82959, + "new": 146.01709 + }, + "wildcard match (prefix)": { + "old": 149.36875, + "new": 151.53375 + }, + "wildcard no-match (short)": { + "old": 29.0075, + "new": 13.1475 + }, + "wildcard no-match (suffix)": { + "old": 33.75208, + "new": 33.64625 + }, + "multi-star match": { + "old": 161.67583, + "new": 159.81292 + }, + "regexp match": { + "old": 166.11292, + "new": 164.85292 + }, + "no Host": { + "old": 7.17084, + "new": 5.82125 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 85.48959, + "new": 61.62291 + }, + "static no-match": { + "old": 27.09916, + "new": 14.04125 + }, + "wildcard match": { + "old": 146.73875, + "new": 150.72292 + }, + "wildcard match (prefix)": { + "old": 148.55542, + "new": 150.25292 + }, + "wildcard no-match (short)": { + "old": 29.50084, + "new": 13.09583 + }, + "wildcard no-match (suffix)": { + "old": 33.15083, + "new": 32.865 + }, + "multi-star match": { + "old": 160.56375, + "new": 162.04167 + }, + "regexp match": { + "old": 166.17209, + "new": 168.52083 + }, + "no Host": { + "old": 7.195, + "new": 5.985 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 86.28, + "new": 64.3125 + }, + "static no-match": { + "old": 27.125, + "new": 13.88292 + }, + "wildcard match": { + "old": 151.72583, + "new": 153.4625 + }, + "wildcard match (prefix)": { + "old": 153.74083, + "new": 153.98041 + }, + "wildcard no-match (short)": { + "old": 29.72292, + "new": 13.37375 + }, + "wildcard no-match (suffix)": { + "old": 34.54041, + "new": 33.88708 + }, + "multi-star match": { + "old": 179.41209, + "new": 163.835 + }, + "regexp match": { + "old": 167.68125, + "new": 168.29834 + }, + "no Host": { + "old": 7.29542, + "new": 5.79042 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 82.46709, + "new": 62.6 + }, + "static no-match": { + "old": 27.12666, + "new": 14.25209 + }, + "wildcard match": { + "old": 148.37375, + "new": 149.19833 + }, + "wildcard match (prefix)": { + "old": 149.4975, + "new": 151.4125 + }, + "wildcard no-match (short)": { + "old": 29.04042, + "new": 13.18041 + }, + "wildcard no-match (suffix)": { + "old": 33.21583, + "new": 33.17375 + }, + "multi-star match": { + "old": 160.61166, + "new": 160.42167 + }, + "regexp match": { + "old": 164.66, + "new": 165.34 + }, + "no Host": { + "old": 7.1625, + "new": 5.78958 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 85.98291, + "new": 65.48292 + }, + "static no-match": { + "old": 27.62791, + "new": 14.09166 + }, + "wildcard match": { + "old": 152.1975, + "new": 149.80416 + }, + "wildcard match (prefix)": { + "old": 151.51667, + "new": 151.64958 + }, + "wildcard no-match (short)": { + "old": 29.0375, + "new": 13.25083 + }, + "wildcard no-match (suffix)": { + "old": 33.30375, + "new": 33.05416 + }, + "multi-star match": { + "old": 163.06416, + "new": 163.64417 + }, + "regexp match": { + "old": 169.49917, + "new": 170.12042 + }, + "no Host": { + "old": 7.53458, + "new": 6.08792 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 84.67792, + "new": 62.75125 + }, + "static no-match": { + "old": 27.61375, + "new": 14.41125 + }, + "wildcard match": { + "old": 151.11625, + "new": 149.22917 + }, + "wildcard match (prefix)": { + "old": 151.26042, + "new": 150.425 + }, + "wildcard no-match (short)": { + "old": 29.15667, + "new": 13.295 + }, + "wildcard no-match (suffix)": { + "old": 33.21208, + "new": 32.8425 + }, + "multi-star match": { + "old": 164.355, + "new": 163.03958 + }, + "regexp match": { + "old": 170.73917, + "new": 165.44375 + }, + "no Host": { + "old": 7.24875, + "new": 5.87 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 81.97333, + "new": 59.59583 + }, + "static no-match": { + "old": 27.81292, + "new": 14.03292 + }, + "wildcard match": { + "old": 144.70792, + "new": 144.81125 + }, + "wildcard match (prefix)": { + "old": 146.50792, + "new": 150.4425 + }, + "wildcard no-match (short)": { + "old": 29.4325, + "new": 13.35084 + }, + "wildcard no-match (suffix)": { + "old": 33.76542, + "new": 32.82083 + }, + "multi-star match": { + "old": 160.41583, + "new": 167.41125 + }, + "regexp match": { + "old": 166.83375, + "new": 164.10958 + }, + "no Host": { + "old": 7.175, + "new": 5.77541 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 81.5625, + "new": 59.32208 + }, + "static no-match": { + "old": 27.49917, + "new": 14.40167 + }, + "wildcard match": { + "old": 145.71208, + "new": 146.28625 + }, + "wildcard match (prefix)": { + "old": 147.52833, + "new": 150.28833 + }, + "wildcard no-match (short)": { + "old": 28.98333, + "new": 13.25209 + }, + "wildcard no-match (suffix)": { + "old": 33.88167, + "new": 32.85208 + }, + "multi-star match": { + "old": 159.50791, + "new": 160.14292 + }, + "regexp match": { + "old": 165.21458, + "new": 164.5625 + }, + "no Host": { + "old": 7.21208, + "new": 6.005 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 88.63209, + "new": 66.15 + }, + "static no-match": { + "old": 28.01375, + "new": 14.24125 + }, + "wildcard match": { + "old": 160.99667, + "new": 159.8225 + }, + "wildcard match (prefix)": { + "old": 154.65291, + "new": 157.29 + }, + "wildcard no-match (short)": { + "old": 29.73792, + "new": 13.75625 + }, + "wildcard no-match (suffix)": { + "old": 33.25042, + "new": 33.05042 + }, + "multi-star match": { + "old": 163.6975, + "new": 167.24125 + }, + "regexp match": { + "old": 168.91167, + "new": 168.34875 + }, + "no Host": { + "old": 7.28792, + "new": 6.02667 + } + } + }, + { + "length": 100000, + "scenarios": { + "static match": { + "old": 82.40417, + "new": 59.60541 + }, + "static no-match": { + "old": 27.57875, + "new": 14.05708 + }, + "wildcard match": { + "old": 146.09416, + "new": 151.64208 + }, + "wildcard match (prefix)": { + "old": 149.66375, + "new": 148.13209 + }, + "wildcard no-match (short)": { + "old": 29.6175, + "new": 13.78375 + }, + "wildcard no-match (suffix)": { + "old": 33.84125, + "new": 33.51583 + }, + "multi-star match": { + "old": 161.30458, + "new": 159.64042 + }, + "regexp match": { + "old": 164.68375, + "new": 164.695 + }, + "no Host": { + "old": 7.72333, + "new": 5.92292 + } + } + } + ] +} diff --git a/bench/results/2026-06-13T07-13-20-615Z/raw-1000000.json b/bench/results/2026-06-13T07-13-20-615Z/raw-1000000.json new file mode 100644 index 0000000..1576640 --- /dev/null +++ b/bench/results/2026-06-13T07-13-20-615Z/raw-1000000.json @@ -0,0 +1,1031 @@ +{ + "length": 1000000, + "sessions": 25, + "runs": [ + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 109.752875, + "new": 61.435375 + }, + "static no-match": { + "old": 27.647292, + "new": 15.370042 + }, + "wildcard match": { + "old": 157.032041, + "new": 146.408 + }, + "wildcard match (prefix)": { + "old": 149.170917, + "new": 150.7805 + }, + "wildcard no-match (short)": { + "old": 30.592333, + "new": 14.166292 + }, + "wildcard no-match (suffix)": { + "old": 34.264875, + "new": 34.417541 + }, + "multi-star match": { + "old": 160.540875, + "new": 159.827042 + }, + "regexp match": { + "old": 163.643875, + "new": 166.00025 + }, + "no Host": { + "old": 7.222084, + "new": 5.878792 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 83.706125, + "new": 61.671667 + }, + "static no-match": { + "old": 27.187625, + "new": 14.040083 + }, + "wildcard match": { + "old": 147.511542, + "new": 148.910042 + }, + "wildcard match (prefix)": { + "old": 149.363583, + "new": 151.194959 + }, + "wildcard no-match (short)": { + "old": 29.349291, + "new": 13.317208 + }, + "wildcard no-match (suffix)": { + "old": 33.459583, + "new": 33.433792 + }, + "multi-star match": { + "old": 161.007875, + "new": 164.042333 + }, + "regexp match": { + "old": 165.324542, + "new": 164.688167 + }, + "no Host": { + "old": 7.33475, + "new": 5.932958 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 86.689083, + "new": 68.169166 + }, + "static no-match": { + "old": 28.088542, + "new": 14.693208 + }, + "wildcard match": { + "old": 240.751375, + "new": 151.723125 + }, + "wildcard match (prefix)": { + "old": 154.062416, + "new": 153.13525 + }, + "wildcard no-match (short)": { + "old": 29.515583, + "new": 13.537 + }, + "wildcard no-match (suffix)": { + "old": 33.808084, + "new": 33.2065 + }, + "multi-star match": { + "old": 161.744542, + "new": 163.7725 + }, + "regexp match": { + "old": 166.859958, + "new": 165.949458 + }, + "no Host": { + "old": 7.46625, + "new": 5.835542 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 81.621833, + "new": 59.836625 + }, + "static no-match": { + "old": 27.718333, + "new": 13.952667 + }, + "wildcard match": { + "old": 145.478417, + "new": 148.104709 + }, + "wildcard match (prefix)": { + "old": 147.848542, + "new": 149.732625 + }, + "wildcard no-match (short)": { + "old": 29.913125, + "new": 13.340458 + }, + "wildcard no-match (suffix)": { + "old": 33.854417, + "new": 33.30225 + }, + "multi-star match": { + "old": 159.511417, + "new": 161.848084 + }, + "regexp match": { + "old": 164.934, + "new": 171.359708 + }, + "no Host": { + "old": 7.450625, + "new": 5.984542 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 89.775708, + "new": 67.371042 + }, + "static no-match": { + "old": 28.512125, + "new": 14.832125 + }, + "wildcard match": { + "old": 155.820041, + "new": 157.210709 + }, + "wildcard match (prefix)": { + "old": 156.76675, + "new": 417.332875 + }, + "wildcard no-match (short)": { + "old": 35.306584, + "new": 14.240584 + }, + "wildcard no-match (suffix)": { + "old": 36.792208, + "new": 34.787 + }, + "multi-star match": { + "old": 167.470708, + "new": 168.48025 + }, + "regexp match": { + "old": 170.406875, + "new": 174.911792 + }, + "no Host": { + "old": 7.390292, + "new": 6.274334 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 84.10925, + "new": 62.45675 + }, + "static no-match": { + "old": 27.824, + "new": 14.243459 + }, + "wildcard match": { + "old": 150.288958, + "new": 151.757708 + }, + "wildcard match (prefix)": { + "old": 152.183416, + "new": 155.121 + }, + "wildcard no-match (short)": { + "old": 31.182542, + "new": 13.7115 + }, + "wildcard no-match (suffix)": { + "old": 34.091333, + "new": 33.307 + }, + "multi-star match": { + "old": 160.478875, + "new": 399.876167 + }, + "regexp match": { + "old": 172.852125, + "new": 168.246625 + }, + "no Host": { + "old": 7.561625, + "new": 5.93225 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 80.370125, + "new": 59.832792 + }, + "static no-match": { + "old": 27.504125, + "new": 14.221125 + }, + "wildcard match": { + "old": 145.731417, + "new": 147.252292 + }, + "wildcard match (prefix)": { + "old": 146.596, + "new": 148.880125 + }, + "wildcard no-match (short)": { + "old": 29.320375, + "new": 13.184625 + }, + "wildcard no-match (suffix)": { + "old": 33.487917, + "new": 33.20925 + }, + "multi-star match": { + "old": 158.349834, + "new": 161.322709 + }, + "regexp match": { + "old": 163.51775, + "new": 163.1675 + }, + "no Host": { + "old": 7.338875, + "new": 5.8455 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 91.942708, + "new": 68.918666 + }, + "static no-match": { + "old": 29.42725, + "new": 14.937958 + }, + "wildcard match": { + "old": 162.711875, + "new": 162.035416 + }, + "wildcard match (prefix)": { + "old": 165.697875, + "new": 167.743167 + }, + "wildcard no-match (short)": { + "old": 32.901792, + "new": 14.984291 + }, + "wildcard no-match (suffix)": { + "old": 35.266417, + "new": 38.081542 + }, + "multi-star match": { + "old": 179.364541, + "new": 172.88525 + }, + "regexp match": { + "old": 214.888708, + "new": 248.3095 + }, + "no Host": { + "old": 8.619416, + "new": 6.945 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 81.381292, + "new": 59.790209 + }, + "static no-match": { + "old": 27.140542, + "new": 14.156458 + }, + "wildcard match": { + "old": 144.792084, + "new": 146.741584 + }, + "wildcard match (prefix)": { + "old": 147.905292, + "new": 148.07275 + }, + "wildcard no-match (short)": { + "old": 29.280042, + "new": 13.176292 + }, + "wildcard no-match (suffix)": { + "old": 33.404792, + "new": 33.010667 + }, + "multi-star match": { + "old": 158.237209, + "new": 159.621541 + }, + "regexp match": { + "old": 162.927083, + "new": 162.346958 + }, + "no Host": { + "old": 7.317333, + "new": 5.820584 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 87.720917, + "new": 64.653333 + }, + "static no-match": { + "old": 27.76325, + "new": 14.049625 + }, + "wildcard match": { + "old": 148.789667, + "new": 150.819625 + }, + "wildcard match (prefix)": { + "old": 150.191, + "new": 152.482292 + }, + "wildcard no-match (short)": { + "old": 29.132833, + "new": 13.347334 + }, + "wildcard no-match (suffix)": { + "old": 33.7155, + "new": 32.865583 + }, + "multi-star match": { + "old": 164.681375, + "new": 165.260791 + }, + "regexp match": { + "old": 167.670541, + "new": 167.196125 + }, + "no Host": { + "old": 7.159042, + "new": 5.863084 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 84.401416, + "new": 63.153542 + }, + "static no-match": { + "old": 27.558041, + "new": 14.133083 + }, + "wildcard match": { + "old": 148.371084, + "new": 150.449708 + }, + "wildcard match (prefix)": { + "old": 151.644833, + "new": 152.49225 + }, + "wildcard no-match (short)": { + "old": 29.205041, + "new": 13.230792 + }, + "wildcard no-match (suffix)": { + "old": 33.218292, + "new": 33.135667 + }, + "multi-star match": { + "old": 161.6935, + "new": 164.532041 + }, + "regexp match": { + "old": 168.122375, + "new": 173.827292 + }, + "no Host": { + "old": 7.204834, + "new": 5.892875 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 81.245958, + "new": 59.059584 + }, + "static no-match": { + "old": 27.191041, + "new": 14.120917 + }, + "wildcard match": { + "old": 144.490333, + "new": 147.073459 + }, + "wildcard match (prefix)": { + "old": 146.745834, + "new": 148.443291 + }, + "wildcard no-match (short)": { + "old": 29.2805, + "new": 13.464667 + }, + "wildcard no-match (suffix)": { + "old": 40.200167, + "new": 34.144958 + }, + "multi-star match": { + "old": 159.152375, + "new": 160.250792 + }, + "regexp match": { + "old": 162.61075, + "new": 161.983125 + }, + "no Host": { + "old": 7.40025, + "new": 6.062542 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 85.425666, + "new": 63.682 + }, + "static no-match": { + "old": 27.284125, + "new": 14.150959 + }, + "wildcard match": { + "old": 149.824125, + "new": 150.547833 + }, + "wildcard match (prefix)": { + "old": 151.236834, + "new": 152.447041 + }, + "wildcard no-match (short)": { + "old": 29.544167, + "new": 13.375375 + }, + "wildcard no-match (suffix)": { + "old": 33.254958, + "new": 32.935792 + }, + "multi-star match": { + "old": 162.425917, + "new": 163.140291 + }, + "regexp match": { + "old": 167.55575, + "new": 166.437958 + }, + "no Host": { + "old": 7.214625, + "new": 5.826417 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 80.525, + "new": 59.402792 + }, + "static no-match": { + "old": 27.414583, + "new": 13.969292 + }, + "wildcard match": { + "old": 145.379958, + "new": 147.22325 + }, + "wildcard match (prefix)": { + "old": 147.164875, + "new": 149.694625 + }, + "wildcard no-match (short)": { + "old": 29.131583, + "new": 13.473625 + }, + "wildcard no-match (suffix)": { + "old": 33.3565, + "new": 33.0425 + }, + "multi-star match": { + "old": 158.776791, + "new": 160.967375 + }, + "regexp match": { + "old": 165.692625, + "new": 165.854042 + }, + "no Host": { + "old": 7.184292, + "new": 5.88125 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 111.365417, + "new": 74.884833 + }, + "static no-match": { + "old": 27.363667, + "new": 15.368459 + }, + "wildcard match": { + "old": 148.505167, + "new": 150.824834 + }, + "wildcard match (prefix)": { + "old": 149.312916, + "new": 150.826375 + }, + "wildcard no-match (short)": { + "old": 29.191583, + "new": 13.191791 + }, + "wildcard no-match (suffix)": { + "old": 33.203958, + "new": 33.177417 + }, + "multi-star match": { + "old": 160.547208, + "new": 162.239333 + }, + "regexp match": { + "old": 165.345083, + "new": 164.952375 + }, + "no Host": { + "old": 7.187791, + "new": 5.820542 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 82.659, + "new": 61.314625 + }, + "static no-match": { + "old": 27.111625, + "new": 13.953041 + }, + "wildcard match": { + "old": 147.431208, + "new": 157.25325 + }, + "wildcard match (prefix)": { + "old": 150.961, + "new": 150.698291 + }, + "wildcard no-match (short)": { + "old": 29.0995, + "new": 13.269292 + }, + "wildcard no-match (suffix)": { + "old": 33.686916, + "new": 33.376833 + }, + "multi-star match": { + "old": 160.456958, + "new": 161.331083 + }, + "regexp match": { + "old": 165.620375, + "new": 164.247541 + }, + "no Host": { + "old": 7.18625, + "new": 5.849 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 80.6635, + "new": 59.662417 + }, + "static no-match": { + "old": 27.187458, + "new": 14.007083 + }, + "wildcard match": { + "old": 145.143, + "new": 146.359375 + }, + "wildcard match (prefix)": { + "old": 167.455167, + "new": 163.387292 + }, + "wildcard no-match (short)": { + "old": 29.222125, + "new": 13.166834 + }, + "wildcard no-match (suffix)": { + "old": 33.357875, + "new": 33.005666 + }, + "multi-star match": { + "old": 158.800292, + "new": 159.917209 + }, + "regexp match": { + "old": 163.270791, + "new": 163.71825 + }, + "no Host": { + "old": 7.266958, + "new": 5.939 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 82.051292, + "new": 59.657791 + }, + "static no-match": { + "old": 27.984, + "new": 14.009125 + }, + "wildcard match": { + "old": 149.366958, + "new": 149.278708 + }, + "wildcard match (prefix)": { + "old": 147.943209, + "new": 148.000583 + }, + "wildcard no-match (short)": { + "old": 29.588959, + "new": 13.387541 + }, + "wildcard no-match (suffix)": { + "old": 33.663208, + "new": 33.238375 + }, + "multi-star match": { + "old": 159.585083, + "new": 161.619584 + }, + "regexp match": { + "old": 165.204291, + "new": 164.064125 + }, + "no Host": { + "old": 7.3, + "new": 5.966958 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 84.039792, + "new": 62.866959 + }, + "static no-match": { + "old": 27.331042, + "new": 14.191917 + }, + "wildcard match": { + "old": 149.06925, + "new": 152.968709 + }, + "wildcard match (prefix)": { + "old": 151.586, + "new": 153.629458 + }, + "wildcard no-match (short)": { + "old": 29.388666, + "new": 13.289917 + }, + "wildcard no-match (suffix)": { + "old": 33.790375, + "new": 33.072375 + }, + "multi-star match": { + "old": 161.517875, + "new": 237.779417 + }, + "regexp match": { + "old": 176.907792, + "new": 167.221542 + }, + "no Host": { + "old": 7.3365, + "new": 5.902875 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 84.959459, + "new": 63.440708 + }, + "static no-match": { + "old": 37.630083, + "new": 23.436291 + }, + "wildcard match": { + "old": 150.691333, + "new": 150.038125 + }, + "wildcard match (prefix)": { + "old": 150.299792, + "new": 149.543416 + }, + "wildcard no-match (short)": { + "old": 29.179041, + "new": 13.207625 + }, + "wildcard no-match (suffix)": { + "old": 33.620125, + "new": 33.163167 + }, + "multi-star match": { + "old": 160.407458, + "new": 161.594209 + }, + "regexp match": { + "old": 167.958583, + "new": 165.261292 + }, + "no Host": { + "old": 7.220208, + "new": 6.209292 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 85.268833, + "new": 64.447708 + }, + "static no-match": { + "old": 28.085041, + "new": 14.838 + }, + "wildcard match": { + "old": 152.044208, + "new": 162.7045 + }, + "wildcard match (prefix)": { + "old": 182.390083, + "new": 164.144125 + }, + "wildcard no-match (short)": { + "old": 30.375, + "new": 14.014167 + }, + "wildcard no-match (suffix)": { + "old": 34.115417, + "new": 34.754334 + }, + "multi-star match": { + "old": 188.40975, + "new": 172.295584 + }, + "regexp match": { + "old": 175.233958, + "new": 283.145625 + }, + "no Host": { + "old": 7.679791, + "new": 6.048917 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 85.374, + "new": 63.38125 + }, + "static no-match": { + "old": 27.899083, + "new": 14.666416 + }, + "wildcard match": { + "old": 151.416208, + "new": 151.546875 + }, + "wildcard match (prefix)": { + "old": 154.635291, + "new": 151.011167 + }, + "wildcard no-match (short)": { + "old": 29.629792, + "new": 13.802666 + }, + "wildcard no-match (suffix)": { + "old": 33.845042, + "new": 33.073583 + }, + "multi-star match": { + "old": 161.599084, + "new": 161.439625 + }, + "regexp match": { + "old": 165.368541, + "new": 165.39425 + }, + "no Host": { + "old": 7.237583, + "new": 5.832375 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 86.435875, + "new": 61.770208 + }, + "static no-match": { + "old": 27.414917, + "new": 14.15025 + }, + "wildcard match": { + "old": 149.986458, + "new": 150.8125 + }, + "wildcard match (prefix)": { + "old": 149.491125, + "new": 153.003917 + }, + "wildcard no-match (short)": { + "old": 29.204959, + "new": 13.242583 + }, + "wildcard no-match (suffix)": { + "old": 33.386958, + "new": 34.406792 + }, + "multi-star match": { + "old": 167.629, + "new": 175.921167 + }, + "regexp match": { + "old": 167.901542, + "new": 167.207916 + }, + "no Host": { + "old": 7.29475, + "new": 5.902916 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 88.378208, + "new": 63.228167 + }, + "static no-match": { + "old": 27.344542, + "new": 13.973292 + }, + "wildcard match": { + "old": 149.028958, + "new": 149.718958 + }, + "wildcard match (prefix)": { + "old": 150.376833, + "new": 152.795459 + }, + "wildcard no-match (short)": { + "old": 29.818083, + "new": 13.694542 + }, + "wildcard no-match (suffix)": { + "old": 34.040417, + "new": 33.163166 + }, + "multi-star match": { + "old": 169.360875, + "new": 170.239333 + }, + "regexp match": { + "old": 172.132291, + "new": 165.872042 + }, + "no Host": { + "old": 7.322583, + "new": 5.910916 + } + } + }, + { + "length": 1000000, + "scenarios": { + "static match": { + "old": 88.798958, + "new": 61.893542 + }, + "static no-match": { + "old": 27.484458, + "new": 14.015292 + }, + "wildcard match": { + "old": 149.910375, + "new": 151.110292 + }, + "wildcard match (prefix)": { + "old": 153.110916, + "new": 150.6175 + }, + "wildcard no-match (short)": { + "old": 29.181875, + "new": 13.217375 + }, + "wildcard no-match (suffix)": { + "old": 33.290375, + "new": 33.003458 + }, + "multi-star match": { + "old": 166.787333, + "new": 163.441334 + }, + "regexp match": { + "old": 166.043333, + "new": 171.096042 + }, + "no Host": { + "old": 8.887625, + "new": 6.055667 + } + } + } + ] +} diff --git a/bench/results/2026-06-13T07-13-20-615Z/summary.json b/bench/results/2026-06-13T07-13-20-615Z/summary.json new file mode 100644 index 0000000..9aad668 --- /dev/null +++ b/bench/results/2026-06-13T07-13-20-615Z/summary.json @@ -0,0 +1,549 @@ +{ + "meta": { + "runId": "2026-06-13T07-13-20-615Z", + "timestamp": "2026-06-13T07:13:20.688Z", + "node": "v24.15.0", + "platform": "darwin", + "arch": "arm64", + "sessions": 25, + "lengths": [ + 10000, + 100000, + 1000000 + ], + "scenarios": [ + "static match", + "static no-match", + "wildcard match", + "wildcard match (prefix)", + "wildcard no-match (short)", + "wildcard no-match (suffix)", + "multi-star match", + "regexp match", + "no Host" + ], + "baseline": "bench/v3-baseline.cjs (v3.0.2)", + "candidate": "package \"vhost\" entry (current build)" + }, + "results": { + "10000": { + "static match": { + "old": { + "mean": 82.05899600000001, + "median": 81.4333, + "min": 78.45, + "max": 87.2542, + "sd": 2.48604878511746, + "sdPct": 3.0295871340144838 + }, + "new": { + "mean": 70.327336, + "median": 69.8166, + "min": 66.6083, + "max": 75.9375, + "sd": 2.4187670045508733, + "sdPct": 3.439298489211753 + }, + "speedup": 1.1668150774259387 + }, + "static no-match": { + "old": { + "mean": 28.788004, + "median": 28.6042, + "min": 27.9125, + "max": 33.5834, + "sd": 1.1044184078436936, + "sdPct": 3.8363840988895705 + }, + "new": { + "mean": 22.700512, + "median": 22.7666, + "min": 22.225, + "max": 23.7292, + "sd": 0.4118831555866298, + "sdPct": 1.8144223160545006 + }, + "speedup": 1.2681654052560578 + }, + "wildcard match": { + "old": { + "mean": 155.445664, + "median": 153.7166, + "min": 148.5583, + "max": 180.55, + "sd": 6.783115549355179, + "sdPct": 4.363656968492334 + }, + "new": { + "mean": 146.94068, + "median": 144.9542, + "min": 142.4375, + "max": 159.5458, + "sd": 4.132232522111989, + "sdPct": 2.8121773508275516 + }, + "speedup": 1.0578803909169334 + }, + "wildcard match (prefix)": { + "old": { + "mean": 151.477344, + "median": 149.7167, + "min": 145.7708, + "max": 165.6375, + "sd": 4.777741711568763, + "sdPct": 3.15409657009088 + }, + "new": { + "mean": 149.83400400000002, + "median": 147.6542, + "min": 145.0667, + "max": 169.8791, + "sd": 5.477411976726235, + "sdPct": 3.6556534768477746 + }, + "speedup": 1.0109677373368462 + }, + "wildcard no-match (short)": { + "old": { + "mean": 29.265331999999997, + "median": 28.9709, + "min": 28.5791, + "max": 31.2417, + "sd": 0.7786922004078383, + "sdPct": 2.6608008424706693 + }, + "new": { + "mean": 12.817844000000001, + "median": 12.7042, + "min": 12.5167, + "max": 13.8333, + "sd": 0.30069824087280583, + "sdPct": 2.3459346273273867 + }, + "speedup": 2.283171179178027 + }, + "wildcard no-match (suffix)": { + "old": { + "mean": 33.429823999999996, + "median": 33.2125, + "min": 32.6917, + "max": 35.6375, + "sd": 0.8127677898539044, + "sdPct": 2.4312655365876426 + }, + "new": { + "mean": 35.21382800000001, + "median": 32.6958, + "min": 32.3209, + "max": 60.8792, + "sd": 7.549732425272832, + "sdPct": 21.439681097075926 + }, + "speedup": 0.9493379702996217 + }, + "multi-star match": { + "old": { + "mean": 163.48232400000003, + "median": 162.6167, + "min": 158.1625, + "max": 182.3791, + "sd": 4.884617179864149, + "sdPct": 2.9878564607780764 + }, + "new": { + "mean": 164.17181599999995, + "median": 161.9917, + "min": 156.5208, + "max": 184.9666, + "sd": 6.762999706915857, + "sdPct": 4.119464516927716 + }, + "speedup": 0.9958001804645937 + }, + "regexp match": { + "old": { + "mean": 166.70917599999996, + "median": 166.3166, + "min": 160.975, + "max": 172.2875, + "sd": 3.041812337575084, + "sdPct": 1.824622021750671 + }, + "new": { + "mean": 177.51133200000004, + "median": 175.7583, + "min": 167.1708, + "max": 194.8666, + "sd": 6.344075919767669, + "sdPct": 3.5738991129691193 + }, + "speedup": 0.9391466681124331 + }, + "no Host": { + "old": { + "mean": 7.093164000000001, + "median": 7, + "min": 6.6292, + "max": 8.6375, + "sd": 0.49752004814278583, + "sdPct": 7.014077894473972 + }, + "new": { + "mean": 6.209012, + "median": 5.6209, + "min": 5.3125, + "max": 8.4125, + "sd": 1.0979587913287092, + "sdPct": 17.683309217774248 + }, + "speedup": 1.1423981786474242 + } + }, + "100000": { + "static match": { + "old": { + "mean": 84.49220079999999, + "median": 84.3575, + "min": 80.98917, + "max": 88.98917, + "sd": 2.011131623612777, + "sdPct": 2.380257117900493 + }, + "new": { + "mean": 61.750264400000006, + "median": 61.35, + "min": 58.66792, + "max": 66.15, + "sd": 2.0753457231393133, + "sdPct": 3.360869371661044 + }, + "speedup": 1.368288891083679 + }, + "static no-match": { + "old": { + "mean": 27.493099599999997, + "median": 27.49917, + "min": 26.90875, + "max": 28.97083, + "sd": 0.4299139506224935, + "sdPct": 1.5637158300713883 + }, + "new": { + "mean": 14.2901668, + "median": 14.20375, + "min": 13.8475, + "max": 17.64833, + "sd": 0.710322219817007, + "sdPct": 4.9707062888657605 + }, + "speedup": 1.9239173331412758 + }, + "wildcard match": { + "old": { + "mean": 149.65734959999997, + "median": 148.40292, + "min": 144.70792, + "max": 160.99667, + "sd": 4.099414394452437, + "sdPct": 2.7392001832247055 + }, + "new": { + "mean": 149.61743240000004, + "median": 149.52583, + "min": 144.81125, + "max": 159.8225, + "sd": 3.0471867461096367, + "sdPct": 2.0366522117309342 + }, + "speedup": 1.0002667951144437 + }, + "wildcard match (prefix)": { + "old": { + "mean": 151.02308319999997, + "median": 149.91083, + "min": 146.50792, + "max": 165.64375, + "sd": 3.8431717823305487, + "sdPct": 2.5447578614462754 + }, + "new": { + "mean": 151.7382012, + "median": 150.785, + "min": 146.35625, + "max": 173.01875, + "sd": 4.952665560326336, + "sdPct": 3.2639543115437544 + }, + "speedup": 0.9952871591046645 + }, + "wildcard no-match (short)": { + "old": { + "mean": 29.68255080000001, + "median": 29.27083, + "min": 28.98333, + "max": 36.32417, + "sd": 1.4115639137096703, + "sdPct": 4.755534398713705 + }, + "new": { + "mean": 13.467166400000002, + "median": 13.36, + "min": 13.09583, + "max": 14.79083, + "sd": 0.3668718420689164, + "sdPct": 2.7241947650466125 + }, + "speedup": 2.2040680213173873 + }, + "wildcard no-match (suffix)": { + "old": { + "mean": 33.6303172, + "median": 33.42167, + "min": 33.05625, + "max": 35.58208, + "sd": 0.5574580301154161, + "sdPct": 1.657605626495298 + }, + "new": { + "mean": 33.266266400000006, + "median": 33.07833, + "min": 32.73958, + "max": 36.15084, + "sd": 0.661287280838699, + "sdPct": 1.9878614356274706 + }, + "speedup": 1.0109435424950481 + }, + "multi-star match": { + "old": { + "mean": 163.16233159999996, + "median": 162.4275, + "min": 157.675, + "max": 179.41209, + "sd": 4.581127285104119, + "sdPct": 2.8077113388738315 + }, + "new": { + "mean": 163.1790688, + "median": 163.03958, + "min": 159.64042, + "max": 172.70375, + "sd": 2.84452736431249, + "sdPct": 1.7431937718671981 + }, + "speedup": 0.999897430472406 + }, + "regexp match": { + "old": { + "mean": 166.634768, + "median": 166.17209, + "min": 162.94625, + "max": 173.26667, + "sd": 2.482803676507668, + "sdPct": 1.4899673737401957 + }, + "new": { + "mean": 166.08631719999997, + "median": 165.34, + "min": 161.22375, + "max": 174.70708, + "sd": 2.9475798638781887, + "sdPct": 1.7747276919439028 + }, + "speedup": 1.003302203391864 + }, + "no Host": { + "old": { + "mean": 7.309616400000003, + "median": 7.27375, + "min": 7.1625, + "max": 7.72333, + "sd": 0.14826824199079183, + "sdPct": 2.0283997665156788 + }, + "new": { + "mean": 5.898067199999999, + "median": 5.87584, + "min": 5.77541, + "max": 6.09583, + "sd": 0.10464175325442521, + "sdPct": 1.7741702443543748 + }, + "speedup": 1.2393240280476974 + } + }, + "1000000": { + "static match": { + "old": { + "mean": 86.74649160000001, + "median": 84.959459, + "min": 80.370125, + "max": 111.365417, + "sd": 7.636595511674257, + "sdPct": 8.80334797502549 + }, + "new": { + "mean": 63.03927003999999, + "median": 62.45675, + "min": 59.059584, + "max": 74.884833, + "sd": 3.5720989657516666, + "sdPct": 5.666466257437754 + }, + "speedup": 1.3760706864936285 + }, + "static no-match": { + "old": { + "mean": 28.0438716, + "median": 27.504125, + "min": 27.111625, + "max": 37.630083, + "sd": 2.0198515651620146, + "sdPct": 7.202470450485213 + }, + "new": { + "mean": 14.69920668, + "median": 14.150959, + "min": 13.952667, + "max": 23.436291, + "sd": 1.8333339702981282, + "sdPct": 12.472332760601255 + }, + "speedup": 1.9078493289135792 + }, + "wildcard match": { + "old": { + "mean": 153.1826416, + "median": 149.06925, + "min": 144.490333, + "max": 240.751375, + "sd": 18.32671774439266, + "sdPct": 11.96396507657083 + }, + "new": { + "mean": 151.15494344, + "median": 150.547833, + "min": 146.359375, + "max": 162.7045, + "sd": 4.310470597469739, + "sdPct": 2.8516901262847236 + }, + "speedup": 1.0134146996046138 + }, + "wildcard match (prefix)": { + "old": { + "mean": 152.96561996, + "median": 150.376833, + "min": 146.596, + "max": 182.390083, + "sd": 7.8276481410853895, + "sdPct": 5.117259775845247 + }, + "new": { + "mean": 163.40841332, + "median": 151.194959, + "min": 148.000583, + "max": 417.332875, + "sd": 52.0642387098441, + "sdPct": 31.86141866997237 + }, + "speedup": 0.9360939063795324 + }, + "wildcard no-match (short)": { + "old": { + "mean": 29.901414960000007, + "median": 29.349291, + "min": 29.0995, + "max": 35.306584, + "sd": 1.378322250128771, + "sdPct": 4.609555273463122 + }, + "new": { + "mean": 13.521375039999999, + "median": 13.347334, + "min": 13.166834, + "max": 14.984291, + "sd": 0.42652191160225106, + "sdPct": 3.15442704858404 + }, + "speedup": 2.211418207951727 + }, + "wildcard no-match (suffix)": { + "old": { + "mean": 34.08702836, + "median": 33.686916, + "min": 33.203958, + "max": 40.200167, + "sd": 1.4522160501434185, + "sdPct": 4.260318719503124 + }, + "new": { + "mean": 33.61260832000001, + "median": 33.2065, + "min": 32.865583, + "max": 38.081542, + "sd": 1.0734483953050644, + "sdPct": 3.193588504306423 + }, + "speedup": 1.0141143476722603 + }, + "multi-star match": { + "old": { + "mean": 163.54146999999995, + "median": 161.007875, + "min": 158.237209, + "max": 188.40975, + "sd": 6.832089349414774, + "sdPct": 4.177588320206964 + }, + "new": { + "mean": 176.54580176000002, + "median": 163.140291, + "min": 159.621541, + "max": 399.876167, + "sd": 48.00601478553766, + "sdPct": 27.191818954040052 + }, + "speedup": 0.9263401812427212 + }, + "regexp match": { + "old": { + "mean": 169.11974148, + "median": 166.043333, + "min": 162.61075, + "max": 214.888708, + "sd": 10.032549679709502, + "sdPct": 5.932216778427341 + }, + "new": { + "mean": 174.49837999999997, + "median": 165.949458, + "min": 161.983125, + "max": 283.145625, + "sd": 27.532303097636625, + "sdPct": 15.777970602154948 + }, + "speedup": 0.9691765704644365 + }, + "no Host": { + "old": { + "mean": 7.43137328, + "median": 7.317333, + "min": 7.159042, + "max": 8.887625, + "sd": 0.4102976493324347, + "sdPct": 5.521155160334439 + }, + "new": { + "mean": 5.97656512, + "median": 5.902916, + "min": 5.820542, + "max": 6.945, + "sd": 0.22838988011018702, + "sdPct": 3.8214237697486513 + }, + "speedup": 1.2434187749635028 + } + } + } +} diff --git a/bench/results/2026-06-13T07-13-20-615Z/summary.md b/bench/results/2026-06-13T07-13-20-615Z/summary.md new file mode 100644 index 0000000..a2831fa --- /dev/null +++ b/bench/results/2026-06-13T07-13-20-615Z/summary.md @@ -0,0 +1,50 @@ +# Benchmark run 2026-06-13T07-13-20-615Z + +- Node: v24.15.0 · darwin/arm64 +- Sessions per length: **25** (independent processes) +- Baseline: `bench/v3-baseline.cjs` (v3.0.2) · Candidate: current build + +ns/op, lower is better. `speedup` = old mean ÷ new mean. + +## 10,000 iterations / scenario + +| scenario | old mean | new mean | new median | new min | new max | speedup | new ±sd% | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| static match | 82.1 | 70.3 | 69.8 | 66.6 | 75.9 | 1.17× | 3.4% | +| static no-match | 28.8 | 22.7 | 22.8 | 22.2 | 23.7 | 1.27× | 1.8% | +| wildcard match | 155.4 | 146.9 | 145.0 | 142.4 | 159.5 | 1.06× | 2.8% | +| wildcard match (prefix) | 151.5 | 149.8 | 147.7 | 145.1 | 169.9 | 1.01× | 3.7% | +| wildcard no-match (short) | 29.3 | 12.8 | 12.7 | 12.5 | 13.8 | 2.28× | 2.3% | +| wildcard no-match (suffix) | 33.4 | 35.2 | 32.7 | 32.3 | 60.9 | 0.95× | 21.4% | +| multi-star match | 163.5 | 164.2 | 162.0 | 156.5 | 185.0 | 1.00× | 4.1% | +| regexp match | 166.7 | 177.5 | 175.8 | 167.2 | 194.9 | 0.94× | 3.6% | +| no Host | 7.1 | 6.2 | 5.6 | 5.3 | 8.4 | 1.14× | 17.7% | + +## 100,000 iterations / scenario + +| scenario | old mean | new mean | new median | new min | new max | speedup | new ±sd% | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| static match | 84.5 | 61.8 | 61.4 | 58.7 | 66.2 | 1.37× | 3.4% | +| static no-match | 27.5 | 14.3 | 14.2 | 13.8 | 17.6 | 1.92× | 5.0% | +| wildcard match | 149.7 | 149.6 | 149.5 | 144.8 | 159.8 | 1.00× | 2.0% | +| wildcard match (prefix) | 151.0 | 151.7 | 150.8 | 146.4 | 173.0 | 1.00× | 3.3% | +| wildcard no-match (short) | 29.7 | 13.5 | 13.4 | 13.1 | 14.8 | 2.20× | 2.7% | +| wildcard no-match (suffix) | 33.6 | 33.3 | 33.1 | 32.7 | 36.2 | 1.01× | 2.0% | +| multi-star match | 163.2 | 163.2 | 163.0 | 159.6 | 172.7 | 1.00× | 1.7% | +| regexp match | 166.6 | 166.1 | 165.3 | 161.2 | 174.7 | 1.00× | 1.8% | +| no Host | 7.3 | 5.9 | 5.9 | 5.8 | 6.1 | 1.24× | 1.8% | + +## 1,000,000 iterations / scenario + +| scenario | old mean | new mean | new median | new min | new max | speedup | new ±sd% | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| static match | 86.7 | 63.0 | 62.5 | 59.1 | 74.9 | 1.38× | 5.7% | +| static no-match | 28.0 | 14.7 | 14.2 | 14.0 | 23.4 | 1.91× | 12.5% | +| wildcard match | 153.2 | 151.2 | 150.5 | 146.4 | 162.7 | 1.01× | 2.9% | +| wildcard match (prefix) | 153.0 | 163.4 | 151.2 | 148.0 | 417.3 | 0.94× | 31.9% | +| wildcard no-match (short) | 29.9 | 13.5 | 13.3 | 13.2 | 15.0 | 2.21× | 3.2% | +| wildcard no-match (suffix) | 34.1 | 33.6 | 33.2 | 32.9 | 38.1 | 1.01× | 3.2% | +| multi-star match | 163.5 | 176.5 | 163.1 | 159.6 | 399.9 | 0.93× | 27.2% | +| regexp match | 169.1 | 174.5 | 165.9 | 162.0 | 283.1 | 0.97× | 15.8% | +| no Host | 7.4 | 6.0 | 5.9 | 5.8 | 6.9 | 1.24× | 3.8% | + diff --git a/bench/results/README.md b/bench/results/README.md new file mode 100644 index 0000000..8d1cfa0 --- /dev/null +++ b/bench/results/README.md @@ -0,0 +1,32 @@ +# Benchmark results + +Stored output of `node bench/collect.mjs` (a.k.a. `npm run bench:collect`). Each run +compares the pinned v3 baseline (`bench/v3-baseline.cjs`) against the current build across +several iteration lengths, using N independent `node` processes per length (one process = +one independent JIT/GC session). + +Each run lives in its own timestamped folder (`/`) so repeated runs accumulate +rather than overwrite: + +| file | contents | +| --- | --- | +| `meta.json` | node version, platform, session count, lengths, timestamp, baseline/candidate labels | +| `raw-.json` | every individual session's ns/op, per scenario, for old + new — the unaggregated data | +| `summary.json` | aggregated mean / median / min / max / sd / speedup per scenario | +| `summary.md` | the same tables, human-readable | + +`speedup` is `old mean ÷ new mean`; lower ns/op is better. + +## Reading the numbers + +Prefer the **median** when `±sd%` is high: a single OS-scheduler hiccup in one session can +inflate the mean and the max (e.g. a stray 296ns sample in an otherwise ~145ns scenario), +but the median stays robust. The `static match`/`static no-match` scenarios use a lowercase +Host — the realistic common case the ASCII fast path targets. + +To reproduce: + +```sh +npm run build # ensure dist/ is current +npm run bench:collect # 25 sessions × {10k,100k,1M} +``` diff --git a/bench/run-experiments.mjs b/bench/run-experiments.mjs new file mode 100644 index 0000000..885950e --- /dev/null +++ b/bench/run-experiments.mjs @@ -0,0 +1,44 @@ +// Spawn N independent sessions of bench/experiments.mjs and aggregate, so the +// variant comparison reflects cross-session variance rather than one warm JIT. +// +// node bench/run-experiments.mjs [sessions] [length] + +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const script = join(__dirname, 'experiments.mjs') + +const sessions = Number(process.argv[2] || 8) +const length = Number(process.argv[3] || 1000000) + +function run () { + const out = execFileSync(process.execPath, [script, String(length)], { encoding: 'utf8' }) + return JSON.parse(out.trim().split('\n').pop()) +} + +const runs = [] +for (let i = 0; i < sessions; i++) runs.push(run()) + +const variants = Object.keys(runs[0].results) +const scenarios = Object.keys(runs[0].results[variants[0]]) + +function mean (xs) { return xs.reduce((a, b) => a + b, 0) / xs.length } +function sd (xs) { const m = mean(xs); return Math.sqrt(mean(xs.map((x) => (x - m) ** 2))) } + +console.log(`${length.toLocaleString()} iters/scenario, ${sessions} sessions — ns/op (mean, ±sd%)\n`) +const header = ['scenario'.padEnd(18)].concat(variants.map((v) => v.padStart(16))).join('') +console.log(header) +for (const s of scenarios) { + const cells = [s.padEnd(18)] + const v0mean = mean(runs.map((r) => r.results.V0[s])) + for (const v of variants) { + const xs = runs.map((r) => r.results[v][s]) + const m = mean(xs) + const rel = v === 'V0' ? '' : ' ' + (v0mean / m).toFixed(2) + 'x' + cells.push((m.toFixed(1) + '±' + ((sd(xs) / m) * 100).toFixed(0) + '%' + rel).padStart(16)) + } + console.log(cells.join('')) +} +console.log('\n(xN = speedup vs V0, the currently shipped regexp.test path)') diff --git a/bench/run-matrix.mjs b/bench/run-matrix.mjs new file mode 100644 index 0000000..854f0c6 --- /dev/null +++ b/bench/run-matrix.mjs @@ -0,0 +1,98 @@ +// Run the session benchmark across multiple lengths and multiple independent +// sessions (fresh processes), then aggregate mean / min / max / spread. +// +// node bench/run-matrix.mjs [sessions] [lengths...] +// node bench/run-matrix.mjs 8 10000 100000 1000000 # defaults +// +// Prints a per-length table comparing the v3 baseline against the current +// build, and a machine-readable JSON blob at the end (captured into +// BENCHMARKS.md). Each session is a separate `node bench/session.mjs ` +// process, so run-to-run variance reflects real cross-session noise rather than +// one warmed-up JIT. + +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const sessionScript = join(__dirname, 'session.mjs') + +const argv = process.argv.slice(2) +const sessions = Number(argv[0] || 8) +const lengths = (argv.length > 1 ? argv.slice(1) : ['10000', '100000', '1000000']).map(Number) + +function runSession (length) { + const stdout = execFileSync(process.execPath, [sessionScript, String(length)], { + encoding: 'utf8' + }) + return JSON.parse(stdout.trim().split('\n').pop()) +} + +// Discover scenarios from the session output (keeps in sync with session.mjs). +const SCENARIOS = Object.keys(runSession(lengths[0]).scenarios) + +function stats (values) { + const mean = values.reduce((a, b) => a + b, 0) / values.length + const min = Math.min(...values) + const max = Math.max(...values) + const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length + return { mean, min, max, sd: Math.sqrt(variance) } +} + +function fmt (n) { + return n.toFixed(1) +} + +const report = { + node: process.version, + platform: process.platform, + arch: process.arch, + sessions, + lengths, + results: {} +} + +for (const length of lengths) { + // Collect `sessions` independent runs at this length. + const runs = [] + for (let i = 0; i < sessions; i++) { + runs.push(runSession(length)) + } + + console.log(`\n=== length ${length.toLocaleString()} iterations/scenario, ${sessions} sessions ===`) + console.log( + 'scenario'.padEnd(16), + 'old mean'.padStart(10), + 'new mean'.padStart(10), + 'new min'.padStart(9), + 'new max'.padStart(9), + 'speedup'.padStart(8), + 'new ±sd%'.padStart(9) + ) + + report.results[length] = {} + for (const s of SCENARIOS) { + const oldStat = stats(runs.map((r) => r.scenarios[s].old)) + const newStat = stats(runs.map((r) => r.scenarios[s].new)) + const speedup = oldStat.mean / newStat.mean + const sdPct = (newStat.sd / newStat.mean) * 100 + + report.results[length][s] = { + old: oldStat, + new: newStat, + speedup + } + + console.log( + s.padEnd(16), + fmt(oldStat.mean).padStart(10), + fmt(newStat.mean).padStart(10), + fmt(newStat.min).padStart(9), + fmt(newStat.max).padStart(9), + (speedup.toFixed(2) + 'x').padStart(8), + ('±' + sdPct.toFixed(1) + '%').padStart(9) + ) + } +} + +console.log('\nJSON:' + JSON.stringify(report)) diff --git a/bench/run-wildcard-experiments.mjs b/bench/run-wildcard-experiments.mjs new file mode 100644 index 0000000..9a03fa8 --- /dev/null +++ b/bench/run-wildcard-experiments.mjs @@ -0,0 +1,48 @@ +// Spawn N independent sessions of bench/wildcard-experiments.mjs and aggregate. +// +// node bench/run-wildcard-experiments.mjs [sessions] [length] + +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const script = join(__dirname, 'wildcard-experiments.mjs') + +const sessions = Number(process.argv[2] || 15) +const length = Number(process.argv[3] || 1000000) + +function run () { + const out = execFileSync(process.execPath, [script, String(length)], { encoding: 'utf8' }) + return JSON.parse(out.trim().split('\n').pop()) +} + +const runs = [] +for (let i = 0; i < sessions; i++) { + runs.push(run()) + process.stderr.write(`\rsession ${i + 1}/${sessions} `) +} +process.stderr.write('\n') + +const variants = Object.keys(runs[0].results) +const scenarios = Object.keys(runs[0].results[variants[0]]) + +function median (xs) { + const s = [...xs].sort((a, b) => a - b) + const n = s.length + return n % 2 ? s[(n - 1) / 2] : (s[n / 2 - 1] + s[n / 2]) / 2 +} + +console.log(`${length.toLocaleString()} iters/scenario, ${sessions} sessions — median ns/op (speedup vs WV0)\n`) +console.log(['scenario'.padEnd(28)].concat(variants.map((v) => v.padStart(15))).join('')) +for (const s of scenarios) { + const base = median(runs.map((r) => r.results.WV0[s])) + const cells = [s.padEnd(28)] + for (const v of variants) { + const m = median(runs.map((r) => r.results[v][s])) + const rel = v === 'WV0' ? '' : ' ' + (base / m).toFixed(2) + 'x' + cells.push((m.toFixed(1) + rel).padStart(15)) + } + console.log(cells.join('')) +} +console.log('\nWV0 shipped · WV1 min-length prefilter · WV2 single-* matcher · WV3 single-* suffix-first') diff --git a/bench/session.mjs b/bench/session.mjs new file mode 100644 index 0000000..3ed5659 --- /dev/null +++ b/bench/session.mjs @@ -0,0 +1,75 @@ +// One benchmark session. Run as a fresh process so JIT/GC state is independent +// between sessions: +// +// node bench/session.mjs +// +// Times iterations of each scenario for both the v3 baseline and the +// current build, and prints a single JSON line of ns/op figures on stdout. +// All human-readable orchestration lives in run-matrix.mjs. + +import { createRequire } from 'node:module' +import vhostNew from 'vhost' + +const require = createRequire(import.meta.url) +const vhostOld = require('./v3-baseline.cjs') + +const length = Number(process.argv[2] || 100000) + +const res = {} +const noop = () => {} +const handle = () => {} +const reqWith = (host) => ({ headers: { host } }) + +function build (vhost) { + return { + 'static match': [vhost('mail.example.com', handle), reqWith('mail.example.com')], + 'static no-match': [vhost('mail.example.com', handle), reqWith('other.example.com')], + 'wildcard match': [vhost('*.example.com', handle), reqWith('foo.example.com')], + 'wildcard match (prefix)': [vhost('user-*.example.com', handle), reqWith('user-bob.example.com')], + 'wildcard no-match (short)': [vhost('*.example.com', handle), reqWith('x.io')], + 'wildcard no-match (suffix)': [vhost('*.example.com', handle), reqWith('foo.example.org')], + 'multi-star match': [vhost('*.*.com', handle), reqWith('foo.bar.com')], + 'regexp match': [vhost(/user-(bob|joe)\.([^.]+)\.example\.com/, handle), reqWith('user-bob.team.example.com')], + 'no Host': [vhost('mail.example.com', handle), reqWith(undefined)] + } +} + +// Time a single uninterrupted loop of `length` calls; return ns per op. +function timeOne (mw, req) { + const t0 = process.hrtime.bigint() + for (let i = 0; i < length; i++) { + req.vhost = undefined + mw(req, res, noop) + } + return Number(process.hrtime.bigint() - t0) / length +} + +const SCENARIOS = [ + 'static match', + 'static no-match', + 'wildcard match', + 'wildcard match (prefix)', + 'wildcard no-match (short)', + 'wildcard no-match (suffix)', + 'multi-star match', + 'regexp match', + 'no Host' +] +const old = build(vhostOld) +const nw = build(vhostNew) + +// Warm both implementations so the timed loop measures steady-state JIT output. +for (const s of SCENARIOS) { + timeOne(...old[s]) + timeOne(...nw[s]) +} + +const out = { length, scenarios: {} } +for (const s of SCENARIOS) { + // Interleave old/new per scenario so each pair sees the same machine state. + const o = timeOne(...old[s]) + const n = timeOne(...nw[s]) + out.scenarios[s] = { old: o, new: n } +} + +process.stdout.write(JSON.stringify(out) + '\n') diff --git a/bench/v3-baseline.cjs b/bench/v3-baseline.cjs new file mode 100644 index 0000000..45b59b8 --- /dev/null +++ b/bench/v3-baseline.cjs @@ -0,0 +1,164 @@ +/*! + * vhost + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict' + +/** + * Module exports. + * @public + */ + +module.exports = vhost + +/** + * Module variables. + * @private + */ + +var ASTERISK_REGEXP = /\*/g +var ASTERISK_REPLACE = '([^.]+)' +var END_ANCHORED_REGEXP = /(?:^|[^\\])(?:\\\\)*\$$/ +var ESCAPE_REGEXP = /([.+?^=!:${}()|[\]/\\])/g +var ESCAPE_REPLACE = '\\$1' + +/** + * Create a vhost middleware. + * + * @param {string|RegExp} hostname + * @param {function} handle + * @return {Function} + * @public + */ + +function vhost (hostname, handle) { + if (!hostname) { + throw new TypeError('argument hostname is required') + } + + if (!handle) { + throw new TypeError('argument handle is required') + } + + if (typeof handle !== 'function') { + throw new TypeError('argument handle must be a function') + } + + // create regular expression for hostname + var regexp = hostregexp(hostname) + + return function vhost (req, res, next) { + var vhostdata = vhostof(req, regexp) + + if (!vhostdata) { + return next() + } + + // populate + req.vhost = vhostdata + + // handle + handle(req, res, next) + } +} + +/** + * Get hostname of request. + * + * @param {object} req + * @return {string} + * @private + */ + +function hostnameof (req) { + var host = req.headers.host + + if (!host) { + return + } + + var offset = host[0] === '[' + ? host.indexOf(']') + 1 + : 0 + var index = host.indexOf(':', offset) + + return index !== -1 + ? host.substring(0, index) + : host +} + +/** + * Determine if object is RegExp. + * + * @param (object} val + * @return {boolean} + * @private + */ + +function isregexp (val) { + return Object.prototype.toString.call(val) === '[object RegExp]' +} + +/** + * Generate RegExp for given hostname value. + * + * @param (string|RegExp} val + * @private + */ + +function hostregexp (val) { + var source = !isregexp(val) + ? String(val).replace(ESCAPE_REGEXP, ESCAPE_REPLACE).replace(ASTERISK_REGEXP, ASTERISK_REPLACE) + : val.source + + // force leading anchor matching + if (source[0] !== '^') { + source = '^' + source + } + + // force trailing anchor matching + if (!END_ANCHORED_REGEXP.test(source)) { + source += '$' + } + + return new RegExp(source, 'i') +} + +/** + * Get the vhost data of the request for RegExp + * + * @param (object} req + * @param (RegExp} regexp + * @return {object} + * @private + */ + +function vhostof (req, regexp) { + var host = req.headers.host + var hostname = hostnameof(req) + + if (!hostname) { + return + } + + var match = regexp.exec(hostname) + + if (!match) { + return + } + + var obj = Object.create(null) + + obj.host = host + obj.hostname = hostname + obj.length = match.length - 1 + + for (var i = 1; i < match.length; i++) { + obj[i - 1] = match[i] + } + + return obj +} diff --git a/bench/wildcard-experiments.mjs b/bench/wildcard-experiments.mjs new file mode 100644 index 0000000..facf330 --- /dev/null +++ b/bench/wildcard-experiments.mjs @@ -0,0 +1,415 @@ +// Experiment: can the WILDCARD (string-with-'*') match be made faster while +// staying byte-for-byte behaviour-identical, including capture values? Each +// variant builds the SAME anchored, case-insensitive regexp the shipped code +// uses and only changes how a static-with-wildcard pattern decides match + +// captures. Run: +// +// node bench/wildcard-experiments.mjs +// +// A fuzzer first asserts every variant produces the IDENTICAL req.vhost object +// (match boolean, length, and every numeric capture) as the regex on thousands +// of random pattern/host pairs, so a behaviour-breaking variant fails loudly +// before any timing is reported. +// +// LESSON (kept on purpose): an earlier version of this file built the matched +// result as a plain object literal `{ host, hostname, length, 0: ... }`, while +// the contract — and `vhostof` below — build it with `Object.create(null)` plus +// incremental property assignment. The literal is a fast monomorphic shape; the +// null-prototype dictionary object is much slower to populate. That difference +// made the string-matcher variants look ~1.5x faster than they really are. Once +// every variant uses the SAME `makeVhost` (Object.create(null)) below, the +// string matchers are net-neutral-to-slower on the match path, because the +// result allocation dominates and dwarfs any saving from skipping `exec`. Always +// build the result exactly as the shipped code does when benchmarking. + +import assert from 'node:assert' + +// Build the result object exactly as the shipped code / vhostof does, so timing +// reflects the real per-request allocation cost. +function makeVhost (host, hostname, captures) { + const obj = Object.create(null) + obj.host = host + obj.hostname = hostname + obj.length = captures.length + for (let i = 0; i < captures.length; i++) obj[i] = captures[i] + return obj +} + +const length = Number(process.argv[2] || 1000000) + +const ASTERISK_REGEXP = /\*/g +const ASTERISK_REPLACE = '([^.]+)' +const END_ANCHORED_REGEXP = /(?:^|[^\\])(?:\\\\)*\$$/ +const ESCAPE_REGEXP = /([.+?^=!:${}()|[\]/\\])/g +const ESCAPE_REPLACE = '\\$1' + +function isregexp (val) { + return Object.prototype.toString.call(val) === '[object RegExp]' +} + +function isAscii (str) { + for (let i = 0; i < str.length; i++) { + if (str.charCodeAt(i) > 0x7f) return false + } + return true +} + +function hostregexp (val) { + let source = !isregexp(val) + ? String(val).replace(ESCAPE_REGEXP, ESCAPE_REPLACE).replace(ASTERISK_REGEXP, ASTERISK_REPLACE) + : val.source + if (source[0] !== '^') source = '^' + source + if (!END_ANCHORED_REGEXP.test(source)) source += '$' + return new RegExp(source, 'i') +} + +function hostnameof (host) { + if (!host) return undefined + const offset = host[0] === '[' ? host.indexOf(']') + 1 : 0 + const index = host.indexOf(':', offset) + return index !== -1 ? host.substring(0, index) : host +} + +function vhostof (host, regexp) { + const hostname = hostnameof(host) + if (!hostname) return undefined + const match = regexp.exec(hostname) + if (!match) return undefined + const obj = { host, hostname, length: match.length - 1 } + for (let i = 1; i < match.length; i++) obj[i - 1] = match[i] + return obj +} + +// --- WV0: shipped behaviour (regex exec + capture loop) ----------------- +function makeWV0 (pattern, handle) { + const regexp = hostregexp(pattern) + return function (req, res, next) { + const host = req.headers.host + if (!host) return next() + const data = vhostof(host, regexp) + if (!data) return next() + req.vhost = data + handle(req, res, next) + } +} + +// --- WV1: min-length reject prefilter, else identical regex path -------- +// Any match needs at least (sum of literal lengths) + (one char per '*'). +// A pure length check only ever REJECTS; matches fall through to the same +// regex+capture path, so behaviour is provably preserved. +function makeWV1 (pattern, handle) { + const regexp = hostregexp(pattern) + const literalLen = pattern.replace(ASTERISK_REGEXP, '').length + const stars = pattern.length - literalLen + const minLen = literalLen + stars + return function (req, res, next) { + const host = req.headers.host + if (!host) return next() + const hostname = hostnameof(host) + if (!hostname || hostname.length < minLen) return next() + const data = vhostof(host, regexp) + if (!data) return next() + req.vhost = data + handle(req, res, next) + } +} + +// --- WV2: single-'*' string matcher (ASCII-gated) + regex fallback ------ +// For exactly one '*', the pattern is PREFIX*SUFFIX -> regex +// ^PREFIX([^.]+)SUFFIX$. Both ends are anchored, so a match is fully +// determined by: hostname starts with PREFIX (case-insensitive), ends with +// SUFFIX, and the middle slice is non-empty with no '.'. The capture is exactly +// that middle slice. Gated on an ASCII pattern, and any non-ASCII byte in the +// prefix/suffix REGION of the host defers to the regex (Kelvin-style folds). +// Multi-'*' and non-ASCII patterns defer wholly to the regex. +function makeWV2 (pattern, handle) { + const regexp = hostregexp(pattern) + const star = pattern.indexOf('*') + const single = star !== -1 && pattern.indexOf('*', star + 1) === -1 + const usable = single && isAscii(pattern) + + const prefix = usable ? pattern.slice(0, star).toLowerCase() : '' + const suffix = usable ? pattern.slice(star + 1).toLowerCase() : '' + const pLen = prefix.length + const sLen = suffix.length + const minLen = pLen + sLen + 1 + + // ASCII case-insensitive compare of host[start..start+len) against `lit`. + // Returns 0 = equal, 1 = differ, 2 = non-ASCII host byte (defer to regex). + function cmpRegion (hostname, start, lit, len) { + for (let i = 0; i < len; i++) { + let c = hostname.charCodeAt(start + i) + if (c > 0x7f) return 2 + if (c >= 65 && c <= 90) c += 32 + if (c !== lit.charCodeAt(i)) return 1 + } + return 0 + } + + return function (req, res, next) { + const host = req.headers.host + if (!host) return next() + const hostname = hostnameof(host) + if (!hostname) return next() + + if (!usable) { + const data = vhostof(host, regexp) + if (!data) return next() + req.vhost = data + handle(req, res, next) + return + } + + const len = hostname.length + let data + if (len < minLen) { + data = undefined + } else { + const pc = pLen ? cmpRegion(hostname, 0, prefix, pLen) : 0 + const sc = sLen ? cmpRegion(hostname, len - sLen, suffix, sLen) : 0 + if (pc === 2 || sc === 2) { + data = vhostof(host, regexp) // non-ASCII region -> identical regex path + } else if (pc === 1 || sc === 1) { + data = undefined + } else { + // prefix/suffix match; verify the middle has no '.' + const midEnd = len - sLen + let dot = false + for (let i = pLen; i < midEnd; i++) { + if (hostname.charCodeAt(i) === 46) { dot = true; break } + } + if (dot) { + data = undefined + } else { + data = makeVhost(host, hostname, [hostname.slice(pLen, midEnd)]) + } + } + } + + if (!data) return next() + req.vhost = data + handle(req, res, next) + } +} + +// --- WV3: WV2's single-'*' matcher, comparing SUFFIX first -------------- +// Same proof as WV2, but tests the suffix region before the prefix. For host +// patterns the distinguishing label is usually the TLD/suffix side, so checking +// it first rejects most non-matches in fewer char compares. Captures identical. +function makeWV3 (pattern, handle) { + const regexp = hostregexp(pattern) + const star = pattern.indexOf('*') + const single = star !== -1 && pattern.indexOf('*', star + 1) === -1 + const usable = single && isAscii(pattern) + + const prefix = usable ? pattern.slice(0, star).toLowerCase() : '' + const suffix = usable ? pattern.slice(star + 1).toLowerCase() : '' + const pLen = prefix.length + const sLen = suffix.length + const minLen = pLen + sLen + 1 + + function cmpRegion (hostname, start, lit, len) { + for (let i = 0; i < len; i++) { + let c = hostname.charCodeAt(start + i) + if (c > 0x7f) return 2 + if (c >= 65 && c <= 90) c += 32 + if (c !== lit.charCodeAt(i)) return 1 + } + return 0 + } + + return function (req, res, next) { + const host = req.headers.host + if (!host) return next() + const hostname = hostnameof(host) + if (!hostname) return next() + + if (!usable) { + const data = vhostof(host, regexp) + if (!data) return next() + req.vhost = data + handle(req, res, next) + return + } + + const len = hostname.length + let data + if (len < minLen) { + data = undefined + } else { + const sc = sLen ? cmpRegion(hostname, len - sLen, suffix, sLen) : 0 + const pc = (sc === 0 && pLen) ? cmpRegion(hostname, 0, prefix, pLen) : sc + if (sc === 2 || pc === 2) { + data = vhostof(host, regexp) + } else if (sc === 1 || pc === 1) { + data = undefined + } else { + const midEnd = len - sLen + let dot = false + for (let i = pLen; i < midEnd; i++) { + if (hostname.charCodeAt(i) === 46) { dot = true; break } + } + data = dot ? undefined : makeVhost(host, hostname, [hostname.slice(pLen, midEnd)]) + } + } + + if (!data) return next() + req.vhost = data + handle(req, res, next) + } +} + +// --- WV4: WV1 min-length guard + WV2 single-'*' matcher combined -------- +// The universally-safe length reject first (catches short misses cheaply), +// then the single-'*' string matcher for the rest. Falls back to regex for +// multi-'*'/non-ASCII patterns and non-ASCII host regions. Captures identical. +function makeWV4 (pattern, handle) { + const regexp = hostregexp(pattern) + const star = pattern.indexOf('*') + const single = star !== -1 && pattern.indexOf('*', star + 1) === -1 + const usable = single && isAscii(pattern) + + const prefix = usable ? pattern.slice(0, star).toLowerCase() : '' + const suffix = usable ? pattern.slice(star + 1).toLowerCase() : '' + const pLen = prefix.length + const sLen = suffix.length + const minLen = pLen + sLen + 1 + + // For the regex-fallback (multi-*/non-ASCII) path, still apply min-length. + const literalLen = pattern.replace(ASTERISK_REGEXP, '').length + const stars = pattern.length - literalLen + const fallbackMinLen = literalLen + stars + + function cmpRegion (hostname, start, lit, len) { + for (let i = 0; i < len; i++) { + let c = hostname.charCodeAt(start + i) + if (c > 0x7f) return 2 + if (c >= 65 && c <= 90) c += 32 + if (c !== lit.charCodeAt(i)) return 1 + } + return 0 + } + + return function (req, res, next) { + const host = req.headers.host + if (!host) return next() + const hostname = hostnameof(host) + if (!hostname) return next() + const len = hostname.length + + if (!usable) { + if (len < fallbackMinLen) return next() + const data = vhostof(host, regexp) + if (!data) return next() + req.vhost = data + handle(req, res, next) + return + } + + if (len < minLen) return next() + + let data + const pc = pLen ? cmpRegion(hostname, 0, prefix, pLen) : 0 + const sc = sLen ? cmpRegion(hostname, len - sLen, suffix, sLen) : 0 + if (pc === 2 || sc === 2) { + data = vhostof(host, regexp) + } else if (pc === 1 || sc === 1) { + data = undefined + } else { + const midEnd = len - sLen + let dot = false + for (let i = pLen; i < midEnd; i++) { + if (hostname.charCodeAt(i) === 46) { dot = true; break } + } + data = dot ? undefined : makeVhost(host, hostname, [hostname.slice(pLen, midEnd)]) + } + + if (!data) return next() + req.vhost = data + handle(req, res, next) + } +} + +const VARIANTS = { WV0: makeWV0, WV1: makeWV1, WV2: makeWV2, WV3: makeWV3, WV4: makeWV4 } + +// --- Fuzz correctness gate: every variant === WV0 (regex) ---------------- +function resultOf (make, pattern, host) { + const req = { headers: { host } } + let captured + const mw = make(pattern, (r) => { captured = r.vhost }) + mw(req, {}, () => {}) + if (captured === undefined) return null + // Normalise to a comparable plain object (numeric keys included). + const o = { host: captured.host, hostname: captured.hostname, length: captured.length } + for (let i = 0; i < captured.length; i++) o[i] = captured[i] + return o +} + +const PATTERNS = [ + '*.example.com', 'user-*.example.com', '*-cdn.example.com', 'a*b.com', + '*.com', '*', 'x*', '*y', 'user-*.*.com', '*.*.com', 'café-*.example' +] +const ALPHABET = ['a', 'b', 'c', 'x', '-', '.', '0', 'A', 'B', 'K', 'K', 'é', ''] +function randHost (seed) { + // simple LCG so the fuzz set is deterministic across sessions + let s = seed + const rnd = () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff + const n = 1 + Math.floor(rnd() * 12) + let h = '' + for (let i = 0; i < n; i++) h += ALPHABET[Math.floor(rnd() * ALPHABET.length)] + return h +} + +let checks = 0 +for (const pattern of PATTERNS) { + for (let seed = 1; seed <= 1500; seed++) { + const host = randHost(seed * 31 + pattern.length) + const expected = resultOf (makeWV0, pattern, host) + for (const [name, make] of Object.entries(VARIANTS)) { + const got = resultOf (make, pattern, host) + assert.deepStrictEqual( + got, expected, + `${name} disagrees: pattern=${JSON.stringify(pattern)} host=${JSON.stringify(host)}\n` + + ` expected ${JSON.stringify(expected)}\n got ${JSON.stringify(got)}` + ) + checks++ + } + } +} +process.stderr.write(`fuzz gate: ${checks} comparisons OK\n`) + +// --- Timing ------------------------------------------------------------- +const res = {} +const noop = () => {} +const handle = () => {} +const reqWith = (host) => ({ headers: { host } }) + +const SCENARIOS = { + 'wildcard match': (mk) => [mk('*.example.com', handle), reqWith('foo.example.com')], + 'wildcard no-match (short)': (mk) => [mk('*.example.com', handle), reqWith('x.io')], + 'wildcard no-match (suffix)': (mk) => [mk('*.example.com', handle), reqWith('foo.example.org')], + 'wildcard match (prefix)': (mk) => [mk('user-*.example.com', handle), reqWith('user-bob.example.com')], + 'multi-star match': (mk) => [mk('*.*.com', handle), reqWith('foo.bar.com')] +} + +function timeOne (mw, req) { + const t0 = process.hrtime.bigint() + for (let i = 0; i < length; i++) { + req.vhost = undefined + mw(req, res, noop) + } + return Number(process.hrtime.bigint() - t0) / length +} + +for (const make of Object.values(VARIANTS)) { + for (const build of Object.values(SCENARIOS)) timeOne(...build(make)) +} + +const out = { length, results: {} } +for (const [vname, make] of Object.entries(VARIANTS)) { + out.results[vname] = {} + for (const [sname, build] of Object.entries(SCENARIOS)) { + out.results[vname][sname] = timeOne(...build(make)) + } +} +process.stdout.write(JSON.stringify(out) + '\n')