Repository navigation
perf(nodejs): derive a monorepo function's dependency closure from the lockfile - #938
Merged
Merged
Conversation
bnusunny
force-pushed
the
fix/nodejs-closure-from-lockfile
branch
from
October 9, 2026 22:25
8abc789 to
1b5b92b
Compare
bnusunny
force-pushed
the
fix/nodejs-closure-from-lockfile
branch
from
October 9, 2026 23:09
1b5b92b to
fb8fcca
Compare
…pm ls The artifacts link asks npm which packages a workspace function resolves, and `npm ls --all --parseable --omit=dev` costs 0.33s per function - the largest remaining per-function cost once the install is no longer the bottleneck. The lockfile the build already locates describes the same resolved tree, so read it instead: walk the lockfileVersion 2/3 `packages` map from the member's own entry, resolve each name the way node does (`<key>/node_modules/<name>`, walking up), follow `link: true` entries to their source directory, and skip anything marked `dev`. A version 1 lockfile has no path map, so that falls back to `npm ls`, as does a lockfile that cannot be read. An entry can be in the lockfile without being on disk: `fsevents` is darwin-only and optional, so webpack's and jest's closures list it while Linux never installs it. `npm ls` reports the installed tree, so the resolver keeps only directories that exist; a required dependency missing there means the install itself failed, which the install step already surfaces. Behaviour is unchanged, which is the point - the existing integration tests that assert each function's artifacts hold its own dependencies and not its sibling's are the regression proof, and they pass unchanged. The new unit tests cover the resolver itself: nested copies, scoped names, workspace links, dev entries, version 1 fallback, an unreadable lockfile, and an entry that is not on disk.
bnusunny
force-pushed
the
fix/nodejs-closure-from-lockfile
branch
from
October 15, 2026 17:20
fb8fcca to
8e2915f
Compare
roger-zhangg
approved these changes
Oct 15, 2026
licjun
approved these changes
Oct 15, 2026
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue #, if available: N/A — a performance change on the path #933 added. No linked issue.
Description of changes
Goal: stop spawning one
npm lsper function when building a workspaces monorepo in source.#935 narrows a function's artifacts to its own dependencies by asking
npm ls --all --parseable --omit=devwhat that function resolves. Correct, and it costs an npm process per function — twenty sequential node startups on a 20-function monorepo.The install already wrote that answer down.
lockfile_closure.production_closurereads npm's hidden lockfile,node_modules/.package-lock.json, whosepackagesmap is keyed by install path — so resolving a dependency is the walk node itself performs: look for<prefix>/node_modules/<name>, strip a segment, repeat.npm lsnpm lsTwo generated fixture sets, npm 11.19.0, Linux; medians of repeated runs.
The hidden lockfile, not the project's own. The in-source install runs
npm install --no-save, which leavespackage-lock.jsonuntouched — so a manifest naming a dependency that lockfile has not recorded installs the package while the file still denies it exists. Only the hidden one records the tree npm reified.Peers are followed. npm 7+ installs peer dependencies and
npm ls --allwalks them, so skipping them drops a package the function resolves.npm lsremains the fallback, taken whenever the hidden lockfile cannot answer: absent (npm 6, or a tree another package manager built), stale againstnode_modulesby npm's own mtime rule, too old alockfileVersion, unreadable, or silent about this install directory. Artifacts, and the experimental-flag gating, are unchanged.Description of how you validated changes
The lockfile's answer is checked against npm's, not against a fixture.
test_the_lockfile_closure_agrees_with_npm_ls_on_a_real_installruns a realnpm installon theworkspaces-monorepofixture and asserts both endpoints' closures are set-equal withnpm ls. A unit test cannot establish this — it would compare this code against a fixture written from this code. That fixture now carries a peer edge (packages/sharedpeer-depends onfake-http-request) precisely because its absence let the peer bug through a passing test.test_a_manifest_the_project_lockfile_has_not_recorded_still_reaches_the_artifactsbuilds the stale-lockfile case end to end and asserts the module reaches the artifacts.Each fix is mutation-checked — reverting it fails its test:
_resolve_keynpm lsequivalence testpeerDependenciesfollowedtest_a_manifest_the_project_lockfile_has_not_recorded...>=in the staleness guardtest_a_hidden_lockfile_as_recent_as_the_tree_is_trustedThat last one matters more than it looks: a fresh install writes the hidden lockfile and
node_modulesin the same clock tick, so a strict>would send every first build down the fallback with every test still green.Unit tests cover the resolver directly — a member's own dependencies plus its shared workspace package, a conflicting version resolving to the nested copy, a member wanting the hoisted version getting both, dev dependencies and declined optional peers excluded — and every way it declines to answer. Three wiring tests pin which source was used (
assert_not_called/assert_called_once_with), without which the lockfile path could stop being taken silently.ruff checkandblack --checkclean.lockfileVersion: 2, not the 3 npm's docs claim, and the resolver's floor of 2 accepts both.Checklist
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.