perf: enable Node.js on-disk compile cache for faster warm starts#633
Conversation
- runMain now enables the V8 compile cache automatically (Node >= 22.8.0; silent no-op otherwise) so dynamically imported modules such as lazy() subcommands skip recompilation on warm starts. The cache directory follows the same XDG convention as the shell-completion workers, so direct runs and completion invocations share one warm cache. Opt out or override via the new MainOptions.compileCache option. - Add a dependency-free politty/compile-cache subpath exporting enableCompileCache for the bin-shim pattern: ESM static imports are compiled during the link phase, so caching the whole CLI graph requires enabling the cache in a minimal shim that loads the real entry with a dynamic import. - Convert politty's own bin (src/cli.ts) into such a shim, with the actual CLI moved to src/cli-main.ts. Measured on dist/cli.js --help: ~122ms cold to ~99ms warm. - Point vitest's XDG_CACHE_HOME at the OS tmpdir so test runs never litter the real user cache with fixture command names.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR adds Node.js V8 compile-cache support, integrates it into ChangesCompile-cache startup and shim generation
Estimated code review effort: 4 (Complex) | ~45 minutes Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
commit: |
- Assert enabled === false instead of failing when module.enableCompileCache is unavailable (Node < 22.8) or disabled via NODE_DISABLE_COMPILE_CACHE. - Skip the fresh-process tests on runtimes that cannot import .ts files directly (type stripping is default from Node 22.18 / 23.6); they spawn plain node against the TypeScript source.
Hand-writing the bin shim is the only setup step the compile cache feature requires; make it generatable instead. politty generate-shim (backed by the exported generateCompileCacheShim) writes the executable ESM shim at build time, so it can be wired into a postbuild or prepack script and never has to live in source: "postbuild": "politty generate-shim --entry ./cli.js --out dist/bin.js" The program name for the cache directory defaults to the first bin name in package.json (falling back to the unscoped package name). Guards: .cjs output and .js output in non-"type": "module" packages are rejected with a pointer to .mjs, since the shim is an ES module.
readNearestPackageJson now searches parent directories up to the filesystem root, matching the documented behavior, so the default program-name derivation and the type-module check keep working when the command runs from a nested directory. A malformed package.json now propagates as an error instead of silently falling through.
compileCacheDir treated programName as a raw path segment, so a name containing separators or dot segments (e.g. a scoped package name passed as command.name) could place the cache outside the intended root. Collapse separators and the scope marker into one segment (@scope/cli -> scope-cli) and fall back to Node's default cache location when nothing safe remains. Realistic command names are unchanged.
TypeScript CLIs conventionally build into dist, so a bare politty generate-shim in postbuild is now enough: - --out defaults to the first bin path in the nearest package.json (the place the executable must live), resolved against the package.json directory. - --entry defaults to the first of ./cli.js, ./cli.mjs, ./index.js, ./index.mjs that exists next to the shim (never the shim itself). - Guardrails: refuse to overwrite an existing file the generator did not write (a bin still pointing at the real CLI entry fails loudly instead of clobbering the build output), and reject a shim that would import itself.
The compile cache feature was only documented in recipes.md and api-reference.md; the package front door never mentioned it. Add a Features bullet and a Faster Startup section (with the generate-shim postbuild setup) to the README, and point the getting-started Next Steps and doc-index recipe blurbs at it.
generate-shim now generates one shim per bin entry: --entry is repeatable and pairs in declaration order with the package's bin entries (or with --out paths of the same count; counts must match). Each shim's program name defaults to its own bin name, so multi-bin packages get correctly keyed cache directories. All shims are validated before any is written, so a failure cannot leave a partially generated set. Also tighten the overwrite-guard marker to a shim-specific string: completion scripts also say "Generated by politty" and must never be treated as overwritable.
A fixed tmpdir path could collide across concurrent vitest runs (watch mode + CI on the same machine) and couple cache state across runs; key the directory by process.pid.
Spawn a fresh node process that never calls enableCompileCache with NODE_COMPILE_CACHE set, and assert the cache directory gets populated — verifying the docs claim that Node honors the variable at process startup, so it applies even with compileCache: false.
|
@coderabbitai review |
✅ Action performedReview finished.
|
Deriving a shim's program name from something other than its own bin entry no longer happens silently: with multiple explicit --out paths, every unmatched shim would share one name (and one cache directory). Generation still proceeds at the specified paths; a warning names the fallback and points at --program. Also align the remaining cache-path JSDoc templates (CompileCacheOptions.programName, MainOptions.compileCache) with the sanitized-name behavior.
The dispatcher scripts use the raw program name while compileCacheDir sanitizes it first; state in the JSDoc that a completion program name is a single shell word resolved on PATH and can never contain separators, so the two paths coincide for every name that can reach both sides.
The self-import guard compared resolve(dirname(out), entry) with the output path, which never matches a file: URL entry — an allowed form — so a shim could be generated that imports itself. Normalize file: entries with fileURLToPath before comparing (unparsable URLs skip the best-effort check).
Functionally identical to the previous default import (Node builtins expose module.exports either way), but the namespace form still links on Node < 22.8 where enableCompileCache is missing — a named import would fail before the runtime feature check — and stops automated reviewers from repeatedly flagging the default-import form. Comment the intent so it is not "simplified" into a named import later.
The generated shim statically imported politty/compile-cache, so a CLI that bundles politty into its build (tsdown noExternal, single-file CLIs) crashed with ERR_MODULE_NOT_FOUND at startup — the optimization became a single point of failure while the real entry ran fine on its own. Load the helper with a dynamic import inside try/catch instead: when politty is not resolvable next to the shim, the CLI starts without the cache. Also document the mismatch warning and this fallback in recipes.md.
Summary
runMainnow enables the Node.js on-disk compile cache (V8 code cache) automatically — Node >= 22.8.0, silent no-op otherwise — so dynamically imported modules such aslazy()subcommands skip recompilation on warm starts. The cache directory follows the same XDG convention as the shell-completion workers (${XDG_CACHE_HOME:-$HOME/.cache}/<command name>/node-compile-cache), so direct CLI runs and TAB-time completion invocations share one warm cache;NODE_COMPILE_CACHEalways takes precedence. Opt out or override via the newMainOptions.compileCacheoption (false| custom directory).politty/compile-cachesubpath exportingenableCompileCache. ESM static imports are compiled during the link phase — before any code runs — so covering the whole CLI graph (politty, zod, command definitions) requires the bin-shim pattern documented indocs/recipes.md: a minimal entry that enables the cache first and loads the real CLI with a dynamic import.politty generate-shimCLI command (andgenerateCompileCacheShimexport) that generates the bin shim at build time, so it can be wired into apostbuild/prepackscript instead of living in source. The program name defaults to the firstbinname inpackage.json;.cjsoutputs and.jsoutputs in non-"type": "module"packages are rejected with a pointer to.mjs.src/cli.ts) is converted into such a shim, with the actual CLI moved tosrc/cli-main.ts.XDG_CACHE_HOMEat the OS tmpdir so test runs never litter the real user cache with fixture command names.Notes
dist/cli.js --help: ~122 ms cold (cache wiped per run) → ~99 ms warm (~19% faster).<XDG>/politty/node-compile-cache/, that a fresh-process integration test covers directory derivation andNODE_COMPILE_CACHEprecedence, and that agenerate-shim-generated shim runs a demo CLI and populates its cache directory.Code Metrics Report
Details
Code coverage of files in pull request scope (91.5% → 91.2%)
Reported by octocov
Summary by CodeRabbit
NODE_COMPILE_CACHEprecedence and opt-out/custom directory viarunMain’scompileCache.politty generate-shimpluspolitty/compile-cacheutilities to generate executable shims per declaredbinentry for faster startup (with safety rules around overwriting/self-import).generate-shimsetup guidance to the README and API/recipes docs.runMainintegration.