diff --git a/.changeset/perf-ignored-reduceplan.md b/.changeset/perf-ignored-reduceplan.md new file mode 100644 index 0000000..b171c22 --- /dev/null +++ b/.changeset/perf-ignored-reduceplan.md @@ -0,0 +1,11 @@ +--- +"watchpack": patch +--- + +perf: skip the path-separator replacement when the input has no backslash +(benchmarks measure ~35–45% less time for `ignored` matchers on POSIX paths), +fast-path single-element `ignored` arrays, and make `reducePlan`'s selection +loop walk only structurally valid candidates with an early exit when the +ideal reduction is found (measured ~20–40% faster on medium and large +plans). Adds a tinybench suite under `bench/` and a CodSpeed GitHub Actions +workflow so future regressions are caught automatically. diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..e86e647 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,40 @@ +name: Benchmarks + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + id-token: write # Required for OIDC authentication with CodSpeed + +jobs: + benchmark: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Use Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: lts/* + cache: npm + + - run: npm ci + + - name: Run benchmarks + uses: CodSpeedHQ/action@fa0c9b1770f933c1bc025c83a9b42946b102f4e6 # v4.10.4 + with: + run: npm run benchmark + mode: "simulation" diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..5d26e63 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,71 @@ +# Benchmarks + +This directory holds watchpack's performance benchmarks. The layout mirrors +[webpack/enhanced-resolve's `benchmark/`](https://github.com/webpack/enhanced-resolve/tree/main/benchmark): +each scenario lives under `cases//index.bench.mjs`, `run.mjs` discovers +every case and runs it through [tinybench](https://github.com/tinylibs/tinybench), +and the same entry point works both locally (wall-clock) and under +[CodSpeed](https://codspeed.io/) (instruction-count simulation) via the +`with-codspeed.mjs` bridge. + +## Running + +```sh +# Run every case +npm run benchmark + +# Run only cases whose directory name contains "ignored" +BENCH_FILTER=ignored npm run benchmark +# - or, equivalently - +npm run benchmark -- ignored +``` + +The output is a table of `ops/s`, mean latency, p99 latency, RME, and +sample count per registered task. When run under `CodSpeedHQ/action` the +bridge switches to instrumentation mode automatically. + +## Layout + +``` +bench/ +├── README.md +├── run.mjs # entry: discovers cases, runs them, prints a table +├── with-codspeed.mjs # tinybench <-> @codspeed/core bridge +└── cases/ + └── / + └── index.bench.mjs +``` + +Each `index.bench.mjs` exports a default `register(bench, ctx)` function +that calls `bench.add(name, fn)` one or more times. The `ctx` argument is +`{ caseName, caseDir, fixtureDir }`; `fixtureDir` points at +`cases//fixture/` (the directory is optional — only create it if you +need a real on-disk tree). + +## Writing a case + +1. Create `bench/cases//index.bench.mjs`. +2. Default-export a `register` function. +3. Pre-build expensive fixtures **outside** the benchmark callback so only + the hot path is measured. +4. Each `bench.add` body should loop over a fixed batch of inputs so the + measurement window sees enough work to be stable (tens of microseconds + minimum). +5. Avoid any non-determinism — use fixed request lists, no `Math.random`. +6. Focus one case on one scenario. If you need a materially different + shape (warm vs. cold cache, POSIX vs. Windows paths), prefer a new case + directory over piling `bench.add` calls into an existing one. + +## CodSpeed + +`bench/run.mjs` wraps the `Bench` with `withCodSpeed()`. When `CODSPEED_*` +env vars are absent the wrapper returns the bench untouched and tinybench's +built-in timing is used. Under `CodSpeedHQ/action` with +`mode: "simulation"` the wrapper overrides `bench.run` to call the +instrumentation hooks once per task, which produces reproducible +instruction-count measurements independent of the runner's load. + +The `@codspeed/tinybench-plugin` package is intentionally not used here: +it reads private tinybench v6 Task fields and breaks in simulation mode. +Both webpack and enhanced-resolve ran into the same issue and wrote their +own bridges; `with-codspeed.mjs` is ported from enhanced-resolve's. diff --git a/benchmark/cases/ignored-cross-platform/index.bench.mjs b/benchmark/cases/ignored-cross-platform/index.bench.mjs new file mode 100644 index 0000000..8a0c92d --- /dev/null +++ b/benchmark/cases/ignored-cross-platform/index.bench.mjs @@ -0,0 +1,87 @@ +/* + * ignored-cross-platform + * + * Stress-tests the separator-normalization path in the `ignored` matcher. + * The matcher must handle native Windows paths (with backslashes), paths + * that have already been normalized to POSIX, and mixed bags produced by + * cross-platform tools. This case isolates those scenarios from the plain + * POSIX batch measured by `ignored-match` so the backslash-heavy code path + * has its own CodSpeed trend line. + */ + +import Watchpack from "../../../lib/index.js"; + +const UNIX_PATHS = [ + "/home/user/project/src/index.js", + "/home/user/project/src/components/App.jsx", + "/home/user/project/node_modules/react/index.js", + "/home/user/project/dist/bundle.js", + "/home/user/project/.git/HEAD", + "/home/user/project/coverage/lcov.info", + "/home/user/project/src/utils/helpers.ts", + "/home/user/project/test/fixtures/a.js", + "/home/user/project/README.md", + "/home/user/project/package.json", +]; + +const WINDOWS_PATHS = UNIX_PATHS.map((path) => path.replace(/\//g, "\\")); +const MIXED_PATHS = UNIX_PATHS.map((path, i) => + i % 2 === 0 ? path : path.replace(/\//g, "\\"), +); + +// Simulates a monorepo deep-path batch: each path has ~17 segments so the +// regex has to scan a long string before committing to match/no-match. +const DEEP_PATHS = Array.from({ length: 10 }, (_, i) => { + const segments = Array.from({ length: 15 }, (_, j) => `level${j}`); + segments.push(i === 3 ? "node_modules" : `leaf${i}`); + segments.push("index.js"); + return `/${segments.join("/")}`; +}); + +/** + * @param {import("../../../lib/index").WatchOptions} options options + * @returns {(item: string) => boolean} true when ignored, otherwise false + */ +const buildIgnored = (options) => new Watchpack(options).watcherOptions.ignored; + +const LARGE_ARRAY_IGNORED = [ + "**/node_modules", + "**/.git", + "**/dist", + "**/build", + "**/coverage", + "**/.cache", + "**/.next", + "**/.nuxt", + "**/tmp", + "**/*.log", +]; + +/** + * @param {import("tinybench").Bench} bench bench + */ +export default function register(bench) { + const regexpMatcher = buildIgnored({ + ignored: /node_modules|\.git|dist|coverage/, + }); + const arrayMatcher = buildIgnored({ ignored: LARGE_ARRAY_IGNORED }); + + bench.add("ignored-cross-platform: regex against windows paths", () => { + for (const path of WINDOWS_PATHS) regexpMatcher(path); + }); + bench.add("ignored-cross-platform: regex against mixed separators", () => { + for (const path of MIXED_PATHS) regexpMatcher(path); + }); + bench.add("ignored-cross-platform: regex against deep posix paths", () => { + for (const path of DEEP_PATHS) regexpMatcher(path); + }); + bench.add("ignored-cross-platform: array[10] against windows paths", () => { + for (const path of WINDOWS_PATHS) arrayMatcher(path); + }); + bench.add( + "ignored-cross-platform: array[10] against deep posix paths", + () => { + for (const path of DEEP_PATHS) arrayMatcher(path); + }, + ); +} diff --git a/benchmark/cases/ignored-match/index.bench.mjs b/benchmark/cases/ignored-match/index.bench.mjs new file mode 100644 index 0000000..c2cd3a4 --- /dev/null +++ b/benchmark/cases/ignored-match/index.bench.mjs @@ -0,0 +1,86 @@ +/* + * ignored-match + * + * Exercises the `ignored` matcher against a realistic POSIX path batch for + * every supported option shape (regex, glob string, short/long array, plain + * predicate, and the "no ignored option" no-op fast path). The matcher is + * what webpack hits per file for every watched entry, so the shape of this + * batch dominates per-rebuild time in large projects. + */ + +import Watchpack from "../../../lib/index.js"; + +const UNIX_PATHS = [ + "/home/user/project/src/index.js", + "/home/user/project/src/components/App.jsx", + "/home/user/project/node_modules/react/index.js", + "/home/user/project/dist/bundle.js", + "/home/user/project/.git/HEAD", + "/home/user/project/coverage/lcov.info", + "/home/user/project/src/utils/helpers.ts", + "/home/user/project/test/fixtures/a.js", + "/home/user/project/README.md", + "/home/user/project/package.json", +]; + +/** + * Reach into a Watchpack instance to get at the normalized matcher without + * duplicating the option-compilation logic. + * @param {import("../../../lib/index").WatchOptions} options options + * @returns {(item: string) => boolean} true when ignored, otherwise false + */ +const buildIgnored = (options) => new Watchpack(options).watcherOptions.ignored; + +/** + * @param {import("tinybench").Bench} bench bench + */ +export default function register(bench) { + const noneMatcher = buildIgnored({}); + const regexpMatcher = buildIgnored({ + ignored: /node_modules|\.git|dist|coverage/, + }); + const stringMatcher = buildIgnored({ ignored: "**/node_modules" }); + const singletonArrayMatcher = buildIgnored({ ignored: ["**/node_modules"] }); + const smallArrayMatcher = buildIgnored({ + ignored: ["**/node_modules", "**/.git"], + }); + const largeArrayMatcher = buildIgnored({ + ignored: [ + "**/node_modules", + "**/.git", + "**/dist", + "**/build", + "**/coverage", + "**/.cache", + "**/.next", + "**/.nuxt", + "**/tmp", + "**/*.log", + ], + }); + const functionMatcher = buildIgnored({ + ignored: (path) => path.includes("node_modules") || path.includes(".git"), + }); + + bench.add("ignored-match: no ignored option (noop fast path)", () => { + for (const path of UNIX_PATHS) noneMatcher(path); + }); + bench.add("ignored-match: regex matcher", () => { + for (const path of UNIX_PATHS) regexpMatcher(path); + }); + bench.add("ignored-match: glob string matcher", () => { + for (const path of UNIX_PATHS) stringMatcher(path); + }); + bench.add("ignored-match: array[1] matcher", () => { + for (const path of UNIX_PATHS) singletonArrayMatcher(path); + }); + bench.add("ignored-match: array[2] matcher", () => { + for (const path of UNIX_PATHS) smallArrayMatcher(path); + }); + bench.add("ignored-match: array[10] matcher", () => { + for (const path of UNIX_PATHS) largeArrayMatcher(path); + }); + bench.add("ignored-match: function matcher", () => { + for (const path of UNIX_PATHS) functionMatcher(path); + }); +} diff --git a/benchmark/cases/link-resolver/index.bench.mjs b/benchmark/cases/link-resolver/index.bench.mjs new file mode 100644 index 0000000..96df73a --- /dev/null +++ b/benchmark/cases/link-resolver/index.bench.mjs @@ -0,0 +1,70 @@ +/* + * link-resolver + * + * LinkResolver resolves directory + file paths while expanding any symlinks + * along the way. Webpack hits it on every watched entry before asking the + * platform's fs.watch, so both the cold (first seen) and warm (cache hit) + * paths matter: the former bounds worst-case rebuild latency, the latter + * bounds the per-file cost of the steady state. + * + * To keep the benchmark deterministic we aim the resolver at a path that + * does not exist. readlinkSync throws ENOENT, LinkResolver catches it + * silently and walks its cache / parent chain exactly like it would for a + * real path — without any filesystem side effects that would make CodSpeed + * instrumentation noisy. + */ + +import LinkResolver from "../../../lib/LinkResolver.js"; + +const SEP = process.platform === "win32" ? "\\" : "/"; +const ROOT = + process.platform === "win32" ? "C:\\nonexistent_bench" : "/nonexistent_bench"; + +const makePath = (depth) => { + let path = ROOT; + for (let i = 0; i < depth; i++) path += `${SEP}level${i}`; + return path; +}; + +const shallowPaths = Array.from( + { length: 100 }, + (_, i) => `${ROOT}${SEP}file${i}`, +); +const mediumPaths = Array.from( + { length: 100 }, + (_, i) => `${makePath(5)}${SEP}file${i}`, +); +const deepPaths = Array.from( + { length: 50 }, + (_, i) => `${makePath(15)}${SEP}file${i}`, +); + +/** + * @param {import("tinybench").Bench} bench bench + */ +export default function register(bench) { + // Pre-populated resolver for the warm/cache-hit measurements. + const warmResolver = new LinkResolver(); + for (const path of shallowPaths) warmResolver.resolve(path); + for (const path of mediumPaths) warmResolver.resolve(path); + for (const path of deepPaths) warmResolver.resolve(path); + + bench.add("link-resolver: cold shallow batch (depth=1, n=100)", () => { + const resolver = new LinkResolver(); + for (const path of shallowPaths) resolver.resolve(path); + }); + bench.add("link-resolver: cold medium batch (depth=5, n=100)", () => { + const resolver = new LinkResolver(); + for (const path of mediumPaths) resolver.resolve(path); + }); + bench.add("link-resolver: cold deep batch (depth=15, n=50)", () => { + const resolver = new LinkResolver(); + for (const path of deepPaths) resolver.resolve(path); + }); + bench.add("link-resolver: warm shallow batch (cache hit, n=100)", () => { + for (const path of shallowPaths) warmResolver.resolve(path); + }); + bench.add("link-resolver: warm deep batch (cache hit, n=50)", () => { + for (const path of deepPaths) warmResolver.resolve(path); + }); +} diff --git a/benchmark/cases/reduce-plan-deep/index.bench.mjs b/benchmark/cases/reduce-plan-deep/index.bench.mjs new file mode 100644 index 0000000..187c5c3 --- /dev/null +++ b/benchmark/cases/reduce-plan-deep/index.bench.mjs @@ -0,0 +1,50 @@ +/* + * reduce-plan-deep + * + * Deeply nested path plans exercise the parent-chain walk in reducePlan's + * tree-building pass (each leaf bubbles its entry count up an arbitrarily + * long ancestor list) and the subtree-deactivation walk during reduction + * (merging near the root has to mark every descendant inactive). + * + * `depth=30, leaves=3` approximates a monorepo with a handful of files at + * every level; `depth=80, leaves=2` is a synthetic worst case with a long + * spine and very few siblings per rung. + */ + +import reducePlan from "../../../lib/reducePlan.js"; + +const ROOT = process.platform === "win32" ? "C:\\root" : "/root"; +const SEP = process.platform === "win32" ? "\\" : "/"; + +/** + * @param {number} depth directory depth + * @param {number} leaves leaves per level + * @returns {Map} plan + */ +const buildDeepPlan = (depth, leaves) => { + const plan = new Map(); + let i = 0; + const walk = (prefix, level) => { + for (let l = 0; l < leaves; l++) { + plan.set(`${prefix}${SEP}file${i}`, `v${i++}`); + } + if (level < depth) walk(`${prefix}${SEP}sub${level}`, level + 1); + }; + walk(ROOT, 0); + return plan; +}; + +const deepPlan = buildDeepPlan(30, 3); +const veryDeepPlan = buildDeepPlan(80, 2); + +/** + * @param {import("tinybench").Bench} bench bench + */ +export default function register(bench) { + bench.add("reduce-plan-deep: depth=30, leaves=3, limit=20", () => { + reducePlan(deepPlan, 20); + }); + bench.add("reduce-plan-deep: depth=80, leaves=2, limit=40", () => { + reducePlan(veryDeepPlan, 40); + }); +} diff --git a/benchmark/cases/reduce-plan-fast-path/index.bench.mjs b/benchmark/cases/reduce-plan-fast-path/index.bench.mjs new file mode 100644 index 0000000..0a7a393 --- /dev/null +++ b/benchmark/cases/reduce-plan-fast-path/index.bench.mjs @@ -0,0 +1,52 @@ +/* + * reduce-plan-fast-path + * + * Measures the cheap paths through reducePlan: plans that are already under + * the limit (no reduction needed at all) and plans that are only slightly + * over the limit (the while loop runs a handful of times). These should be + * dominated by tree construction + the final "write down new plan" pass, + * not by the selection loop. + * + * Keeping this isolated from the heavy cases means a regression in the + * setup cost (e.g. accidentally quadratic tree building) shows up on its + * own trend line instead of hiding under a 10ms huge-plan benchmark. + */ + +import reducePlan from "../../../lib/reducePlan.js"; + +const ROOT = process.platform === "win32" ? "C:\\root" : "/root"; +const SEP = process.platform === "win32" ? "\\" : "/"; + +/** + * @param {number} count number of leaf targets + * @param {number} width branching factor per directory + * @returns {Map} plan + */ +const buildWidePlan = (count, width) => { + const plan = new Map(); + let i = 0; + let dir = 0; + while (i < count) { + const group = `${ROOT}${SEP}group${dir}`; + for (let j = 0; j < width && i < count; j++, i++) { + plan.set(`${group}${SEP}file${i}`, `v${i}`); + } + dir++; + } + return plan; +}; + +const smallPlan = buildWidePlan(50, 10); +const mediumPlan = buildWidePlan(500, 20); + +/** + * @param {import("tinybench").Bench} bench bench + */ +export default function register(bench) { + bench.add("reduce-plan-fast-path: under limit (n=50, limit=100)", () => { + reducePlan(smallPlan, 100); + }); + bench.add("reduce-plan-fast-path: barely over (n=500, limit=499)", () => { + reducePlan(mediumPlan, 499); + }); +} diff --git a/benchmark/cases/reduce-plan-flat/index.bench.mjs b/benchmark/cases/reduce-plan-flat/index.bench.mjs new file mode 100644 index 0000000..10b0326 --- /dev/null +++ b/benchmark/cases/reduce-plan-flat/index.bench.mjs @@ -0,0 +1,41 @@ +/* + * reduce-plan-flat + * + * Lots of sibling entries under a single directory: every leaf shares the + * same parent, so the tree is one level deep and the candidate-selection + * loop only has a single viable merge target per round. This distribution + * shows up in projects that watch many files in one folder (e.g. a single + * "pages" or "generated" directory with hundreds of entries). + */ + +import reducePlan from "../../../lib/reducePlan.js"; + +const ROOT = process.platform === "win32" ? "C:\\root" : "/root"; +const SEP = process.platform === "win32" ? "\\" : "/"; + +/** + * @param {number} count number of targets + * @returns {Map} plan + */ +const buildFlatPlan = (count) => { + const plan = new Map(); + for (let i = 0; i < count; i++) { + plan.set(`${ROOT}${SEP}file${i}`, `v${i}`); + } + return plan; +}; + +const flatMediumPlan = buildFlatPlan(500); +const flatLargePlan = buildFlatPlan(5000); + +/** + * @param {import("tinybench").Bench} bench bench + */ +export default function register(bench) { + bench.add("reduce-plan-flat: n=500 in one dir, limit=50", () => { + reducePlan(flatMediumPlan, 50); + }); + bench.add("reduce-plan-flat: n=5000 in one dir, limit=100", () => { + reducePlan(flatLargePlan, 100); + }); +} diff --git a/benchmark/cases/reduce-plan-wide/index.bench.mjs b/benchmark/cases/reduce-plan-wide/index.bench.mjs new file mode 100644 index 0000000..5e1c009 --- /dev/null +++ b/benchmark/cases/reduce-plan-wide/index.bench.mjs @@ -0,0 +1,65 @@ +/* + * reduce-plan-wide + * + * Exercises reducePlan on "wide" plans — lots of sibling leaves bucketed + * under shared parent directories. This is the distribution webpack creates + * when many watchers share a root but fan out into sub-trees, and it's the + * stress pattern for the selection loop: every iteration of the outer + * `while (currentCount > limit)` must scan the candidate set to find the + * best node to merge. + * + * Three magnitudes: small (50), medium (500), large (2000), huge (10000). + */ + +import reducePlan from "../../../lib/reducePlan.js"; + +const ROOT = process.platform === "win32" ? "C:\\root" : "/root"; +const SEP = process.platform === "win32" ? "\\" : "/"; + +/** + * @param {number} count number of leaf targets + * @param {number} width branching factor per directory + * @returns {Map} plan + */ +const buildWidePlan = (count, width) => { + const plan = new Map(); + let i = 0; + let dir = 0; + while (i < count) { + const group = `${ROOT}${SEP}group${dir}`; + for (let j = 0; j < width && i < count; j++, i++) { + plan.set(`${group}${SEP}file${i}`, `v${i}`); + } + dir++; + } + return plan; +}; + +const smallPlan = buildWidePlan(50, 10); +const mediumPlan = buildWidePlan(500, 20); +const largePlan = buildWidePlan(2000, 25); +const hugePlan = buildWidePlan(10000, 40); + +/** + * @param {import("tinybench").Bench} bench bench + */ +export default function register(bench) { + bench.add("reduce-plan-wide: small plan (n=50, limit=10)", () => { + reducePlan(smallPlan, 10); + }); + bench.add("reduce-plan-wide: medium plan (n=500, limit=50)", () => { + reducePlan(mediumPlan, 50); + }); + bench.add("reduce-plan-wide: medium light (n=500, limit=400)", () => { + reducePlan(mediumPlan, 400); + }); + bench.add("reduce-plan-wide: large plan (n=2000, limit=100)", () => { + reducePlan(largePlan, 100); + }); + bench.add("reduce-plan-wide: large aggressive (n=2000, limit=10)", () => { + reducePlan(largePlan, 10); + }); + bench.add("reduce-plan-wide: huge plan (n=10000, limit=500)", () => { + reducePlan(hugePlan, 500); + }); +} diff --git a/benchmark/cases/watchpack-construction/index.bench.mjs b/benchmark/cases/watchpack-construction/index.bench.mjs new file mode 100644 index 0000000..79dc12d --- /dev/null +++ b/benchmark/cases/watchpack-construction/index.bench.mjs @@ -0,0 +1,64 @@ +/* + * watchpack-construction + * + * Webpack creates and tears down a Watchpack instance at module boundaries + * and on every dev-server restart. The constructor compiles the `ignored` + * matcher, installs it on a WeakMap-keyed options cache, and wires up the + * internal `WatcherManager`. This case measures the construction cost for + * each supported `ignored` option shape so any regression in that setup + * path surfaces before it reaches webpack users. + * + * `.close()` is invoked after every construction to keep the outer test + * process from leaking timers even though no watchers have been attached. + */ + +import Watchpack from "../../../lib/index.js"; + +const optionsNone = {}; +const optionsWithRegExp = { ignored: /node_modules|\.git/ }; +const optionsWithString = { ignored: "**/node_modules" }; +const optionsWithSmallArray = { ignored: ["**/node_modules", "**/.git"] }; +const optionsWithLargeArray = { + ignored: [ + "**/node_modules", + "**/.git", + "**/dist", + "**/build", + "**/coverage", + "**/.cache", + "**/.next", + "**/.nuxt", + "**/tmp", + "**/*.log", + ], +}; +const optionsWithFn = { ignored: (path) => path.includes("node_modules") }; + +/** + * @param {import("tinybench").Bench} bench bench + */ +export default function register(bench) { + bench.add("watchpack-construction: no ignored option", () => { + new Watchpack(optionsNone).close(); + }); + bench.add("watchpack-construction: regex ignored", () => { + new Watchpack(optionsWithRegExp).close(); + }); + bench.add("watchpack-construction: glob string ignored", () => { + new Watchpack(optionsWithString).close(); + }); + bench.add("watchpack-construction: array[2] ignored", () => { + new Watchpack(optionsWithSmallArray).close(); + }); + bench.add("watchpack-construction: array[10] ignored", () => { + new Watchpack(optionsWithLargeArray).close(); + }); + bench.add("watchpack-construction: function ignored", () => { + new Watchpack(optionsWithFn).close(); + }); + bench.add("watchpack-construction: cached options (WeakMap hit)", () => { + // Same options object every iteration exercises the WeakMap cache + // installed on the options by `cachedNormalizeOptions`. + new Watchpack(optionsWithLargeArray).close(); + }); +} diff --git a/benchmark/run.mjs b/benchmark/run.mjs new file mode 100644 index 0000000..2d68ecd --- /dev/null +++ b/benchmark/run.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/* + * Benchmark entry point for enhanced-resolve. + * + * Discovers every directory under ./cases/ that contains an `index.bench.mjs` + * file, calls its default-exported `register(bench, ctx)` function to + * populate tinybench tasks, then runs them all. + * + * The bench is wrapped with a local `withCodSpeed()` bridge (ported from + * webpack) so the same entry point works for: + * - local development (`npm run benchmark`) -> wall-clock measurements + * printed to the terminal; the wrapper detects that CodSpeed is not + * active and returns the bench untouched + * - CI under CodSpeedHQ/action -> the wrapper switches to instrumentation + * mode automatically and results are uploaded to codspeed.io + * + * See ./README.md for the layout of individual cases. + */ + +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath, pathToFileURL } from "url"; +import { Bench, hrtimeNow } from "tinybench"; +import { withCodSpeed } from "./with-codspeed.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const casesPath = path.join(__dirname, "cases"); + +/** + * Filter expression from CLI or env (e.g. `npm run benchmark -- realistic`). + * A case is included if its directory name contains this substring. Empty + * means "include everything". + */ +const filter = process.env.BENCH_FILTER || process.argv[2] || ""; + +const bench = withCodSpeed( + new Bench({ + name: "enhanced-resolve", + now: hrtimeNow, + throws: true, + warmup: true, + warmupIterations: 2, + // Kept deliberately low: each task's body already loops over many + // resolve calls, and we want wall-clock runs to finish in a few + // seconds. CodSpeed's simulation mode ignores this and instruments + // exactly one iteration per task. + iterations: 10, + }), +); + +const caseDirs = (await fs.readdir(casesPath, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => !filter || name.includes(filter)) + .sort(); + +if (caseDirs.length === 0) { + console.error( + filter + ? `No benchmark cases matched filter "${filter}"` + : "No benchmark cases found", + ); + process.exit(1); +} + +for (const caseName of caseDirs) { + const benchFile = path.join(casesPath, caseName, "index.bench.mjs"); + try { + await fs.access(benchFile); + } catch { + console.warn(`[skip] ${caseName}: no index.bench.mjs`); + continue; + } + const mod = await import(pathToFileURL(benchFile).href); + if (typeof mod.default !== "function") { + throw new Error( + `${caseName}/index.bench.mjs must export a default function`, + ); + } + await mod.default(bench, { + caseName, + caseDir: path.join(casesPath, caseName), + fixtureDir: path.join(casesPath, caseName, "fixture"), + }); + console.log(`Registered: ${caseName}`); +} + +console.log(`\nRunning ${bench.tasks.length} tasks...\n`); +await bench.run(); + +// Pretty-print results. Kept simple on purpose — CodSpeed uploads its own +// data in CI; this table is for humans running locally. +const rows = bench.tasks.map((task) => { + const r = task.result; + if (!r) return { name: task.name, status: "no result" }; + // tinybench v6 result shape: latency/throughput objects, no top-level + // hz / samples. `latency` fields are in ms already. + const lat = r.latency; + const tp = r.throughput; + return { + name: task.name, + "ops/s": tp?.mean?.toFixed(2) ?? "n/a", + "mean (ms)": lat?.mean?.toFixed(4) ?? "n/a", + "p99 (ms)": lat?.p99?.toFixed(4) ?? "n/a", + "rme (%)": lat?.rme?.toFixed(2) ?? "n/a", + samples: lat?.samplesCount ?? 0, + }; +}); +console.log(); +console.table(rows); + +// Exit non-zero if any task threw, so CI picks it up. +const failed = bench.tasks.filter((t) => t.result?.error); +if (failed.length > 0) { + console.error(`\n${failed.length} task(s) errored:`); + for (const t of failed) { + console.error(` - ${t.name}: ${t.result?.error?.message}`); + } + process.exit(1); +} diff --git a/benchmark/with-codspeed.mjs b/benchmark/with-codspeed.mjs new file mode 100644 index 0000000..b07e400 --- /dev/null +++ b/benchmark/with-codspeed.mjs @@ -0,0 +1,184 @@ +/* + * CodSpeed <-> tinybench bridge for watchpack benchmarks. + * + * Ported from webpack/enhanced-resolve's benchmark/with-codspeed.mjs, which + * itself is derived from webpack's test/BenchmarkTestCases.benchmark.mjs. + * + * Why not @codspeed/tinybench-plugin? + * That package accesses tinybench Task internals (task.fn, task.fnOpts) + * that were made private in tinybench v6, causing a TypeError in + * simulation mode. webpack and enhanced-resolve hit the same issue and use + * @codspeed/core directly — we follow their lead. + * + * Modes (via getCodspeedRunnerMode() from @codspeed/core): + * "disabled" — returns the bench untouched (local runs) + * "simulation" — overrides bench.run/runSync for CodSpeed instrumentation + * "walltime" — left untouched; tinybench's built-in timing is used + */ + +import path from "path"; +import { fileURLToPath } from "url"; +import { + InstrumentHooks, + getCodspeedRunnerMode, + setupCore, + teardownCore, +} from "@codspeed/core"; + +/** @typedef {import("tinybench").Bench} Bench */ +/** @typedef {() => unknown | Promise} Fn */ + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +/** + * Capture the file that invoked bench.add() so we can build a stable URI + * for CodSpeed to identify the benchmark. + * @returns {string} calling file path relative to the repo root + */ +function getCallingFile() { + const dummy = {}; + const prev = Error.prepareStackTrace; + const prevLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 10; + Error.prepareStackTrace = (_err, trace) => trace; + Error.captureStackTrace(dummy, getCallingFile); + const trace = /** @type {NodeJS.CallSite[]} */ ( + /** @type {{ stack: unknown }} */ (dummy).stack + ); + Error.prepareStackTrace = prev; + Error.stackTraceLimit = prevLimit; + + let file = /** @type {string} */ (trace[1].getFileName() || ""); + if (file.startsWith("file://")) file = fileURLToPath(file); + if (!file) return ""; + return path.relative(repoRoot, file); +} + +// eslint-disable-next-line jsdoc/require-property +/** @typedef {object} EXPECTED_OBJECT */ + +/** + * @typedef {{ uri: string, fn: Fn, opts: EXPECTED_OBJECT | undefined }} TaskMeta + */ + +/** + * @type {WeakMap>} + */ +const metaMap = new WeakMap(); + +/** + * @param {Bench} bench bench + * @returns {Map} task meta + */ +function getOrCreateMeta(bench) { + let m = metaMap.get(bench); + if (!m) { + m = new Map(); + metaMap.set(bench, m); + } + return m; +} + +/** + * Wrap a tinybench Bench so that CodSpeed simulation mode instruments each + * task. In "disabled" and "walltime" modes the bench is returned as-is. + * @param {Bench} bench bench + * @returns {Bench} bench + */ +export function withCodSpeed(bench) { + const mode = getCodspeedRunnerMode(); + if (mode === "disabled" || mode === "walltime") return bench; + + // --- simulation mode --- + + const meta = getOrCreateMeta(bench); + const rawAdd = bench.add.bind(bench); + + bench.add = (name, fn, opts) => { + const callingFile = getCallingFile(); + const uri = `${callingFile}::${name}`; + meta.set(name, { uri, fn, opts }); + return rawAdd(name, fn, opts); + }; + + const setup = () => { + setupCore(); + console.log("[CodSpeed] running in simulation mode"); + }; + + const teardown = () => { + teardownCore(); + console.log(`[CodSpeed] Done running ${bench.tasks.length} benches.`); + return bench.tasks; + }; + + /** + * @param {Fn} fn function + * @param {boolean} isAsync true when is async, otherwise false + * @returns {Fn} wrapper function + */ + const wrapFrame = (fn, isAsync) => { + if (isAsync) { + // eslint-disable-next-line camelcase + return async function __codspeed_root_frame__() { + await fn(); + }; + } + // eslint-disable-next-line camelcase + return function __codspeed_root_frame__() { + fn(); + }; + }; + + bench.run = async () => { + setup(); + for (const task of bench.tasks) { + const m = /** @type {TaskMeta} */ (meta.get(task.name)); + + // Warm-up: run the body a few times to stabilise caches / JIT. + for (let i = 0; i < bench.iterations - 1; i++) { + await m.fn(); + } + + // Instrumented run. + global.gc?.(); + InstrumentHooks.startBenchmark(); + await wrapFrame(m.fn, true)(); + InstrumentHooks.stopBenchmark(); + InstrumentHooks.setExecutedBenchmark(process.pid, m.uri); + + console.log( + `[CodSpeed] ${ + InstrumentHooks.isInstrumented() ? "Measured" : "Checked" + } ${m.uri}`, + ); + } + return teardown(); + }; + + bench.runSync = () => { + setup(); + for (const task of bench.tasks) { + const m = /** @type {TaskMeta} */ (meta.get(task.name)); + for (let i = 0; i < bench.iterations - 1; i++) { + m.fn(); + } + global.gc?.(); + InstrumentHooks.startBenchmark(); + wrapFrame(m.fn, false)(); + InstrumentHooks.stopBenchmark(); + InstrumentHooks.setExecutedBenchmark(process.pid, m.uri); + console.log( + `[CodSpeed] ${ + InstrumentHooks.isInstrumented() ? "Measured" : "Checked" + } ${m.uri}`, + ); + } + return teardown(); + }; + + return bench; +} diff --git a/eslint.config.mjs b/eslint.config.mjs index ed6c60d..bb980e6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -41,4 +41,17 @@ export default defineConfig([ "n/prefer-node-protocol": "off", }, }, + { + files: ["benchmark/**/*"], + languageOptions: { + ecmaVersion: "latest", + }, + rules: { + "no-console": "off", + "n/hashbang": "off", + "n/no-unsupported-features/node-builtins": "off", + "n/no-unsupported-features/es-syntax": "off", + "n/no-process-exit": "off", + }, + }, ]); diff --git a/package-lock.json b/package-lock.json index 9f70a8c..9c748ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "@changesets/cli": "^2.30.0", "@changesets/get-github-info": "^0.8.0", + "@codspeed/core": "^5.2.0", "@types/glob-to-regexp": "^0.4.4", "@types/graceful-fs": "^4.1.9", "@types/jest": "^30.0.0", @@ -23,6 +24,7 @@ "eslint-config-webpack": "^4.9.3", "jest": "^30.3.0", "prettier": "^3.7.4", + "tinybench": "^6.0.0", "typescript": "^6.0.2", "write-file-atomic": "^7.0.1" }, @@ -61,7 +63,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -872,6 +873,107 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/@codspeed/core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@codspeed/core/-/core-5.2.0.tgz", + "integrity": "sha512-CmDhpWjcOJg2iBOQ/BmBnSBq8qxlM3r4h8uvYDkoUaba+EKRT3T73BZtKuml/48jZMsB+4/FG2UbTBinDWtuvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.4.0", + "find-up": "^6.3.0", + "form-data": "^4.0.4", + "node-gyp-build": "^4.6.0" + } + }, + "node_modules/@codspeed/core/node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@codspeed/core/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@codspeed/core/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@codspeed/core/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@codspeed/core/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@codspeed/core/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -2379,7 +2481,6 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2421,7 +2522,6 @@ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", @@ -2461,7 +2561,6 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -2981,7 +3080,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3292,6 +3390,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3308,6 +3413,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/babel-jest": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", @@ -3481,7 +3598,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3779,6 +3895,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -4003,6 +4132,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4369,7 +4508,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4446,7 +4584,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5278,6 +5415,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -5324,6 +5482,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -6547,7 +6722,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -8464,6 +8638,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -8593,6 +8790,18 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9176,7 +9385,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9247,6 +9455,16 @@ "dev": true, "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -10254,6 +10472,16 @@ "node": ">=8" } }, + "node_modules/tinybench": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.0.0.tgz", + "integrity": "sha512-BWlWpVbbZXaYjRV0twGLNQO00Zj4HA/sjLOQP2IvzQqGwRGp+2kh7UU3ijyJ3ywFRogYDRbiHDMrUOfaMnN56g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -10508,7 +10736,6 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10992,7 +11219,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 42bc9f8..11c8889 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test:only": "npm run test:base", "test:watch": "npm run test:base -- --watch", "test:coverage": "npm run test:base -- --collectCoverageFrom=\"lib/**/*.js\" --coverage", + "benchmark": "node --max-old-space-size=4096 --hash-seed=1 --random-seed=1 --no-opt --predictable --predictable-gc-schedule --interpreted-frames-native-stack --allow-natives-syntax --expose-gc --no-concurrent-sweeping ./benchmark/run.mjs", "version": "changeset version", "release": "changeset publish" }, @@ -49,6 +50,7 @@ "devDependencies": { "@changesets/cli": "^2.30.0", "@changesets/get-github-info": "^0.8.0", + "@codspeed/core": "^5.2.0", "@types/glob-to-regexp": "^0.4.4", "@types/graceful-fs": "^4.1.9", "@types/jest": "^30.0.0", @@ -57,6 +59,7 @@ "eslint-config-webpack": "^4.9.3", "jest": "^30.3.0", "prettier": "^3.7.4", + "tinybench": "^6.0.0", "typescript": "^6.0.2", "write-file-atomic": "^7.0.1" },