Repository navigation
Conversation
bnusunny
force-pushed
the
fix/nodejs-monorepo-artifacts-933
branch
from
September 30, 2026 17:24
070a021 to
bb7368b
Compare
1 task done
…s monorepo npm hoists a workspace package's dependencies to the monorepo root, so node_modules never appears beside the function and the artifacts link found no source at all - silently producing a deployment package with no dependencies. Ask npm where its project root is, the same question the lockfile lookup asks, and link the artifacts there. Only a project root outside the install directory redirects the link, so anything that is not a workspace member keeps the path it had. Refs aws#933
bnusunny
force-pushed
the
fix/nodejs-monorepo-artifacts-933
branch
from
October 5, 2026 18:47
bb7368b to
eebcfc3
Compare
bnusunny
force-pushed
the
fix/nodejs-monorepo-artifacts-933
branch
from
October 5, 2026 21:55
eebcfc3 to
1c026ca
Compare
licjun
previously approved these changes
Oct 7, 2026
roger-zhangg
reviewed
Oct 7, 2026
roger-zhangg
reviewed
Oct 7, 2026
…o tree Linking the monorepo root's whole node_modules into the artifacts also ships every sibling function's dependencies, because npm hoists them all into one directory. Ask npm which packages this function resolves (npm ls --all --parseable --omit=dev) and link those under their own names. A workspace dependency is reported as its source directory, whose basename is not the package name, so the name comes from its manifest. A nested copy that a dependency pins to another version stays inside that dependency rather than being hoisted, where it would shadow the top-level version. When npm cannot answer, link the whole installed tree: an over-complete node_modules still runs, an empty one does not. Refs aws#933
bnusunny
force-pushed
the
fix/nodejs-monorepo-artifacts-933
branch
from
October 7, 2026 22:13
1c026ca to
81e1b84
Compare
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: Fixes #933
Description of changes
nodejs_npmwith--build-in-sourceproduces an artifact with no dependencies at all for an npm workspaces monorepo, and reports success. npm hoists a workspace member's dependencies to the monorepo root, so nothing is installed beside the function; the artifacts link looks fornode_modulesnext to the function, finds none, and silently skips. The function then fails at runtime withCannot find module.Opt-in while it rolls out. Both changes are behind
experimentalNodejsMonorepo- sam-cli'sExperimentalFlag.NodejsMonorepo, the same flag #931 uses. Without it npm is not asked where its projectroot is and the artifacts link stays exactly where it is today, which for a monorepo means this bug is
still there: that is deliberate, so a release can carry the fix while users opt into it.
test_without_the_flag_a_monorepo_keeps_the_plain_link_and_npm_is_not_askedpins that side.Two changes, one per commit.
cabe7ee— link the artifacts to wherever npm actually installed.SubprocessNpm.resolve_project_rootasks npm itself (npm prefix, cached per directory), and only a project root outside the install directory redirects the link. npm answers with the install directory itself for anything that is not a workspace member, so every existing path — including the external-manifest one, which links through the source tree — is left exactly as it was.070a021— ship only the function's own dependencies. Linking the whole hoisted root would put every sibling function's dependencies into every artifact.NodejsNpmLinkDependencyClosureActionlinks just the packages the function resolves, each under its own name:@mono/shared)node_modulesstill runsBoth path comparisons go through
os.path.normcase. They compare npm's stdout against a build-computed path, and on Windows two spellings that differ only in case — a drive letter included — name the same directory; reading them as different roots would redirect the link for a project that is not a workspace member, or hoist a nested copy. It is a no-op off Windows.The esbuild workflow needs none of this: it bundles what each entry point imports into the output file, resolving through the hoisted root, so its artifacts were already correct.
New fixture manifests and lockfiles under the
nodejs_npmintegrationtestdata/directory are test data, not a dependency change to this package.Design notes
Detail that reviewers asked for, kept here rather than in the code comments.
Names come from the install path, not the manifest. npm supports aliases:
"lodash4": "npm:lodash@^4.0.0"installs atnode_modules/lodash4while the manifest inside still says"name": "lodash". Reading the manifest renames the package, andrequire("lodash4")then fails withCannot find module— the same class of failure this PR fixes.npm ls --parseableprints only the path, so_link_nametakes the segments after the lastnode_modulescomponent, scope included.That also keeps an untrusted value out of a filesystem path: a dependency's manifest is third-party input, and
"name": "../../../evil"or"/tmp/x"joined onto the destination writes outside the artifacts. The manifest is read only for a path that is not undernode_modules— a workspace dependency, which npm reports as its own source directory — and that value is validated againstNODE_MODULES_PACKAGE_NAME.Two guards, covering different things. With the name rule relaxed to
^.*$, the containment assert alone stops../../../eviland..but allows/tmp/evilanda/b/c; both of those stay inside the artifacts, becauseos.path.join(destination, *"/tmp/evil".split("/"))passes an empty first segment rather than an absolute path. So the name rule is the primary control and the assert covers traversal. The assert resolves the link's parent and appends the final component unresolved — resolving the link itself follows an existing symlink to its target, which is outside the artifacts by design, and would refuse every legitimate re-link — then normalises, becausecommonpathcompares components without interpreting a trailing...Same-name collisions are resolved explicitly. A function pinning its own version of a package the root also hoists yields
install_dir/node_modules/lodashandproject_root/node_modules/lodash; neither is nested inside the other, so both reach the link step, andcreate_symlink_or_copyreturns early on an existing destination — whichever npm printed first used to win._packages_by_namegives the function's own copy precedence, since that is what its code resolves. The hoisted version is not lost for anyone else: every package is linked as a symlink into the real tree, so a dependency resolvinglodashfrom inside its real directory still walks up to the root's copy.This is not fixable by treating
install_diras a nesting container, which is the obvious reading of "the nesting test never matches install_dir".project_rootcontains every hoisted package, so adding it as a container would exclude all of them, and excluding the function's own pinned copy would reinstate the bug this action exists to fix.The no-closure fallback is an overlay, not one link. Linking
project_root/node_modulesas the artifacts'node_modulesis simpler and is not a superset of what the function resolves: a member's dependency that conflicts with the hoisted version is installed under the member's ownnode_modules, and a single link to the root cannot carry it, so the function would resolve the root's different version or nothing at all._link_every_installed_packagelinks entries one at a time from the root then the function's own directory, resolved into one mapping before anything is linked (linking as it goes would keep the first of each name, the opposite of the needed precedence). That also removes a dangling link:os.symlinksucceeds against a missing target on POSIX, so a function with no installed dependencies previously got anode_modulespointing nowhere.Failing the build instead was the alternative. Rejected because
resolve_dependency_closurereturnsNonewhenevernpm lsexits non-zero, and a missing peer dependency does that on a perfectly usable tree — common enough that failing would make the flag unusable for many real monorepos.Description of how you validated changes
The new integration test fails without the fix. With this branch's tests kept and only the three production files reverted to #931,
test_build_in_source_in_workspaces_monorepo_links_the_hoisted_dependenciesfails on all five supported runtimes withAssertionError: False is not true : the artifacts have no node_modules at all— the #933 symptom exactly.The fixture is a workspaces monorepo with two functions on disjoint dependencies (
minimal-request-promise,ms), a shared workspace package both depend on, and a version conflict the shared package pins so npm keeps a nested copy. Against the real npm, across every supported runtime, the test asserts each function's artifacts carry its own dependency and the shared package but not its sibling's, that the nested conflicting copy stays nested, and thatnode -e "require('./included.js')"succeeds from each artifacts directory — so the artifact is covered, not just the installed tree.Unit tests cover the closure selection (
test_links_only_this_function_s_dependencies,test_links_a_workspace_dependency_under_its_own_name,test_leaves_a_nested_copy_inside_the_dependency_that_pins_it), the workflow wiring for both the monorepo and the unchanged non-monorepo path, andnpm prefixresolution including its per-directory caching and that a failure is cached too.One test per review finding, each mutation-checked — reverting the fix fails the test:
test_an_aliased_dependency_keeps_the_name_npm_installed_it_undertest_a_hostile_manifest_name_cannot_write_outside_the_artifactstest_the_function_s_own_pinned_copy_wins_a_name_it_shares_with_the_hoisted_onetest_the_fallback_carries_the_function_s_own_non_hoisted_dependenciestest_the_fallback_links_nothing_rather_than_a_dangling_node_modulesThe manifest is now needed only outside
node_modules, sotest_fails_loudly_when_a_reported_package_has_no_manifestbecametest_fails_loudly_when_a_workspace_dependency_has_no_manifest, withtest_a_package_under_node_modules_needs_no_manifest_at_allpinning the other half.ruff checkandblack --checkclean.npm prefixand hoisting are npm-version dependent.create_symlink_or_copy, which falls back to a copy whenos.symlinkis not permitted; thewindows-latestlanes on this PR are green.Checklist
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.