diff --git a/.changeset/quiet-roots-trace.md b/.changeset/quiet-roots-trace.md new file mode 100644 index 0000000..6ec676f --- /dev/null +++ b/.changeset/quiet-roots-trace.md @@ -0,0 +1,7 @@ +--- +'ndepe': patch +--- + +Add a configurable, canonical dependency trace root and keep nft tracing and +path restoration aligned to the same boundary. +`traceOptions.base` can no longer override the ndepe-managed trace root. diff --git a/README.md b/README.md index 7739fe8..5da3c55 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,23 @@ nodeDepEmit({ ``` +### Trace root + +Use `traceRoot` to set the `@vercel/nft` dependency tracing boundary: + +```js +nodeDepEmit({ + appDir: appDirectory, + sourceDir: sourceDirectory, + traceRoot: '../..', +}) +``` + +Relative paths are resolved from `appDir`; absolute paths are used as the root +candidate. Configured roots are canonicalized before tracing. An empty string +resolves to `appDir`, and omitting `traceRoot` keeps the existing `/` default. + +The root must contain the application output and all runtime workspace packages +and dependencies. It is an analysis boundary, not a security sandbox. Ndepe +manages `traceOptions.base`; other nft options and filesystem hooks are +forwarded. diff --git a/src/index.ts b/src/index.ts index 0264f73..54f17d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,32 @@ export type { NodeFileTraceOptions } from "@vercel/nft"; export type { TransformPackageJsonHook } from "./utils"; export { nodeFileTrace } from "@vercel/nft"; +const isPathInsideOrEqual = (parentPath: string, childPath: string) => { + const relativePath = path.relative(parentPath, childPath); + return ( + relativePath === "" || + (!path.isAbsolute(relativePath) && + relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`)) + ); +}; + +const resolveTracingPath = async ( + filePath: string, + traceBoundary: string, + base: string, +) => { + const resolvedPath = path.resolve(filePath); + return fse.realpath(resolvedPath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + return isPathInsideOrEqual(traceBoundary, resolvedPath) + ? path.resolve(base, path.relative(traceBoundary, resolvedPath)) + : resolvedPath; + }); +}; + export const nodeDepEmit = async ({ appDir, sourceDir, @@ -43,6 +69,7 @@ export const nodeDepEmit = async ({ fileCache: false, symlinkCache: false, }, + traceRoot, traceOptions, }: { /** @@ -69,15 +96,64 @@ export const nodeDepEmit = async ({ transformPackageJson?: TransformPackageJsonHook; copyWholePackage?: (pkgName: string, pkgJSON: PackageJson) => boolean; cacheOptions?: CacheOptions; - traceOptions?: NodeFileTraceOptions; + /** + * Boundary used by node file tracing. Relative paths are resolved from appDir. + * Defaults to the filesystem root for backward compatibility. + */ + traceRoot?: string; + /** + * Options forwarded to nodeFileTrace. base is managed by ndepe and cannot + * be overridden here. + */ + traceOptions?: Omit; }) => { - const base = "/"; + const traceBoundary = + traceRoot === undefined ? "/" : path.resolve(appDir, traceRoot); + + let base = traceBoundary; + let tracingAppDir = appDir; + let tracingSourceDir = sourceDir; + if (traceRoot !== undefined) { + base = await fse.realpath(traceBoundary); + [tracingAppDir, tracingSourceDir] = await Promise.all( + [appDir, sourceDir].map((filePath) => + resolveTracingPath(filePath, traceBoundary, base), + ), + ); + + if (!isPathInsideOrEqual(base, tracingSourceDir)) { + throw new Error( + `The trace root "${traceBoundary}" must contain sourceDir "${path.resolve(sourceDir)}".`, + ); + } + } + const entryFiles = await findEntryFiles(sourceDir, entryFilter); + const allEntryFiles = entryFiles.concat(includeEntries || []); + let tracingEntryFiles = allEntryFiles; + if (traceRoot !== undefined) { + tracingEntryFiles = await Promise.all( + allEntryFiles.map((entryFile) => + resolveTracingPath(entryFile, traceBoundary, base), + ), + ); + + const outsideEntryFiles = tracingEntryFiles.filter( + (entryFile) => !isPathInsideOrEqual(base, entryFile), + ); + if (outsideEntryFiles.length > 0) { + throw new Error( + `The trace root "${traceBoundary}" must contain every entry file. Outside entries:\n${outsideEntryFiles + .map((entryFile) => `- "${entryFile}"`) + .join("\n")}`, + ); + } + } debug("trace files start"); const fileTrace = await traceFiles({ - entryFiles: entryFiles.concat(includeEntries || []), - sourceDir, + entryFiles: tracingEntryFiles, + sourceDir: tracingSourceDir, cacheOptions: { ...cacheOptions, cacheDir: path.resolve(appDir, cacheOptions.cacheDir), @@ -86,9 +162,12 @@ export const nodeDepEmit = async ({ traceOptions, }); debug("trace files end"); - const currentProjectModules = path.join(appDir, "node_modules"); + const currentProjectModules = path.join(tracingAppDir, "node_modules"); // Because vercel/nft may find inaccurately, we limit the range of query of dependencies - const dependencySearchRoot = path.resolve(appDir, "../../../../../../"); + const dependencySearchRoot = path.resolve( + tracingAppDir, + "../../../../../../", + ); const packageJsonCache = new Map(); @@ -101,8 +180,8 @@ export const nodeDepEmit = async ({ const filePath = await resolveTracedPath(base, _path); if ( - isSubPath(sourceDir, filePath) || - (isSubPath(appDir, filePath) && + isSubPath(tracingSourceDir, filePath) || + (isSubPath(tracingAppDir, filePath) && !isSubPath(currentProjectModules, filePath)) ) { return; @@ -171,7 +250,7 @@ export const nodeDepEmit = async ({ parents, isDirectDep: parents.some((parent) => { return ( - isSubPath(appDir, parent) && + isSubPath(tracingAppDir, parent) && !isSubPath(currentProjectModules, parent) ); }), diff --git a/src/utils.ts b/src/utils.ts index 2997c45..929ba4b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -297,7 +297,7 @@ export const traceFiles = async ({ sourceDir: string; base?: string; cacheOptions: CacheOptions; - traceOptions?: NodeFileTraceOptions; + traceOptions?: Omit; }) => { const { cacheDir, fileCache, analysisCache, symlinkCache } = cacheOptions; const analysisCacheFile = path.join(cacheDir, "analysis-cache.json"); @@ -311,10 +311,10 @@ export const traceFiles = async ({ }; const res = await nodeFileTrace(entryFiles, { - base, processCwd: sourceDir, cache, ...traceOptions, + base, }); if (analysisCache || fileCache || symlinkCache) { diff --git a/tests/nde.test.ts b/tests/nde.test.ts index 71e5f1c..927db4b 100644 --- a/tests/nde.test.ts +++ b/tests/nde.test.ts @@ -1,8 +1,31 @@ import { expect, describe, it, afterEach } from 'vitest' +import os from 'node:os' import path from 'node:path' +import type { NodeFileTraceResult } from '@vercel/nft'; import fse from 'fs-extra' import { nodeDepEmit } from '../src'; +import { traceFiles as defaultTraceFiles } from '../src/utils'; + +const emptyTraceResult: NodeFileTraceResult = { + fileList: new Set(), + esmFileList: new Set(), + warnings: new Set(), + reasons: new Map(), +}; + +const withTempDir = async ( + run: (tempDir: string) => Promise, +): Promise => { + const tempDir = await fse.realpath( + await fse.mkdtemp(path.join(os.tmpdir(), 'nde-trace-root-')), + ); + try { + await run(tempDir); + } finally { + await fse.remove(tempDir); + } +}; describe('handle dependencies', () => { const project1Dir = path.join(__dirname, 'fixtures/project1'); @@ -51,4 +74,72 @@ describe('handle workspace packages with directory entry points', () => { }) }) +describe('trace root', () => { + it('supports default, empty, relative, and absolute trace roots', async () => { + await withTempDir(async tempDir => { + const appDir = path.join(tempDir, 'app'); + const sourceDir = path.join(appDir, 'dist'); + const tracedBases: string[] = []; + await fse.outputFile( + path.join(sourceDir, 'index.js'), + 'module.exports = 1;', + ); + + for (const traceRoot of [undefined, '', tempDir]) { + await nodeDepEmit({ + appDir, + sourceDir, + traceRoot, + traceFiles: async options => { + tracedBases.push(options.base || ''); + return emptyTraceResult; + }, + }); + } + + expect(tracedBases).toEqual(['/', appDir, tempDir]); + }); + }); + + it('uses the same relative trace root for tracing and dependency copying', async () => { + await withTempDir(async traceRoot => { + const appDir = path.join(traceRoot, 'apps/app'); + const sourceDir = path.join(appDir, 'dist'); + const dependencyDir = path.join(traceRoot, 'node_modules/test-dependency'); + let tracedBase: string | undefined; + + await fse.outputJSON(path.join(dependencyDir, 'package.json'), { + name: 'test-dependency', + version: '1.0.0', + main: 'index.js', + }); + await fse.outputFile( + path.join(dependencyDir, 'index.js'), + 'module.exports = "test";', + ); + await fse.outputFile( + path.join(sourceDir, 'index.js'), + 'module.exports = require("test-dependency");', + ); + + await nodeDepEmit({ + appDir, + sourceDir, + traceRoot: '../..', + traceFiles: async options => { + tracedBase = options.base; + return defaultTraceFiles(options); + }, + }); + + expect(tracedBase).toBe(traceRoot); + await expect( + fse.readFile( + path.join(sourceDir, 'node_modules/test-dependency/index.js'), + 'utf8', + ), + ).resolves.toBe('module.exports = "test";'); + }); + }); +}); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 39f21c9..e48f080 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -8,8 +8,17 @@ import { isFile, findEntryFiles, isSubPath, + traceFiles, } from '../src/utils'; +const { nodeFileTraceMock } = vi.hoisted(() => ({ + nodeFileTraceMock: vi.fn(), +})); + +vi.mock('@vercel/nft', () => ({ + nodeFileTrace: nodeFileTraceMock, +})); + vi.mock('fs-extra', () => { const actual = vi.importActual('fs-extra'); return { @@ -131,4 +140,50 @@ describe('utils', () => { expect(isSubPath('/parent', '/parent2/sibling')).toBe(false); }); }); + + describe('traceFiles', () => { + it('keeps ownership of base, processCwd, and cache while forwarding other trace options', async () => { + const readFile = vi.fn(); + nodeFileTraceMock.mockResolvedValue({ + fileList: new Set(), + esmFileList: new Set(), + reasons: new Map(), + warnings: new Set(), + }); + + await traceFiles({ + entryFiles: ['/app/dist/index.js'], + sourceDir: '/app/dist', + base: '/trace-root', + cacheOptions: { + cacheDir: '/cache', + analysisCache: false, + fileCache: false, + symlinkCache: false, + }, + traceOptions: { + base: '/overridden-root', + processCwd: '/overridden-cwd', + cache: { custom: true }, + analysis: false, + readFile, + }, + }); + + expect(nodeFileTraceMock).toHaveBeenCalledWith( + ['/app/dist/index.js'], + expect.objectContaining({ + base: '/trace-root', + processCwd: '/app/dist', + cache: { + analysisCache: undefined, + fileCache: undefined, + symlinkCache: undefined, + }, + analysis: false, + readFile, + }), + ); + }); + }); });