web-infra-dev / web-infra-dev/nde
nodeDepEmit emits a package.json whose entry point was never copied — bare require() of a subpath-only package crashes at runtime
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13
- Forks
- 3
- PR merge metrics
- No merged PRs in 30d
Description
What happens
nodeDepEmit copies only the files that @vercel/nft traced, but for every traced package it writes the package's full package.json. When a package is only ever reached through a subpath (for example zod/v3), the root entry point that its main / exports["."] points at is never traced, so it is not copied — yet the emitted manifest still declares it.
The resulting node_modules is self-inconsistent: Node resolves the manifest and then throws on the first bare require of that package:
Error: Cannot find module '<output>/node_modules/zod/index.cjs'
In writePackage (src/utils.ts), the copy loop writes pkg.versions[version].files and then unconditionally writes the manifest. Nothing guarantees that the file the manifest's root entry resolves to is in files.
Minimal reproduction
// index.js — the traced entry
require('zod/v3');
Run nodeDepEmit over a directory containing this file. The output contains node_modules/zod/package.json (with "main": "./index.cjs") but no index.cjs. Any runtime require('zod') against that output crashes.
How we hit it in production
Under pnpm's isolated layout our app resolves more packages to two versions than a hoisted layout does. nodeDepEmit parks each multi-version package under .ndepe/<name>@<version>/node_modules/<name> and links it into its parents with symlinks. Our packaging step dereferences those symlinks, so a parked package loses its sibling dependency links, and its bare require('zod') falls through to the hoisted top-level zod — which is exactly the copy whose entry point was never traced. The pod crash-looped on Cannot find module '.output/node_modules/zod/index.cjs'.
(The symlink dereferencing is our packaging's fault, but the fall-through only crashes because the emitted top-level copy violates its own manifest.)
Proposed fix
Invariant: never emit a package.json whose root entry point was not copied.
After the initial trace, for each emitted package version whose root entry (resolved from exports["."] / main / index.js) exists on disk in the source package but is not in the traced file set, add that entry file as an additional trace entry and merge its closure into the package's files.
Notes on the edge cases:
- Copying just the entry file is not enough — its own
requires were not traced either, so the crash only moves deeper. The closure is needed. - Packages whose manifest root entry does not resolve even in the source package (types-only packages,
mainpointing at unpublishedsrc/) must be skipped silently; they were never loadable and should not fail the build. - This can grow output size (the root entry's closure may cross into other packages), so you may prefer it behind an option.
copyWholePackagealready exists as a per-name escape hatch for tracer blind spots — Modern.js hardcodes it for@modern-js/utils— but it requires each user to discover each broken package by crashing first.
Happy to send a PR once you have a preference between default-on and opt-in.
Environment
- ndepe 0.1.13, invoked by
modern deploy(@modern-js/app-tools3.8.2) - Node.js 22, pnpm with isolated
node_modules
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/utils.ts at writePackage and trace how nodeDepEmit builds each package version's file set and writes package.json. Reproduce the zod/v3 case, then inspect how the root entry is resolved from exports["."], main, or index.js and how traced closures are merged. Done means emitted manifests do not point to missing loadable root entries, while unresolved source entries are skipped silently; the default-on versus opt-in choice remains open.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- build-system, devops
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100