fix(pack): bundle dependencies declared in bundleDependencies (#11524)

- Fixes [#11519](https://github.com/pnpm/pnpm/issues/11519): `pnpm pack` in pnpm 11 silently dropped every package listed in `bundleDependencies` / `bundledDependencies`, producing tarballs that no longer contained the bundled `node_modules/<dep>` files that v10 produced.
- Root cause: the npm-packlist v10 upgrade ([#10658](https://github.com/pnpm/pnpm/pull/10658)) changed its API to require the caller to pre-populate the dependency tree's `edgesOut` Map. The wrapper in `fs/packlist` passed an empty Map, so npm-packlist's `gatherBundles()` looked up each declared name, found nothing, and skipped them all.
- Fix: `fs/packlist` now reads each bundled dep's `package.json` (walking up parent `node_modules` to support hoisted layouts), recursively populates `edgesOut` for transitive deps of bundled packages, and normalizes `bundleDependencies: true` to an explicit array (npm-packlist iterates the field directly).
This commit is contained in:
Zoltan Kochan
2026-05-08 22:23:06 +02:00
parent 744399c7a9
commit dd8d5d7597
3 changed files with 194 additions and 10 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/fs.packlist": patch
"pnpm": patch
---
Fix `pnpm pack` not bundling dependencies listed in `bundleDependencies` (or `bundledDependencies`). The npm-packlist upgrade in pnpm 11 changed its API to require the caller to pre-populate the dependency tree, which the wrapper was not doing — `bundleDependencies` were silently dropped from the tarball [#11519](https://github.com/pnpm/pnpm/issues/11519).

View File

@@ -1,26 +1,113 @@
import fs from 'node:fs/promises'
import fs from 'node:fs'
import path from 'node:path'
import util from 'node:util'
import npmPacklist from 'npm-packlist'
interface Edge {
to: TreeNode
peer: boolean
dev: boolean
}
interface TreeNode {
path: string
package: Record<string, unknown>
isProjectRoot?: boolean
isLink: boolean
target: TreeNode
edgesOut: Map<string, Edge>
}
export async function packlist (pkgDir: string, opts?: {
manifest?: Record<string, unknown>
}): Promise<string[]> {
const pkg = opts?.manifest ?? await readPackageJson(pkgDir)
const tree = {
path: pkgDir,
package: normalizePackage(pkg),
isProjectRoot: true,
edgesOut: new Map(),
}
const pkg = opts?.manifest ?? readPackageJson(pkgDir)
const tree = buildRootTree(pkgDir, pkg)
const files = await npmPacklist(tree)
return files.map((file) => file.replace(/^\.[/\\]/, ''))
}
async function readPackageJson (dir: string): Promise<Record<string, unknown>> {
function buildRootTree (pkgDir: string, pkg: Record<string, unknown>): TreeNode {
const bundledDeps = getRootBundledDeps(pkg)
// npm-packlist's gatherBundles() iterates package.bundleDependencies directly,
// so the field must be an array. Normalize true/undefined to an explicit list.
const normalizedPkg = normalizePackage(pkg)
normalizedPkg.bundleDependencies = bundledDeps
delete normalizedPkg.bundledDependencies
const root = makeNode(pkgDir, normalizedPkg, true)
const seen = new Map<string, TreeNode>([[pkgDir, root]])
populateEdges(root, bundledDeps, seen)
return root
}
function buildBundledTree (pkgDir: string, seen: Map<string, TreeNode>): TreeNode {
const cached = seen.get(pkgDir)
if (cached) return cached
const pkg = readPackageJson(pkgDir)
const node = makeNode(pkgDir, normalizePackage(pkg), false)
seen.set(pkgDir, node)
populateEdges(node, getNestedBundledDeps(pkg), seen)
return node
}
function populateEdges (node: TreeNode, deps: string[], seen: Map<string, TreeNode>): void {
for (const dep of deps) {
const depDir = resolveDependency(dep, node.path)
if (!depDir) continue
const depNode = buildBundledTree(depDir, seen)
node.edgesOut.set(dep, { to: depNode, peer: false, dev: false })
}
}
function makeNode (pkgDir: string, pkg: Record<string, unknown>, isProjectRoot: boolean): TreeNode {
const node = {
path: pkgDir,
package: pkg,
isProjectRoot,
isLink: false,
edgesOut: new Map<string, Edge>(),
} as TreeNode
node.target = node
return node
}
function getRootBundledDeps (pkg: Record<string, unknown>): string[] {
const bundle = pkg.bundleDependencies ?? pkg.bundledDependencies
if (Array.isArray(bundle)) return bundle as string[]
if (bundle === true) {
return Object.keys((pkg.dependencies ?? {}) as Record<string, string>)
}
return []
}
function getNestedBundledDeps (pkg: Record<string, unknown>): string[] {
const dependencies = (pkg.dependencies ?? {}) as Record<string, string>
const optionalDependencies = (pkg.optionalDependencies ?? {}) as Record<string, string>
return [...Object.keys(dependencies), ...Object.keys(optionalDependencies)]
}
function resolveDependency (depName: string, fromDir: string): string | undefined {
let currentDir = fromDir
while (true) {
const candidate = path.join(currentDir, 'node_modules', depName)
try {
const stat = fs.statSync(path.join(candidate, 'package.json'))
if (stat.isFile()) return candidate
} catch (err: unknown) {
if (!util.types.isNativeError(err) || !('code' in err) || err.code !== 'ENOENT') {
throw err
}
}
const parent = path.dirname(currentDir)
if (parent === currentDir) return undefined
currentDir = parent
}
}
function readPackageJson (dir: string): Record<string, unknown> {
try {
return JSON.parse(await fs.readFile(path.join(dir, 'package.json'), 'utf8'))
return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
} catch (err: unknown) {
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') {
return {}

View File

@@ -99,6 +99,97 @@ test('pack: with dry-run', async () => {
expect(fs.existsSync('package.json')).toBeTruthy()
})
test('pack: bundles dependencies listed in bundleDependencies', async () => {
prepare({
name: 'pkg-with-bundle-deps',
version: '0.0.0',
bundleDependencies: ['bundled-dep'],
})
fs.mkdirSync('node_modules/bundled-dep', { recursive: true })
fs.writeFileSync('node_modules/bundled-dep/package.json', JSON.stringify({ name: 'bundled-dep', version: '1.0.0' }), 'utf8')
fs.writeFileSync('node_modules/bundled-dep/index.js', 'module.exports = 42', 'utf8')
fs.mkdirSync('node_modules/not-bundled', { recursive: true })
fs.writeFileSync('node_modules/not-bundled/package.json', JSON.stringify({ name: 'not-bundled', version: '1.0.0' }), 'utf8')
await pack.handler({
...DEFAULT_OPTS,
nodeLinker: 'hoisted',
argv: { original: [] },
dir: process.cwd(),
extraBinPaths: [],
packDestination: process.cwd(),
})
await tar.x({ file: 'pkg-with-bundle-deps-0.0.0.tgz' })
expect(fs.existsSync('package/node_modules/bundled-dep/package.json')).toBeTruthy()
expect(fs.existsSync('package/node_modules/bundled-dep/index.js')).toBeTruthy()
expect(fs.existsSync('package/node_modules/not-bundled/package.json')).toBeFalsy()
})
test('pack: bundles every dependency when bundleDependencies is true', async () => {
prepare({
name: 'pkg-with-bundle-deps-true',
version: '0.0.0',
dependencies: {
'bundled-dep': '1.0.0',
},
bundleDependencies: true,
})
fs.mkdirSync('node_modules/bundled-dep', { recursive: true })
fs.writeFileSync('node_modules/bundled-dep/package.json', JSON.stringify({ name: 'bundled-dep', version: '1.0.0' }), 'utf8')
fs.writeFileSync('node_modules/bundled-dep/index.js', 'module.exports = 42', 'utf8')
fs.mkdirSync('node_modules/not-a-dep', { recursive: true })
fs.writeFileSync('node_modules/not-a-dep/package.json', JSON.stringify({ name: 'not-a-dep', version: '1.0.0' }), 'utf8')
await pack.handler({
...DEFAULT_OPTS,
nodeLinker: 'hoisted',
argv: { original: [] },
dir: process.cwd(),
extraBinPaths: [],
packDestination: process.cwd(),
})
await tar.x({ file: 'pkg-with-bundle-deps-true-0.0.0.tgz' })
expect(fs.existsSync('package/node_modules/bundled-dep/index.js')).toBeTruthy()
expect(fs.existsSync('package/node_modules/bundled-dep/package.json')).toBeTruthy()
expect(fs.existsSync('package/node_modules/not-a-dep/package.json')).toBeFalsy()
})
test('pack: bundles transitive dependencies of bundled dependencies (hoisted)', async () => {
prepare({
name: 'pkg-with-transitive-bundle-deps',
version: '0.0.0',
bundledDependencies: ['top'],
})
fs.mkdirSync('node_modules/top', { recursive: true })
fs.writeFileSync(
'node_modules/top/package.json',
JSON.stringify({ name: 'top', version: '1.0.0', dependencies: { nested: '1.0.0' } }),
'utf8'
)
fs.writeFileSync('node_modules/top/index.js', 'top', 'utf8')
fs.mkdirSync('node_modules/nested', { recursive: true })
fs.writeFileSync('node_modules/nested/package.json', JSON.stringify({ name: 'nested', version: '1.0.0' }), 'utf8')
fs.writeFileSync('node_modules/nested/index.js', 'nested', 'utf8')
await pack.handler({
...DEFAULT_OPTS,
nodeLinker: 'hoisted',
argv: { original: [] },
dir: process.cwd(),
extraBinPaths: [],
packDestination: process.cwd(),
})
await tar.x({ file: 'pkg-with-transitive-bundle-deps-0.0.0.tgz' })
expect(fs.existsSync('package/node_modules/top/index.js')).toBeTruthy()
expect(fs.existsSync('package/node_modules/nested/index.js')).toBeTruthy()
})
test('pack when there is bundledDependencies but without node-linker=hoisted', async () => {
prepare({
name: 'bundled-deps-without-node-linker-hoisted',