mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-01 12:41:16 -04:00
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:
6
.changeset/pack-bundle-dependencies.md
Normal file
6
.changeset/pack-bundle-dependencies.md
Normal 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).
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user