|
| 1 | +/* |
| 2 | + * node-compare |
| 3 | + * |
| 4 | + * Head-to-head comparison between enhanced-resolve and Node.js's built-in |
| 5 | + * resolvers (CJS `require.resolve` via `createRequire`, ESM |
| 6 | + * `import.meta.resolve`) over a large, fixed request list (10000 resolves |
| 7 | + * per iteration). This is the case people most often ask about when |
| 8 | + * deciding whether enhanced-resolve's feature set is "worth it" over the |
| 9 | + * built-in resolvers — so it lives as its own bench for easy |
| 10 | + * cross-reference. |
| 11 | + * |
| 12 | + * Everything is held equal across tasks: |
| 13 | + * - Same fixture tree |
| 14 | + * - Same 10000-entry request list (one entry per unique target, see below) |
| 15 | + * - Same starting directory |
| 16 | + * |
| 17 | + * The enhanced-resolve side is benched in three variants per API flavour |
| 18 | + * so the contribution of each caching layer is visible: |
| 19 | + * - `(no cache)` — `CachedInputFileSystem` constructed with |
| 20 | + * `duration: 0`, which routes through `OperationMergerBackend` |
| 21 | + * instead of `CacheBackend` and does no stat/readdir/readJson |
| 22 | + * memoization (see `lib/CachedInputFileSystem.js:527-532`). No |
| 23 | + * `unsafeCache` either. Real pipeline work AND real fs work on every |
| 24 | + * call. |
| 25 | + * - `(fs cache)` — `CachedInputFileSystem` with a 30s TTL so stat / |
| 26 | + * readdir / readJson are memoized in memory. Mirrors the OS-level fs |
| 27 | + * cache Node itself relies on, but does NOT cache the |
| 28 | + * specifier → path mapping. |
| 29 | + * - `(fs + unsafeCache)` — adds `unsafeCache: true`, the closest |
| 30 | + * analogue to Node's per-specifier memoization (Module._pathCache for |
| 31 | + * CJS, the ESM loader cache for `import.meta.resolve`). |
| 32 | + * |
| 33 | + * The request list is *unique-per-entry* — every specifier in the 10000- |
| 34 | + * request batch points at a distinct resolvable target. This defeats the |
| 35 | + * per-specifier result caches within a single batch, so even with |
| 36 | + * `unsafeCache` on the first pass through the list must run the full |
| 37 | + * resolver pipeline for every entry. Subsequent tinybench iterations |
| 38 | + * replay the list, at which point Node's internal caches and |
| 39 | + * enhanced-resolve's `unsafeCache` / fs cache start hitting — which is |
| 40 | + * exactly what the cached variants are there to illustrate. |
| 41 | + * |
| 42 | + * Cross-iteration caching is unavoidable: tinybench replays the same |
| 43 | + * request list across 2 warmup + 10 measurement iterations, and the |
| 44 | + * first run through populates Node's internal caches for the whole |
| 45 | + * list. Read the numbers as "steady-state warm, cache cannot help" |
| 46 | + * rather than truly cold. |
| 47 | + * |
| 48 | + * Pool composition (all 10000 entries unique, ESM-compatible so they |
| 49 | + * resolve in every task): |
| 50 | + * - 5000 bare specifiers into a single package with wildcard subpath |
| 51 | + * exports (`megapkg/fileNNNN` → `megapkg/lib/fileNNNN.js`). A single |
| 52 | + * package with wildcard exports keeps the fixture size small while |
| 53 | + * still exercising the `exports` field / `node_modules` lookup path. |
| 54 | + * - 5000 relative specifiers with explicit `.js` extensions (`./fileNNNN.js`) |
| 55 | + * so Node's ESM resolver accepts them; enhanced-resolve is happy with |
| 56 | + * them too. |
| 57 | + * |
| 58 | + * The fixture is generated at registration time (one `megapkg` with 5000 |
| 59 | + * lib files + 5000 src files + one ESM helper for the bound |
| 60 | + * `import.meta.resolve`) and gitignored. A sentinel file skips |
| 61 | + * regeneration when counts haven't changed, so only the first run pays |
| 62 | + * the setup cost. |
| 63 | + */ |
| 64 | + |
| 65 | +import fs from "fs"; |
| 66 | +import path from "path"; |
| 67 | +import { createRequire } from "module"; |
| 68 | +import { pathToFileURL } from "url"; |
| 69 | +import enhanced from "../../../lib/index.js"; |
| 70 | + |
| 71 | +const { ResolverFactory, CachedInputFileSystem } = enhanced; |
| 72 | + |
| 73 | +const PKG_SUBPATH_COUNT = 500; |
| 74 | +const FILE_COUNT = 500; |
| 75 | +const BATCH_SIZE = PKG_SUBPATH_COUNT + FILE_COUNT; |
| 76 | + |
| 77 | +/** |
| 78 | + * Build a deterministic fixture tree on disk. Skipped entirely when a |
| 79 | + * sentinel file records that the current (PKG_SUBPATH_COUNT, FILE_COUNT) |
| 80 | + * combination has already been materialized — creating 10k tiny files is |
| 81 | + * a ~5s cost we don't want to pay every `npm run benchmark`. |
| 82 | + * |
| 83 | + * Layout: |
| 84 | + * fixture/ |
| 85 | + * package.json // type: commonjs |
| 86 | + * src/ |
| 87 | + * resolver.mjs // exports `import.meta.resolve` |
| 88 | + * file0000.js .. file4999.js // relative-resolve targets |
| 89 | + * node_modules/megapkg/ |
| 90 | + * package.json // wildcard subpath exports |
| 91 | + * lib/ |
| 92 | + * file0000.js .. file4999.js // bare-specifier targets |
| 93 | + */ |
| 94 | +function ensureFixture(fixtureDir) { |
| 95 | + const sentinel = path.join(fixtureDir, ".ok"); |
| 96 | + const expected = `${PKG_SUBPATH_COUNT}:${FILE_COUNT}`; |
| 97 | + try { |
| 98 | + if (fs.readFileSync(sentinel, "utf8") === expected) return; |
| 99 | + } catch { |
| 100 | + // sentinel missing or unreadable — regenerate |
| 101 | + } |
| 102 | + |
| 103 | + fs.mkdirSync(fixtureDir, { recursive: true }); |
| 104 | + fs.writeFileSync( |
| 105 | + path.join(fixtureDir, "package.json"), |
| 106 | + JSON.stringify({ |
| 107 | + name: "node-compare-fixture", |
| 108 | + version: "1.0.0", |
| 109 | + type: "commonjs", |
| 110 | + }), |
| 111 | + ); |
| 112 | + |
| 113 | + const srcDir = path.join(fixtureDir, "src"); |
| 114 | + fs.mkdirSync(srcDir, { recursive: true }); |
| 115 | + // ESM helper: `import.meta.resolve` is a bound method that closes over |
| 116 | + // its owning module's URL, so we need a module that actually lives in |
| 117 | + // the fixture tree. Exporting the bound function lets the bench body |
| 118 | + // call it and have relative + bare specifiers resolved against this |
| 119 | + // module's location. |
| 120 | + fs.writeFileSync( |
| 121 | + path.join(srcDir, "resolver.mjs"), |
| 122 | + "export const resolve = import.meta.resolve;\n", |
| 123 | + ); |
| 124 | + for (let i = 0; i < FILE_COUNT; i++) { |
| 125 | + const name = `file${String(i).padStart(4, "0")}`; |
| 126 | + fs.writeFileSync( |
| 127 | + path.join(srcDir, `${name}.js`), |
| 128 | + `module.exports = ${i};\n`, |
| 129 | + ); |
| 130 | + } |
| 131 | + |
| 132 | + const megapkgDir = path.join(fixtureDir, "node_modules", "megapkg"); |
| 133 | + const megapkgLibDir = path.join(megapkgDir, "lib"); |
| 134 | + fs.mkdirSync(megapkgLibDir, { recursive: true }); |
| 135 | + // Wildcard subpath exports: `megapkg/fileNNNN` → `./lib/fileNNNN.js`. |
| 136 | + // Supported by both Node's CJS + ESM resolvers and enhanced-resolve. |
| 137 | + fs.writeFileSync( |
| 138 | + path.join(megapkgDir, "package.json"), |
| 139 | + JSON.stringify({ |
| 140 | + name: "megapkg", |
| 141 | + version: "1.0.0", |
| 142 | + exports: { |
| 143 | + "./*": "./lib/*.js", |
| 144 | + }, |
| 145 | + }), |
| 146 | + ); |
| 147 | + for (let i = 0; i < PKG_SUBPATH_COUNT; i++) { |
| 148 | + const name = `file${String(i).padStart(4, "0")}`; |
| 149 | + fs.writeFileSync( |
| 150 | + path.join(megapkgLibDir, `${name}.js`), |
| 151 | + `module.exports = ${JSON.stringify(name)};\n`, |
| 152 | + ); |
| 153 | + } |
| 154 | + |
| 155 | + fs.writeFileSync(sentinel, expected); |
| 156 | +} |
| 157 | + |
| 158 | +/** |
| 159 | + * @param {import('tinybench').Bench} bench |
| 160 | + * @param {{ fixtureDir: string }} ctx |
| 161 | + */ |
| 162 | +export default async function register(bench, { fixtureDir }) { |
| 163 | + ensureFixture(fixtureDir); |
| 164 | + const srcDir = path.join(fixtureDir, "src"); |
| 165 | + |
| 166 | + // Build the 10000-entry request list. All specifiers are unique so the |
| 167 | + // per-specifier result caches in Node's CJS and ESM resolvers (and |
| 168 | + // enhanced-resolve's `unsafeCache` if we'd enabled it) cannot |
| 169 | + // short-circuit anything within a single batch. |
| 170 | + const requests = new Array(BATCH_SIZE); |
| 171 | + for (let i = 0; i < PKG_SUBPATH_COUNT; i++) { |
| 172 | + requests[i] = `megapkg/file${String(i).padStart(4, "0")}`; |
| 173 | + } |
| 174 | + for (let i = 0; i < FILE_COUNT; i++) { |
| 175 | + requests[PKG_SUBPATH_COUNT + i] = `./file${String(i).padStart(4, "0")}.js`; |
| 176 | + } |
| 177 | + |
| 178 | + // Node's `require.resolve` is anchored to a real file inside `src/` so |
| 179 | + // relative specifiers resolve against that directory, and bare |
| 180 | + // specifiers walk up the `node_modules` lookup chain. |
| 181 | + const requireAnchor = createRequire(path.join(srcDir, "index.js")); |
| 182 | + |
| 183 | + // `import.meta.resolve` is bound to its module's URL, so import the |
| 184 | + // helper we wrote into `fixture/src/resolver.mjs` and use its exported |
| 185 | + // `resolve`. Relative specifiers will resolve against `fixture/src/`, |
| 186 | + // matching the CJS anchor above. |
| 187 | + const { resolve: importMetaResolve } = await import( |
| 188 | + pathToFileURL(path.join(srcDir, "resolver.mjs")).href |
| 189 | + ); |
| 190 | + |
| 191 | + // Two filesystem wrappers: |
| 192 | + // - raw: duration 0 → OperationMergerBackend, no stat/readdir cache |
| 193 | + // - cached: 30s TTL is long enough that entries never expire during |
| 194 | + // a bench run |
| 195 | + const rawFileSystem = new CachedInputFileSystem(fs, 0); |
| 196 | + const fileSystem = new CachedInputFileSystem(fs, 30 * 1000); |
| 197 | + |
| 198 | + const baseOptions = { |
| 199 | + extensions: [".js"], |
| 200 | + conditionNames: ["node", "require", "import"], |
| 201 | + mainFields: ["main"], |
| 202 | + }; |
| 203 | + |
| 204 | + // Three resolver configs per API flavour: no cache / fs cache / fs + |
| 205 | + // unsafeCache. Each flavour gets its own factory call so tinybench |
| 206 | + // measures independent V8 inline-cache state per task. |
| 207 | + const asyncResolverNone = ResolverFactory.createResolver({ |
| 208 | + ...baseOptions, |
| 209 | + fileSystem: rawFileSystem, |
| 210 | + }); |
| 211 | + const asyncResolverFs = ResolverFactory.createResolver({ |
| 212 | + ...baseOptions, |
| 213 | + fileSystem, |
| 214 | + }); |
| 215 | + const asyncResolverFsUnsafe = ResolverFactory.createResolver({ |
| 216 | + ...baseOptions, |
| 217 | + fileSystem, |
| 218 | + unsafeCache: true, |
| 219 | + }); |
| 220 | + const syncResolverNone = ResolverFactory.createResolver({ |
| 221 | + ...baseOptions, |
| 222 | + fileSystem: rawFileSystem, |
| 223 | + useSyncFileSystemCalls: true, |
| 224 | + }); |
| 225 | + const syncResolverFs = ResolverFactory.createResolver({ |
| 226 | + ...baseOptions, |
| 227 | + fileSystem, |
| 228 | + useSyncFileSystemCalls: true, |
| 229 | + }); |
| 230 | + const syncResolverFsUnsafe = ResolverFactory.createResolver({ |
| 231 | + ...baseOptions, |
| 232 | + fileSystem, |
| 233 | + useSyncFileSystemCalls: true, |
| 234 | + unsafeCache: true, |
| 235 | + }); |
| 236 | + |
| 237 | + const resolveAsync = (resolver, req) => |
| 238 | + new Promise((resolve, reject) => { |
| 239 | + resolver.resolve({}, srcDir, req, {}, (err, result) => { |
| 240 | + if (err) return reject(err); |
| 241 | + if (!result) return reject(new Error(`no result for ${req}`)); |
| 242 | + resolve(result); |
| 243 | + }); |
| 244 | + }); |
| 245 | + |
| 246 | + // --- async (callback API, wrapped in a Promise) --- |
| 247 | + bench.add( |
| 248 | + `node-compare: enhanced-resolve async x ${BATCH_SIZE} (no cache)`, |
| 249 | + async () => { |
| 250 | + for (let i = 0; i < requests.length; i++) { |
| 251 | + await resolveAsync(asyncResolverNone, requests[i]); |
| 252 | + } |
| 253 | + }, |
| 254 | + ); |
| 255 | + |
| 256 | + bench.add( |
| 257 | + `node-compare: enhanced-resolve async x ${BATCH_SIZE} (fs cache)`, |
| 258 | + async () => { |
| 259 | + for (let i = 0; i < requests.length; i++) { |
| 260 | + await resolveAsync(asyncResolverFs, requests[i]); |
| 261 | + } |
| 262 | + }, |
| 263 | + ); |
| 264 | + |
| 265 | + bench.add( |
| 266 | + `node-compare: enhanced-resolve async x ${BATCH_SIZE} (fs + unsafeCache)`, |
| 267 | + async () => { |
| 268 | + for (let i = 0; i < requests.length; i++) { |
| 269 | + await resolveAsync(asyncResolverFsUnsafe, requests[i]); |
| 270 | + } |
| 271 | + }, |
| 272 | + ); |
| 273 | + |
| 274 | + // --- promise API (resolver.resolvePromise) --- |
| 275 | + // Functionally equivalent to the callback task wrapped in a new |
| 276 | + // Promise — benched separately so the overhead (or lack thereof) of |
| 277 | + // the built-in promise wrapper vs a hand-rolled one is visible. |
| 278 | + bench.add( |
| 279 | + `node-compare: enhanced-resolve promise x ${BATCH_SIZE} (no cache)`, |
| 280 | + async () => { |
| 281 | + for (let i = 0; i < requests.length; i++) { |
| 282 | + const r = await asyncResolverNone.resolvePromise( |
| 283 | + {}, |
| 284 | + srcDir, |
| 285 | + requests[i], |
| 286 | + ); |
| 287 | + if (!r) throw new Error(`no result for ${requests[i]}`); |
| 288 | + } |
| 289 | + }, |
| 290 | + ); |
| 291 | + |
| 292 | + bench.add( |
| 293 | + `node-compare: enhanced-resolve promise x ${BATCH_SIZE} (fs cache)`, |
| 294 | + async () => { |
| 295 | + for (let i = 0; i < requests.length; i++) { |
| 296 | + const r = await asyncResolverFs.resolvePromise({}, srcDir, requests[i]); |
| 297 | + if (!r) throw new Error(`no result for ${requests[i]}`); |
| 298 | + } |
| 299 | + }, |
| 300 | + ); |
| 301 | + |
| 302 | + bench.add( |
| 303 | + `node-compare: enhanced-resolve promise x ${BATCH_SIZE} (fs + unsafeCache)`, |
| 304 | + async () => { |
| 305 | + for (let i = 0; i < requests.length; i++) { |
| 306 | + const r = await asyncResolverFsUnsafe.resolvePromise( |
| 307 | + {}, |
| 308 | + srcDir, |
| 309 | + requests[i], |
| 310 | + ); |
| 311 | + if (!r) throw new Error(`no result for ${requests[i]}`); |
| 312 | + } |
| 313 | + }, |
| 314 | + ); |
| 315 | + |
| 316 | + // --- sync API (resolveSync, useSyncFileSystemCalls: true) --- |
| 317 | + bench.add( |
| 318 | + `node-compare: enhanced-resolve sync x ${BATCH_SIZE} (no cache)`, |
| 319 | + () => { |
| 320 | + for (let i = 0; i < requests.length; i++) { |
| 321 | + const r = syncResolverNone.resolveSync({}, srcDir, requests[i]); |
| 322 | + if (!r) throw new Error(`no result for ${requests[i]}`); |
| 323 | + } |
| 324 | + }, |
| 325 | + ); |
| 326 | + |
| 327 | + bench.add( |
| 328 | + `node-compare: enhanced-resolve sync x ${BATCH_SIZE} (fs cache)`, |
| 329 | + () => { |
| 330 | + for (let i = 0; i < requests.length; i++) { |
| 331 | + const r = syncResolverFs.resolveSync({}, srcDir, requests[i]); |
| 332 | + if (!r) throw new Error(`no result for ${requests[i]}`); |
| 333 | + } |
| 334 | + }, |
| 335 | + ); |
| 336 | + |
| 337 | + bench.add( |
| 338 | + `node-compare: enhanced-resolve sync x ${BATCH_SIZE} (fs + unsafeCache)`, |
| 339 | + () => { |
| 340 | + for (let i = 0; i < requests.length; i++) { |
| 341 | + const r = syncResolverFsUnsafe.resolveSync({}, srcDir, requests[i]); |
| 342 | + if (!r) throw new Error(`no result for ${requests[i]}`); |
| 343 | + } |
| 344 | + }, |
| 345 | + ); |
| 346 | + |
| 347 | + bench.add(`node-compare: node require.resolve x ${BATCH_SIZE}`, () => { |
| 348 | + for (let i = 0; i < requests.length; i++) { |
| 349 | + requireAnchor.resolve(requests[i]); |
| 350 | + } |
| 351 | + }); |
| 352 | + |
| 353 | + // `import.meta.resolve` is sync in modern Node (>= 20.6 / 18.19). The |
| 354 | + // ESM loader keeps its own specifier cache; using unique specifiers |
| 355 | + // (see comment on `requests` above) keeps that cache from hiding the |
| 356 | + // real resolve cost on every call within a batch. |
| 357 | + bench.add(`node-compare: node import.meta.resolve x ${BATCH_SIZE}`, () => { |
| 358 | + for (let i = 0; i < requests.length; i++) { |
| 359 | + importMetaResolve(requests[i]); |
| 360 | + } |
| 361 | + }); |
| 362 | +} |
0 commit comments