Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/quiet-roots-trace.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
97 changes: 88 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -43,6 +69,7 @@ export const nodeDepEmit = async ({
fileCache: false,
symlinkCache: false,
},
traceRoot,
traceOptions,
}: {
/**
Expand All @@ -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<NodeFileTraceOptions, "base">;
}) => {
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),
Expand All @@ -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<string, PackageJson>();

Expand All @@ -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;
Expand Down Expand Up @@ -171,7 +250,7 @@ export const nodeDepEmit = async ({
parents,
isDirectDep: parents.some((parent) => {
return (
isSubPath(appDir, parent) &&
isSubPath(tracingAppDir, parent) &&
!isSubPath(currentProjectModules, parent)
);
}),
Expand Down
4 changes: 2 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ export const traceFiles = async ({
sourceDir: string;
base?: string;
cacheOptions: CacheOptions;
traceOptions?: NodeFileTraceOptions;
traceOptions?: Omit<NodeFileTraceOptions, "base">;
}) => {
const { cacheDir, fileCache, analysisCache, symlinkCache } = cacheOptions;
const analysisCacheFile = path.join(cacheDir, "analysis-cache.json");
Expand All @@ -311,10 +311,10 @@ export const traceFiles = async ({
};

const res = await nodeFileTrace(entryFiles, {
base,
processCwd: sourceDir,
cache,
...traceOptions,
base,
});

if (analysisCache || fileCache || symlinkCache) {
Expand Down
91 changes: 91 additions & 0 deletions tests/nde.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
): Promise<void> => {
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');
Expand Down Expand Up @@ -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";');
});
});

});
55 changes: 55 additions & 0 deletions tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,17 @@
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 {
Expand Down Expand Up @@ -131,4 +140,50 @@
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(

Check failure on line 173 in tests/utils.test.ts

View workflow job for this annotation

GitHub Actions / Test

tests/utils.test.ts > utils > traceFiles > keeps ownership of base, processCwd, and cache while forwarding other trace options

AssertionError: expected "spy" to be called with arguments: [ [ '/app/dist/index.js' ], …(1) ] Received: 1st spy call: Array [ Array [ "/app/dist/index.js", ], - ObjectContaining { + Object { "analysis": false, "base": "/trace-root", "cache": Object { - "analysisCache": undefined, - "fileCache": undefined, - "symlinkCache": undefined, + "custom": true, }, - "processCwd": "/app/dist", + "processCwd": "/overridden-cwd", "readFile": [Function spy], }, ] Number of calls: 1 ❯ tests/utils.test.ts:173:33
['/app/dist/index.js'],
expect.objectContaining({
base: '/trace-root',
processCwd: '/app/dist',
cache: {
analysisCache: undefined,
fileCache: undefined,
symlinkCache: undefined,
},
analysis: false,
readFile,
}),
);
});
});
});
Loading