diff --git a/.changeset/pack-bundle-dependencies.md b/.changeset/pack-bundle-dependencies.md new file mode 100644 index 0000000000..5c5b9411e7 --- /dev/null +++ b/.changeset/pack-bundle-dependencies.md @@ -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). diff --git a/fs/packlist/src/index.ts b/fs/packlist/src/index.ts index e0a993c884..e0cf24e3e6 100644 --- a/fs/packlist/src/index.ts +++ b/fs/packlist/src/index.ts @@ -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 + isProjectRoot?: boolean + isLink: boolean + target: TreeNode + edgesOut: Map +} + export async function packlist (pkgDir: string, opts?: { manifest?: Record }): Promise { - 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> { +function buildRootTree (pkgDir: string, pkg: Record): 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([[pkgDir, root]]) + populateEdges(root, bundledDeps, seen) + return root +} + +function buildBundledTree (pkgDir: string, seen: Map): 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): 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, isProjectRoot: boolean): TreeNode { + const node = { + path: pkgDir, + package: pkg, + isProjectRoot, + isLink: false, + edgesOut: new Map(), + } as TreeNode + node.target = node + return node +} + +function getRootBundledDeps (pkg: Record): 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) + } + return [] +} + +function getNestedBundledDeps (pkg: Record): string[] { + const dependencies = (pkg.dependencies ?? {}) as Record + const optionalDependencies = (pkg.optionalDependencies ?? {}) as Record + 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 { 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 {} diff --git a/releasing/commands/test/publish/pack.ts b/releasing/commands/test/publish/pack.ts index d1e094878a..1d27c81f3d 100644 --- a/releasing/commands/test/publish/pack.ts +++ b/releasing/commands/test/publish/pack.ts @@ -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',