diff --git a/.changeset/perf-tap-registration.md b/.changeset/perf-tap-registration.md new file mode 100644 index 0000000..5d12ae6 --- /dev/null +++ b/.changeset/perf-tap-registration.md @@ -0,0 +1,33 @@ +--- +"tapable": patch +--- + +Perf: reduce allocations and work on the tap registration and compile paths. + +- `Hook#_tap` builds the final tap descriptor in a single allocation for the + common `hook.tap("name", fn)` string-options case instead of creating an + intermediate `{ name }` object that was then merged via `Object.assign`. +- `Hook#_insert` takes an O(1) fast path for the common append case (no + `before`, and stage consistent with the last tap) - the previous + implementation always ran the shift loop once. +- `Hook#_runRegisterInterceptors` early-returns when there are no + interceptors and uses an indexed loop instead of `for…of`. +- `HookMap#for` inlines the `_map.get` lookup instead of routing through + `this.get(key)`, saving a method dispatch on a path hit once per hook + access in consumers like webpack. +- `HookCodeFactory#setup` builds `_x` with a preallocated array + explicit + loop instead of `Array.prototype.map`. +- `HookCodeFactory#init` uses `Array.prototype.slice` instead of spread to + skip the iterator protocol. +- `HookCodeFactory#args` memoizes the common no-before/no-after result so + arguments are joined once per compile rather than once per tap. +- `HookCodeFactory#needContext`, `callTapsSeries`, `callTapsParallel` and + `MultiHook`'s iteration use indexed loops with cached length, and the + series/parallel code hoists the per-tap `done`/`doneBreak` closures + out of the compile-time loop. Replaces `Array.prototype.findIndex` + with a local loop to avoid callback allocation. + +Registering 10 taps on a `SyncHook` is roughly 2× faster, +`SyncHook: tap 5 + first call (compile)` is ~15% faster, and +`HookMap#for (existing key)` is ~6% faster in the micro-benchmarks. +The `.call()` path is unchanged. 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..2409aa5 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,95 @@ +# Benchmarks + +Performance benchmarks for `tapable`, tracked over time via +[CodSpeed](https://codspeed.io/). + +Runner stack: [tinybench](https://github.com/tinylibs/tinybench) + +[`@codspeed/core`](https://www.npmjs.com/package/@codspeed/core) with a local +`withCodSpeed()` wrapper ported from webpack's +`test/BenchmarkTestCases.benchmark.mjs` (via enhanced-resolve). Locally it +falls back to plain tinybench wall-clock measurements, and under +`CodSpeedHQ/action` in CI it automatically switches to CodSpeed's +instrumentation mode. + +## Running locally + +```sh +npm run benchmark +``` + +Optional substring filter to run only matching cases: + +```sh +npm run benchmark -- sync +BENCH_FILTER=async-parallel npm run benchmark +``` + +Locally the runner uses tinybench's wall-clock measurements and prints a +table of ops/s, mean, p99, and relative margin of error per task. Under CI, +the bridge detects the CodSpeed runner environment and switches to +instruction-counting mode automatically. + +The V8 flags in `package.json` (`--no-opt --predictable --hash-seed=1` etc.) +are required by CodSpeed's instrumentation mode for deterministic results — +do not drop them. + +### Optional: running real instruction counts locally + +If you want to reproduce CI's exact instrument-count numbers on your own +machine (Linux only — the underlying Valgrind tooling has no macOS backend), +install the standalone CodSpeed CLI and wrap `npm run benchmark` with it: + +```sh +curl -fsSL https://codspeed.io/install.sh | bash +codspeed run npm run benchmark +``` + +This is only useful if you want to debug an instruction-count regression +outside CI. Day-to-day benchmark iteration should use `npm run benchmark` +directly (wall-clock mode). + +## Layout + +``` +benchmark/ +├── run.mjs # entry point: discovers cases, runs bench +├── with-codspeed.mjs # local @codspeed/core <-> tinybench bridge +└── cases/ + └── / + └── index.bench.mjs # default export: register(bench, ctx) +``` + +Each case directory must contain `index.bench.mjs` exporting a default +function with the signature: + +```js +export default function register(bench, { caseName, caseDir }) { + bench.add("my case: descriptive name", () => { + // ... hook calls ... + }); +} +``` + +## Existing cases + +| Case | What it measures | +| ----------------------------- | ------------------------------------------------------------------------------------- | +| `sync-hook` | Steady-state `SyncHook#call` at tap counts 0/1/5/10/20/50 and arg counts 0..5 | +| `sync-bail-hook` | `SyncBailHook#call`, full walk vs. bail at start / middle / end | +| `sync-waterfall-hook` | Value-threading through taps that all return / all skip / mixed | +| `sync-loop-hook` | Single-pass and multi-pass loops | +| `async-series-hook` | `callAsync` and `promise`, sync / async / promise tap flavors | +| `async-series-bail-hook` | Full walk vs. bail, for sync and callback-async taps | +| `async-series-waterfall-hook` | Waterfall for sync / async / promise taps | +| `async-series-loop-hook` | Single-pass and multi-pass async loops | +| `async-parallel-hook` | Fan-out across sync / async / promise taps | +| `async-parallel-bail-hook` | Parallel race with and without a bailing tap | +| `hook-map` | `HookMap#for` hot / cold / missing lookups plus interceptor factories | +| `multi-hook` | Fan-out registration and `isUsed` / `intercept` across a 3-hook `MultiHook` | +| `interceptors-sync` | Baseline vs. `call`, `tap`, combined, multiple, and register interceptors on SyncHook | +| `interceptors-async` | Same matrix on `AsyncSeriesHook` and `AsyncParallelHook` | +| `tap-registration` | `tap` / `tapAsync` / `tapPromise` with string, object, stage, and `before` options | +| `hook-compile` | First-call code-gen cost for every hook type (5 taps + first call per iteration) | + +Add new cases by creating a new directory under `cases/` — `run.mjs` will +pick it up automatically on the next run. diff --git a/benchmark/cases/async-parallel-bail-hook/index.bench.mjs b/benchmark/cases/async-parallel-bail-hook/index.bench.mjs new file mode 100644 index 0000000..64cc518 --- /dev/null +++ b/benchmark/cases/async-parallel-bail-hook/index.bench.mjs @@ -0,0 +1,70 @@ +/* + * async-parallel-bail-hook + * + * AsyncParallelBailHook races taps in parallel but bails (with the + * lowest-index result) as soon as any tap produces a non-undefined value. + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncParallelBailHook } = tapable; + +function makeHook(numTaps, kind, bailAt) { + const hook = new AsyncParallelBailHook(["a"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + const name = `plugin-${idx}`; + if (kind === "sync") { + hook.tap(name, (v) => (idx === bailAt ? v : undefined)); + } else { + hook.tapAsync(name, (v, cb) => cb(null, idx === bailAt ? v : undefined)); + } + } + hook.callAsync(1, () => {}); + return hook; +} + +const INNER_ITERATIONS = 100; + +function runBatch(hook) { + return new Promise((resolve, reject) => { + let remaining = INNER_ITERATIONS; + const done = (err) => { + if (err) return reject(err); + if (--remaining === 0) return resolve(); + }; + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.callAsync(1, done); + } + }); +} + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + { + const hook = makeHook(10, "sync", -1); + bench.add("async-parallel-bail-hook: 10 sync taps, no bail", () => + runBatch(hook) + ); + } + { + const hook = makeHook(10, "sync", 4); + bench.add("async-parallel-bail-hook: 10 sync taps, bail mid", () => + runBatch(hook) + ); + } + { + const hook = makeHook(5, "async", -1); + bench.add("async-parallel-bail-hook: 5 async taps, no bail", () => + runBatch(hook) + ); + } + { + const hook = makeHook(5, "async", 2); + bench.add("async-parallel-bail-hook: 5 async taps, bail mid", () => + runBatch(hook) + ); + } +} diff --git a/benchmark/cases/async-parallel-hook/index.bench.mjs b/benchmark/cases/async-parallel-hook/index.bench.mjs new file mode 100644 index 0000000..95b6404 --- /dev/null +++ b/benchmark/cases/async-parallel-hook/index.bench.mjs @@ -0,0 +1,61 @@ +/* + * async-parallel-hook + * + * AsyncParallelHook fires every tap at once and waits for all of them + * to finish. Touches the generated parallel loop / counter structure. + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncParallelHook } = tapable; + +function makeHook(numTaps, kind) { + const hook = new AsyncParallelHook(["a"]); + for (let i = 0; i < numTaps; i++) { + const name = `plugin-${i}`; + if (kind === "sync") { + hook.tap(name, () => {}); + } else if (kind === "async") { + hook.tapAsync(name, (_a, cb) => cb()); + } else if (kind === "promise") { + hook.tapPromise(name, () => Promise.resolve()); + } + } + hook.callAsync(1, () => {}); + return hook; +} + +const INNER_ITERATIONS = 200; + +function runBatch(hook) { + return new Promise((resolve, reject) => { + let remaining = INNER_ITERATIONS; + const done = (err) => { + if (err) return reject(err); + if (--remaining === 0) return resolve(); + }; + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.callAsync(1, done); + } + }); +} + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const n of [1, 5, 20]) { + const hook = makeHook(n, "sync"); + bench.add(`async-parallel-hook: ${n} sync taps`, () => runBatch(hook)); + } + + for (const n of [5, 20]) { + const hook = makeHook(n, "async"); + bench.add(`async-parallel-hook: ${n} async taps`, () => runBatch(hook)); + } + + { + const hook = makeHook(5, "promise"); + bench.add("async-parallel-hook: 5 promise taps", () => runBatch(hook)); + } +} diff --git a/benchmark/cases/async-series-bail-hook/index.bench.mjs b/benchmark/cases/async-series-bail-hook/index.bench.mjs new file mode 100644 index 0000000..71df5da --- /dev/null +++ b/benchmark/cases/async-series-bail-hook/index.bench.mjs @@ -0,0 +1,71 @@ +/* + * async-series-bail-hook + * + * AsyncSeriesBailHook walks taps in order until one returns / callbacks + * with a non-undefined value. Exercises both the full-chain "nothing bails" + * branch and the early-exit branch. + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncSeriesBailHook } = tapable; + +function makeHook(numTaps, kind, bailAt) { + const hook = new AsyncSeriesBailHook(["a"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + const name = `plugin-${idx}`; + if (kind === "sync") { + hook.tap(name, (v) => (idx === bailAt ? v : undefined)); + } else { + hook.tapAsync(name, (v, cb) => cb(null, idx === bailAt ? v : undefined)); + } + } + hook.callAsync(1, () => {}); + return hook; +} + +const INNER_ITERATIONS = 200; + +function runBatch(hook) { + return new Promise((resolve, reject) => { + let remaining = INNER_ITERATIONS; + const done = (err) => { + if (err) return reject(err); + if (--remaining === 0) return resolve(); + }; + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.callAsync(1, done); + } + }); +} + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + { + const hook = makeHook(10, "sync", -1); + bench.add("async-series-bail-hook: 10 sync taps, no bail", () => + runBatch(hook) + ); + } + { + const hook = makeHook(10, "sync", 4); + bench.add("async-series-bail-hook: 10 sync taps, bail mid", () => + runBatch(hook) + ); + } + { + const hook = makeHook(5, "async", -1); + bench.add("async-series-bail-hook: 5 async taps, no bail", () => + runBatch(hook) + ); + } + { + const hook = makeHook(5, "async", 2); + bench.add("async-series-bail-hook: 5 async taps, bail mid", () => + runBatch(hook) + ); + } +} diff --git a/benchmark/cases/async-series-hook/index.bench.mjs b/benchmark/cases/async-series-hook/index.bench.mjs new file mode 100644 index 0000000..4ef1e8b --- /dev/null +++ b/benchmark/cases/async-series-hook/index.bench.mjs @@ -0,0 +1,92 @@ +/* + * async-series-hook + * + * AsyncSeriesHook under its three tap flavors: + * - sync taps (`hook.tap`) -> generated code falls through + * - async taps (`hook.tapAsync`) -> callback continuations + * - promise taps (`hook.tapPromise`) -> .then() continuations + * + * A batch of callAsync / promise invocations runs per iteration so the + * measured body dominates the tinybench scheduler overhead. + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncSeriesHook } = tapable; + +function makeHook(numTaps, kind) { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < numTaps; i++) { + const name = `plugin-${i}`; + if (kind === "sync") { + hook.tap(name, () => {}); + } else if (kind === "async") { + hook.tapAsync(name, (_a, cb) => cb()); + } else if (kind === "promise") { + hook.tapPromise(name, () => Promise.resolve()); + } + } + hook.callAsync(1, () => {}); + return hook; +} + +const INNER_ITERATIONS = 200; + +function runCallbackBatch(hook) { + return new Promise((resolve, reject) => { + let remaining = INNER_ITERATIONS; + const done = (err) => { + if (err) return reject(err); + if (--remaining === 0) return resolve(); + }; + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.callAsync(1, done); + } + }); +} + +async function runPromiseBatch(hook) { + for (let i = 0; i < INNER_ITERATIONS; i++) { + await hook.promise(1); + } +} + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const n of [1, 5, 20]) { + const hook = makeHook(n, "sync"); + bench.add(`async-series-hook: callAsync, ${n} sync taps`, () => + runCallbackBatch(hook) + ); + } + + for (const n of [5, 20]) { + const hook = makeHook(n, "async"); + bench.add(`async-series-hook: callAsync, ${n} async taps`, () => + runCallbackBatch(hook) + ); + } + + { + const hook = makeHook(5, "promise"); + bench.add("async-series-hook: callAsync, 5 promise taps", () => + runCallbackBatch(hook) + ); + } + + { + const hook = makeHook(5, "sync"); + bench.add("async-series-hook: promise, 5 sync taps", () => + runPromiseBatch(hook) + ); + } + + { + const hook = makeHook(5, "async"); + bench.add("async-series-hook: promise, 5 async taps", () => + runPromiseBatch(hook) + ); + } +} diff --git a/benchmark/cases/async-series-loop-hook/index.bench.mjs b/benchmark/cases/async-series-loop-hook/index.bench.mjs new file mode 100644 index 0000000..356166c --- /dev/null +++ b/benchmark/cases/async-series-loop-hook/index.bench.mjs @@ -0,0 +1,67 @@ +/* + * async-series-loop-hook + * + * AsyncSeriesLoopHook is the async cousin of SyncLoopHook - re-runs the + * tap chain while any tap signals "loop again" (non-undefined value). + * Covers 0 reloops (single pass) and a small multi-pass case. + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncSeriesLoopHook } = tapable; + +function makeHook(numTaps, reloops) { + const hook = new AsyncSeriesLoopHook(["state"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + hook.tap(`plugin-${idx}`, (state) => { + if (state.counts[idx] < reloops) { + state.counts[idx]++; + return true; + } + return undefined; + }); + } + hook.callAsync( + { counts: Array.from({ length: numTaps }, () => reloops) }, + () => {} + ); + return hook; +} + +function resetState(state) { + for (let i = 0; i < state.counts.length; i++) state.counts[i] = 0; +} + +const INNER_ITERATIONS = 100; + +function runBatch(hook, state) { + return new Promise((resolve, reject) => { + let remaining = INNER_ITERATIONS; + const next = (err) => { + if (err) return reject(err); + resetState(state); + if (--remaining === 0) return resolve(); + hook.callAsync(state, next); + }; + resetState(state); + hook.callAsync(state, next); + }); +} + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const [n, reloops] of [ + [3, 0], + [3, 2], + [10, 0] + ]) { + const hook = makeHook(n, reloops); + const state = { counts: Array.from({ length: n }, () => 0) }; + bench.add(`async-series-loop-hook: ${n} taps, ${reloops} reloops`, () => + runBatch(hook, state) + ); + } +} diff --git a/benchmark/cases/async-series-waterfall-hook/index.bench.mjs b/benchmark/cases/async-series-waterfall-hook/index.bench.mjs new file mode 100644 index 0000000..a738733 --- /dev/null +++ b/benchmark/cases/async-series-waterfall-hook/index.bench.mjs @@ -0,0 +1,67 @@ +/* + * async-series-waterfall-hook + * + * AsyncSeriesWaterfallHook threads a value through a chain of taps where + * each tap can be sync, callback-async, or promise-async. + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncSeriesWaterfallHook } = tapable; + +function makeHook(numTaps, kind) { + const hook = new AsyncSeriesWaterfallHook(["value"]); + for (let i = 0; i < numTaps; i++) { + const name = `plugin-${i}`; + if (kind === "sync") { + hook.tap(name, (v) => v + 1); + } else if (kind === "async") { + hook.tapAsync(name, (v, cb) => cb(null, v + 1)); + } else if (kind === "promise") { + hook.tapPromise(name, (v) => Promise.resolve(v + 1)); + } + } + hook.callAsync(0, () => {}); + return hook; +} + +const INNER_ITERATIONS = 200; + +function runBatch(hook) { + return new Promise((resolve, reject) => { + let remaining = INNER_ITERATIONS; + const done = (err) => { + if (err) return reject(err); + if (--remaining === 0) return resolve(); + }; + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.callAsync(0, done); + } + }); +} + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const n of [1, 5, 20]) { + const hook = makeHook(n, "sync"); + bench.add(`async-series-waterfall-hook: ${n} sync taps`, () => + runBatch(hook) + ); + } + + { + const hook = makeHook(5, "async"); + bench.add("async-series-waterfall-hook: 5 async taps", () => + runBatch(hook) + ); + } + + { + const hook = makeHook(5, "promise"); + bench.add("async-series-waterfall-hook: 5 promise taps", () => + runBatch(hook) + ); + } +} diff --git a/benchmark/cases/hook-compile/index.bench.mjs b/benchmark/cases/hook-compile/index.bench.mjs new file mode 100644 index 0000000..0d58d3e --- /dev/null +++ b/benchmark/cases/hook-compile/index.bench.mjs @@ -0,0 +1,91 @@ +/* + * hook-compile + * + * Measures first-call compile cost per hook type. Each iteration builds + * a fresh hook with 5 taps and forces code generation by calling it once. + * This is the path that webpack hits every time the tap-set changes. + */ + +import tapable from "../../../lib/index.js"; + +const { + SyncHook, + SyncBailHook, + SyncWaterfallHook, + SyncLoopHook, + AsyncSeriesHook, + AsyncSeriesBailHook, + AsyncSeriesWaterfallHook, + AsyncParallelHook, + AsyncParallelBailHook +} = tapable; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + bench.add("hook-compile: SyncHook, 5 taps + first call", () => { + const hook = new SyncHook(["a", "b"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => {}); + hook.call(1, 2); + }); + + bench.add("hook-compile: SyncBailHook, 5 taps + first call", () => { + const hook = new SyncBailHook(["a"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); + hook.call(1); + }); + + bench.add("hook-compile: SyncWaterfallHook, 5 taps + first call", () => { + const hook = new SyncWaterfallHook(["v"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, (v) => v); + hook.call(0); + }); + + bench.add("hook-compile: SyncLoopHook, 5 taps + first call", () => { + const hook = new SyncLoopHook(["s"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); + hook.call({}); + }); + + bench.add("hook-compile: AsyncSeriesHook, 5 taps + first callAsync", () => { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => {}); + hook.callAsync(1, () => {}); + }); + + bench.add( + "hook-compile: AsyncSeriesBailHook, 5 taps + first callAsync", + () => { + const hook = new AsyncSeriesBailHook(["a"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, () => undefined); + hook.callAsync(1, () => {}); + } + ); + + bench.add( + "hook-compile: AsyncSeriesWaterfallHook, 5 taps + first callAsync", + () => { + const hook = new AsyncSeriesWaterfallHook(["v"]); + for (let i = 0; i < 5; i++) hook.tap(`p-${i}`, (v) => v); + hook.callAsync(0, () => {}); + } + ); + + bench.add("hook-compile: AsyncParallelHook, 5 taps + first callAsync", () => { + const hook = new AsyncParallelHook(["a"]); + for (let i = 0; i < 5; i++) hook.tapAsync(`p-${i}`, (_a, cb) => cb()); + hook.callAsync(1, () => {}); + }); + + bench.add( + "hook-compile: AsyncParallelBailHook, 5 taps + first callAsync", + () => { + const hook = new AsyncParallelBailHook(["a"]); + for (let i = 0; i < 5; i++) { + hook.tapAsync(`p-${i}`, (_a, cb) => cb(null, undefined)); + } + hook.callAsync(1, () => {}); + } + ); +} diff --git a/benchmark/cases/hook-map/index.bench.mjs b/benchmark/cases/hook-map/index.bench.mjs new file mode 100644 index 0000000..f242517 --- /dev/null +++ b/benchmark/cases/hook-map/index.bench.mjs @@ -0,0 +1,69 @@ +/* + * hook-map + * + * HookMap is the keyed sub-hook container used by plugin systems + * (webpack compilation.hooks.*). Hot paths: + * - `map.for(key)` on an already-populated key (pure Map.get) + * - `map.for(key)` on a new key (factory + interceptor walk) + * - `map.get(key)` for existing / missing keys + */ + +import tapable from "../../../lib/index.js"; + +const { HookMap, SyncHook } = tapable; + +const LOOKUP_ITERATIONS = 2000; +const COLD_KEYS = 10; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + const warm = new HookMap(() => new SyncHook(["x"])); + for (let i = 0; i < 20; i++) { + warm.for(`key-${i}`).tap(`plugin-${i}`, () => {}); + } + + bench.add("hook-map: for(existing key)", () => { + for (let i = 0; i < LOOKUP_ITERATIONS; i++) { + warm.for("key-10"); + } + }); + + bench.add("hook-map: get(existing key)", () => { + for (let i = 0; i < LOOKUP_ITERATIONS; i++) { + warm.get("key-10"); + } + }); + + bench.add("hook-map: get(missing key)", () => { + for (let i = 0; i < LOOKUP_ITERATIONS; i++) { + warm.get("not-there"); + } + }); + + bench.add(`hook-map: for(new key) x ${COLD_KEYS}, no interceptors`, () => { + const map = new HookMap(() => new SyncHook(["x"])); + for (let i = 0; i < COLD_KEYS; i++) { + map.for(`k-${i}`); + } + }); + + bench.add(`hook-map: for(new key) x ${COLD_KEYS}, 1 interceptor`, () => { + const map = new HookMap(() => new SyncHook(["x"])); + map.intercept({ factory: (_k, hook) => hook }); + for (let i = 0; i < COLD_KEYS; i++) { + map.for(`k-${i}`); + } + }); + + bench.add(`hook-map: for(new key) x ${COLD_KEYS}, 3 interceptors`, () => { + const map = new HookMap(() => new SyncHook(["x"])); + map.intercept({ factory: (_k, hook) => hook }); + map.intercept({ factory: (_k, hook) => hook }); + map.intercept({ factory: (_k, hook) => hook }); + for (let i = 0; i < COLD_KEYS; i++) { + map.for(`k-${i}`); + } + }); +} diff --git a/benchmark/cases/interceptors-async/index.bench.mjs b/benchmark/cases/interceptors-async/index.bench.mjs new file mode 100644 index 0000000..485f17d --- /dev/null +++ b/benchmark/cases/interceptors-async/index.bench.mjs @@ -0,0 +1,73 @@ +/* + * interceptors-async + * + * Interceptor overhead on the async hooks. Covers both AsyncSeriesHook + * (serialized, one-tap-at-a-time) and AsyncParallelHook (fan-out). + */ + +import tapable from "../../../lib/index.js"; + +const { AsyncSeriesHook, AsyncParallelHook } = tapable; + +function runBatch(hook, iterations) { + return new Promise((resolve, reject) => { + let remaining = iterations; + const done = (err) => { + if (err) return reject(err); + if (--remaining === 0) return resolve(); + }; + for (let i = 0; i < iterations; i++) { + hook.callAsync(1, done); + } + }); +} + +const INNER_ITERATIONS = 200; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + // --- AsyncSeriesHook --- + const seriesBaseline = new AsyncSeriesHook(["a"]); + for (let i = 0; i < 5; i++) seriesBaseline.tap(`p-${i}`, () => {}); + seriesBaseline.callAsync(1, () => {}); + bench.add("interceptors-async: series, 5 sync taps, no interceptors", () => + runBatch(seriesBaseline, INNER_ITERATIONS) + ); + + const seriesCall = new AsyncSeriesHook(["a"]); + seriesCall.intercept({ call: () => {} }); + for (let i = 0; i < 5; i++) seriesCall.tap(`p-${i}`, () => {}); + seriesCall.callAsync(1, () => {}); + bench.add("interceptors-async: series, 5 sync taps, call interceptor", () => + runBatch(seriesCall, INNER_ITERATIONS) + ); + + const seriesTap = new AsyncSeriesHook(["a"]); + seriesTap.intercept({ tap: () => {} }); + for (let i = 0; i < 5; i++) seriesTap.tap(`p-${i}`, () => {}); + seriesTap.callAsync(1, () => {}); + bench.add("interceptors-async: series, 5 sync taps, tap interceptor", () => + runBatch(seriesTap, INNER_ITERATIONS) + ); + + // --- AsyncParallelHook --- + const parallelBaseline = new AsyncParallelHook(["a"]); + for (let i = 0; i < 5; i++) { + parallelBaseline.tapAsync(`p-${i}`, (_a, cb) => cb()); + } + parallelBaseline.callAsync(1, () => {}); + bench.add("interceptors-async: parallel, 5 async taps, no interceptors", () => + runBatch(parallelBaseline, INNER_ITERATIONS) + ); + + const parallelAll = new AsyncParallelHook(["a"]); + parallelAll.intercept({ call: () => {}, tap: () => {} }); + for (let i = 0; i < 5; i++) parallelAll.tapAsync(`p-${i}`, (_a, cb) => cb()); + parallelAll.callAsync(1, () => {}); + bench.add( + "interceptors-async: parallel, 5 async taps, call + tap interceptor", + () => runBatch(parallelAll, INNER_ITERATIONS) + ); +} diff --git a/benchmark/cases/interceptors-sync/index.bench.mjs b/benchmark/cases/interceptors-sync/index.bench.mjs new file mode 100644 index 0000000..576405d --- /dev/null +++ b/benchmark/cases/interceptors-sync/index.bench.mjs @@ -0,0 +1,66 @@ +/* + * interceptors-sync + * + * Measures how interceptors slow down the SyncHook `.call()` path. The + * baseline no-interceptor run is included so delta is visible. + */ + +import tapable from "../../../lib/index.js"; + +const { SyncHook } = tapable; + +function makeHook(numTaps, interceptors) { + const hook = new SyncHook(["a"]); + for (const i of interceptors) hook.intercept(i); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, () => {}); + } + hook.call(1); + return hook; +} + +const INNER_ITERATIONS = 1500; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + const baseline = makeHook(5, []); + bench.add("interceptors-sync: 5 taps, no interceptors", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) baseline.call(1); + }); + + const call = makeHook(5, [{ call: () => {} }]); + bench.add("interceptors-sync: 5 taps, call interceptor", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) call.call(1); + }); + + const tap = makeHook(5, [{ tap: () => {} }]); + bench.add("interceptors-sync: 5 taps, tap interceptor", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) tap.call(1); + }); + + const combined = makeHook(5, [{ call: () => {}, tap: () => {} }]); + bench.add("interceptors-sync: 5 taps, call + tap interceptor", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) combined.call(1); + }); + + const many = makeHook(5, [ + { call: () => {} }, + { tap: () => {} }, + { call: () => {} } + ]); + bench.add("interceptors-sync: 5 taps, 3 interceptors", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) many.call(1); + }); + + // Register interceptor runs at tap time only. + bench.add( + "interceptors-sync: register interceptor + 10 tap registrations", + () => { + const hook = new SyncHook(["a"]); + hook.intercept({ register: (t) => t }); + for (let i = 0; i < 10; i++) hook.tap(`p-${i}`, () => {}); + } + ); +} diff --git a/benchmark/cases/multi-hook/index.bench.mjs b/benchmark/cases/multi-hook/index.bench.mjs new file mode 100644 index 0000000..c6796ef --- /dev/null +++ b/benchmark/cases/multi-hook/index.bench.mjs @@ -0,0 +1,51 @@ +/* + * multi-hook + * + * MultiHook fans operations (tap / intercept / isUsed) across a small set + * of underlying hooks. Covers the hot fan-out loop and the boolean-short + * `isUsed` check. + */ + +import tapable from "../../../lib/index.js"; + +const { MultiHook, SyncHook } = tapable; + +function makeMulti() { + return new MultiHook([ + new SyncHook(["x"]), + new SyncHook(["x"]), + new SyncHook(["x"]) + ]); +} + +const TAP_COUNT = 10; +const IS_USED_ITERATIONS = 2000; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + bench.add(`multi-hook: tap x ${TAP_COUNT} across 3 hooks`, () => { + const multi = makeMulti(); + for (let i = 0; i < TAP_COUNT; i++) { + multi.tap(`p-${i}`, () => {}); + } + }); + + const usedMulti = makeMulti(); + for (let i = 0; i < 5; i++) { + usedMulti.tap(`p-${i}`, () => {}); + } + + bench.add("multi-hook: isUsed (3 hooks, 5 taps)", () => { + for (let i = 0; i < IS_USED_ITERATIONS; i++) { + usedMulti.isUsed(); + } + }); + + bench.add("multi-hook: intercept across 3 hooks", () => { + const multi = makeMulti(); + for (let i = 0; i < 5; i++) multi.tap(`p-${i}`, () => {}); + multi.intercept({ call: () => {} }); + }); +} diff --git a/benchmark/cases/sync-bail-hook/index.bench.mjs b/benchmark/cases/sync-bail-hook/index.bench.mjs new file mode 100644 index 0000000..6ebef03 --- /dev/null +++ b/benchmark/cases/sync-bail-hook/index.bench.mjs @@ -0,0 +1,49 @@ +/* + * sync-bail-hook + * + * Measures SyncBailHook.call() with taps that either bail early (returning + * a value) or pass through (returning undefined). Bail position changes + * how many taps run per call, so it directly exercises the conditional + * structure of the generated code. + */ + +import tapable from "../../../lib/index.js"; + +const { SyncBailHook } = tapable; + +function makeHook(numTaps, bailAt) { + const hook = new SyncBailHook(["value"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + hook.tap(`plugin-${idx}`, (v) => (idx === bailAt ? v : undefined)); + } + hook.call(1); + return hook; +} + +const INNER_ITERATIONS = 1500; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + // Full chain walk - no tap bails. + for (const n of [1, 5, 10, 20]) { + const hook = makeHook(n, -1); + bench.add(`sync-bail-hook: ${n} taps, no bail`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(1); + } + }); + } + + // Bail at start / middle / end of a 10-tap chain. + for (const pos of [0, 4, 9]) { + const hook = makeHook(10, pos); + bench.add(`sync-bail-hook: 10 taps, bail at index ${pos}`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(1); + } + }); + } +} diff --git a/benchmark/cases/sync-hook/index.bench.mjs b/benchmark/cases/sync-hook/index.bench.mjs new file mode 100644 index 0000000..e88318d --- /dev/null +++ b/benchmark/cases/sync-hook/index.bench.mjs @@ -0,0 +1,49 @@ +/* + * sync-hook + * + * Steady-state `.call()` cost for SyncHook at varying tap counts. Each + * bench body loops over the hook many times so the measurement captures + * the generated call-path rather than the tinybench harness overhead. + */ + +import tapable from "../../../lib/index.js"; + +const { SyncHook } = tapable; + +function makeHook(numTaps, argNames = ["a", "b"]) { + const hook = new SyncHook(argNames); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, () => {}); + } + // Force compilation so the benchmark measures the steady-state call path. + hook.call(...argNames.map((_, i) => i)); + return hook; +} + +const INNER_ITERATIONS = 2000; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const n of [0, 1, 5, 10, 20, 50]) { + const hook = makeHook(n); + bench.add(`sync-hook: call with ${n} taps`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(1, 2); + } + }); + } + + // Argument-count variations with a fixed 5-tap chain. + for (const argCount of [0, 1, 3, 5]) { + const args = Array.from({ length: argCount }, (_, i) => `a${i}`); + const hook = makeHook(5, args); + const callArgs = args.map((_, i) => i); + bench.add(`sync-hook: call 5 taps / ${argCount} args`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(...callArgs); + } + }); + } +} diff --git a/benchmark/cases/sync-loop-hook/index.bench.mjs b/benchmark/cases/sync-loop-hook/index.bench.mjs new file mode 100644 index 0000000..9dd4372 --- /dev/null +++ b/benchmark/cases/sync-loop-hook/index.bench.mjs @@ -0,0 +1,53 @@ +/* + * sync-loop-hook + * + * SyncLoopHook re-runs the tap chain while any tap returns a non-undefined + * value. The case covers: + * - single-pass (every tap returns undefined on the first run) + * - multi-pass (each tap asks for N reloops before settling) + */ + +import tapable from "../../../lib/index.js"; + +const { SyncLoopHook } = tapable; + +function makeHook(numTaps, reloops) { + const hook = new SyncLoopHook(["state"]); + for (let i = 0; i < numTaps; i++) { + const idx = i; + hook.tap(`plugin-${idx}`, (state) => { + if (state.counts[idx] < reloops) { + state.counts[idx]++; + return true; + } + return undefined; + }); + } + return hook; +} + +function resetState(state) { + for (let i = 0; i < state.counts.length; i++) state.counts[i] = 0; +} + +const INNER_ITERATIONS = 500; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const [n, reloops] of [ + [3, 0], + [10, 0], + [3, 2] + ]) { + const hook = makeHook(n, reloops); + const state = { counts: Array.from({ length: n }, () => 0) }; + bench.add(`sync-loop-hook: ${n} taps, ${reloops} reloops`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + resetState(state); + hook.call(state); + } + }); + } +} diff --git a/benchmark/cases/sync-waterfall-hook/index.bench.mjs b/benchmark/cases/sync-waterfall-hook/index.bench.mjs new file mode 100644 index 0000000..bc870d9 --- /dev/null +++ b/benchmark/cases/sync-waterfall-hook/index.bench.mjs @@ -0,0 +1,58 @@ +/* + * sync-waterfall-hook + * + * SyncWaterfallHook threads a value through each tap. Covers three shapes + * that hit different branches of the generated code: + * - every tap returns a new value (value is overwritten on each step) + * - every tap returns undefined (initial value threaded through) + * - mixed returns + */ + +import tapable from "../../../lib/index.js"; + +const { SyncWaterfallHook } = tapable; + +function makeHook(numTaps, returning) { + const hook = new SyncWaterfallHook(["value"]); + for (let i = 0; i < numTaps; i++) { + hook.tap(`plugin-${i}`, returning ? (v) => v + 1 : () => undefined); + } + hook.call(0); + return hook; +} + +const INNER_ITERATIONS = 1500; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + for (const n of [1, 5, 20, 50]) { + const hook = makeHook(n, true); + bench.add(`sync-waterfall-hook: ${n} taps, all return`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(0); + } + }); + } + + for (const n of [5, 20]) { + const hook = makeHook(n, false); + bench.add(`sync-waterfall-hook: ${n} taps, all undefined`, () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + hook.call(0); + } + }); + } + + const mixed = new SyncWaterfallHook(["value"]); + for (let i = 0; i < 10; i++) { + mixed.tap(`plugin-${i}`, i % 2 === 0 ? (v) => v + 1 : () => undefined); + } + mixed.call(0); + bench.add("sync-waterfall-hook: 10 taps, mixed return", () => { + for (let i = 0; i < INNER_ITERATIONS; i++) { + mixed.call(0); + } + }); +} diff --git a/benchmark/cases/tap-registration/index.bench.mjs b/benchmark/cases/tap-registration/index.bench.mjs new file mode 100644 index 0000000..44dc989 --- /dev/null +++ b/benchmark/cases/tap-registration/index.bench.mjs @@ -0,0 +1,80 @@ +/* + * tap-registration + * + * Measures Hook#tap / tapAsync / tapPromise at the registration step, + * with the four kinds of options shapes that hit different code paths + * in Hook.js (_tap, _insert): + * - string options (most common - plugin name only) + * - object options (same thing but wrapped) + * - stages (numeric ordering) + * - `before` constraint (forces the shift loop to scan) + */ + +import tapable from "../../../lib/index.js"; + +const { SyncHook, AsyncSeriesHook } = tapable; + +const TAP_COUNT = 10; + +/** + * @param {import('tinybench').Bench} bench + */ +export default function register(bench) { + bench.add( + `tap-registration: SyncHook tap x ${TAP_COUNT}, string options`, + () => { + const hook = new SyncHook(["a"]); + for (let i = 0; i < TAP_COUNT; i++) { + hook.tap(`p-${i}`, () => {}); + } + } + ); + + bench.add( + `tap-registration: SyncHook tap x ${TAP_COUNT}, object options`, + () => { + const hook = new SyncHook(["a"]); + for (let i = 0; i < TAP_COUNT; i++) { + hook.tap({ name: `p-${i}` }, () => {}); + } + } + ); + + bench.add( + `tap-registration: SyncHook tap x ${TAP_COUNT}, with stages`, + () => { + const hook = new SyncHook(["a"]); + for (let i = 0; i < TAP_COUNT; i++) { + hook.tap({ name: `p-${i}`, stage: i % 3 }, () => {}); + } + } + ); + + bench.add( + `tap-registration: SyncHook tap x ${TAP_COUNT}, alternating before`, + () => { + const hook = new SyncHook(["a"]); + hook.tap("first", () => {}); + for (let i = 0; i < TAP_COUNT - 1; i++) { + hook.tap({ name: `p-${i}`, before: "first" }, () => {}); + } + } + ); + + bench.add(`tap-registration: AsyncSeriesHook tapAsync x ${TAP_COUNT}`, () => { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < TAP_COUNT; i++) { + hook.tapAsync(`p-${i}`, (_a, cb) => cb()); + } + }); + + bench.add( + `tap-registration: AsyncSeriesHook tapPromise x ${TAP_COUNT}`, + () => { + const hook = new AsyncSeriesHook(["a"]); + for (let i = 0; i < TAP_COUNT; i++) { + hook.tapPromise(`p-${i}`, () => Promise.resolve()); + } + } + ); +} diff --git a/benchmark/run.mjs b/benchmark/run.mjs new file mode 100644 index 0000000..65f5254 --- /dev/null +++ b/benchmark/run.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/* + * Benchmark entry point for tapable. + * + * 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 + * enhanced-resolve / 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 -- sync`). + * 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: "tapable", + now: hrtimeNow, + throws: true, + warmup: true, + warmupIterations: 5, + // Kept deliberately low: each task's body already loops over many + // hook 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: 20 + }) +); + +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) + }); + 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. + 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..4924e18 --- /dev/null +++ b/benchmark/with-codspeed.mjs @@ -0,0 +1,180 @@ +/* + * CodSpeed <-> tinybench bridge for tapable benchmarks. + * + * Ported from the equivalent wrapper in enhanced-resolve (which in turn + * was ported 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 {import("tinybench").Task} Task */ +/** @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); +} + +/** + * @typedef {{ uri: string, fn: Fn, opts: object | undefined }} TaskMeta + * @type {WeakMap>} + */ +const metaMap = new WeakMap(); + +/** + * @param {Bench} bench + * @returns {Map} + */ +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 + * @returns {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 + * @param {boolean} isAsync + * @returns {Fn} + */ + 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 42f3e64..9075656 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,11 +12,27 @@ export default defineConfig([ } }, { + files: ["lib/__tests__/**/*.js"], languageOptions: { parserOptions: { ecmaVersion: 2018 } + } + }, + { + files: ["benchmark/**/*.mjs"], + languageOptions: { + parserOptions: { + ecmaVersion: 2022 + } }, - files: ["lib/__tests__/**/*.js"] + rules: { + "no-console": "off", + "import/namespace": "off", + "n/hashbang": "off", + "n/no-unsupported-features/es-syntax": "off", + "n/no-unsupported-features/node-builtins": "off", + "n/no-process-exit": "off" + } } ]); diff --git a/package-lock.json b/package-lock.json index 1a67583..6c8f4bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,15 +11,17 @@ "devDependencies": { "@babel/core": "^7.4.4", "@babel/preset-env": "^7.4.4", - "@changesets/cli": "^2.30.0", + "@changesets/cli": "^2.31.0", "@changesets/get-github-info": "^0.8.0", + "@codspeed/core": "^5.3.0", "@stylistic/eslint-plugin": "^5.2.3", "babel-jest": "^30.3.0", "eslint": "^9.28.0", "eslint-config-webpack": "^4.6.3", "jest": "^30.3.0", - "prettier": "^3.5.3", - "prettier-1": "npm:prettier@^1" + "prettier": "^3.8.3", + "prettier-1": "npm:prettier@^1", + "tinybench": "^6.0.0" }, "engines": { "node": ">=6" @@ -1784,13 +1786,13 @@ "license": "MIT" }, "node_modules/@changesets/apply-release-plan": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.0.tgz", - "integrity": "sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.1.tgz", + "integrity": "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==", "dev": true, "license": "MIT", "dependencies": { - "@changesets/config": "^3.1.3", + "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", @@ -1845,14 +1847,14 @@ } }, "node_modules/@changesets/assemble-release-plan": { - "version": "6.0.9", - "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.9.tgz", - "integrity": "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==", + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.10.tgz", + "integrity": "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==", "dev": true, "license": "MIT", "dependencies": { "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", + "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", @@ -1883,19 +1885,19 @@ } }, "node_modules/@changesets/cli": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-2.30.0.tgz", - "integrity": "sha512-5D3Nk2JPqMI1wK25pEymeWRSlSMdo5QOGlyfrKg0AOufrUcjEE3RQgaCpHoBiM31CSNrtSgdJ0U6zL1rLDDfBA==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-2.31.0.tgz", + "integrity": "sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==", "dev": true, "license": "MIT", "dependencies": { - "@changesets/apply-release-plan": "^7.1.0", - "@changesets/assemble-release-plan": "^6.0.9", + "@changesets/apply-release-plan": "^7.1.1", + "@changesets/assemble-release-plan": "^6.0.10", "@changesets/changelog-git": "^0.2.1", - "@changesets/config": "^3.1.3", + "@changesets/config": "^3.1.4", "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", - "@changesets/get-release-plan": "^4.0.15", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/get-release-plan": "^4.0.16", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", @@ -1934,14 +1936,14 @@ } }, "node_modules/@changesets/config": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.3.tgz", - "integrity": "sha512-vnXjcey8YgBn2L1OPWd3ORs0bGC4LoYcK/ubpgvzNVr53JXV5GiTVj7fWdMRsoKUH7hhhMAQnsJUqLr21EncNw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.4.tgz", + "integrity": "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==", "dev": true, "license": "MIT", "dependencies": { "@changesets/errors": "^0.2.0", - "@changesets/get-dependents-graph": "^2.1.3", + "@changesets/get-dependents-graph": "^2.1.4", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", @@ -1961,9 +1963,9 @@ } }, "node_modules/@changesets/get-dependents-graph": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.3.tgz", - "integrity": "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.4.tgz", + "integrity": "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==", "dev": true, "license": "MIT", "dependencies": { @@ -1998,14 +2000,14 @@ } }, "node_modules/@changesets/get-release-plan": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.15.tgz", - "integrity": "sha512-Q04ZaRPuEVZtA+auOYgFaVQQSA98dXiVe/yFaZfY7hoSmQICHGvP0TF4u3EDNHWmmCS4ekA/XSpKlSM2PyTS2g==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.16.tgz", + "integrity": "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==", "dev": true, "license": "MIT", "dependencies": { - "@changesets/assemble-release-plan": "^6.0.9", - "@changesets/config": "^3.1.3", + "@changesets/assemble-release-plan": "^6.0.10", + "@changesets/config": "^3.1.4", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", @@ -2150,6 +2152,107 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/@codspeed/core": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@codspeed/core/-/core-5.3.0.tgz", + "integrity": "sha512-++/kkPHPFI+dzX6FD+w+jFgKk8rgm5O1Tpg5nKUuFLENaB9ma///CZv76HdYZTJHu46dEeWwxGH+z0G3lLh59Q==", + "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", @@ -4364,6 +4467,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", @@ -4380,6 +4490,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", @@ -4895,6 +5017,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", @@ -5119,6 +5254,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", @@ -6506,6 +6651,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", @@ -6552,6 +6718,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", @@ -9806,9 +9989,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -9818,6 +10001,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", @@ -9941,6 +10147,18 @@ } } }, + "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", @@ -10460,9 +10678,9 @@ } }, "node_modules/prettier": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", - "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { @@ -10549,6 +10767,16 @@ "react-is": "^16.13.1" } }, + "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", @@ -11567,6 +11795,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", diff --git a/package.json b/package.json index 0383f32..a1aa075 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "fix": "npm run fix:code && npm run fmt", "fix:code": "npm run lint:code -- --fix", "test": "jest", + "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" }, @@ -45,17 +46,19 @@ } }, "devDependencies": { - "@changesets/cli": "^2.30.0", - "@changesets/get-github-info": "^0.8.0", "@babel/core": "^7.4.4", "@babel/preset-env": "^7.4.4", + "@changesets/cli": "^2.31.0", + "@changesets/get-github-info": "^0.8.0", + "@codspeed/core": "^5.3.0", "@stylistic/eslint-plugin": "^5.2.3", "babel-jest": "^30.3.0", "eslint": "^9.28.0", "eslint-config-webpack": "^4.6.3", "jest": "^30.3.0", - "prettier": "^3.5.3", - "prettier-1": "npm:prettier@^1" + "prettier": "^3.8.3", + "prettier-1": "npm:prettier@^1", + "tinybench": "^6.0.0" }, "engines": { "node": ">=6"