Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/stupid-weeks-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/builders": patch
---

catch node builtin usage when entry fields diverge
77 changes: 77 additions & 0 deletions packages/builders/src/node-module-esbuild-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,83 @@ describe('workflow-node-module-error plugin', () => {
}
});

it('should detect packages when esbuild and resolver choose different entry fields', async () => {
const tempDir = mkdtempSync(join(realTmpdir, 'node-module-plugin-test-'));
const nodeModulesDir = join(tempDir, 'node_modules', 'dual-entry-package');
const esmDir = join(nodeModulesDir, 'esm');
const cjsDir = join(nodeModulesDir, 'cjs');
const { mkdirSync } = await import('node:fs');
mkdirSync(esmDir, { recursive: true });
mkdirSync(cjsDir, { recursive: true });

writeFileSync(
join(esmDir, 'index.js'),
`
import os from "os";
export function getPlatform() {
return os.platform();
}
`
);
writeFileSync(
join(cjsDir, 'index.cjs'),
`
module.exports = {
getPlatform() {
return "cjs";
}
};
`
);
writeFileSync(
join(nodeModulesDir, 'package.json'),
JSON.stringify({
name: 'dual-entry-package',
main: 'cjs/index.cjs',
module: 'esm/index.js',
})
);

const testCode = `
import { getPlatform } from "dual-entry-package";
export function workflow() {
return getPlatform();
}
`;
const entryFile = join(tempDir, 'workflow.ts');
writeFileSync(entryFile, testCode);
const relativeEntry = relative(process.cwd(), entryFile);

try {
await esbuild.build({
entryPoints: [entryFile],
bundle: true,
write: false,
platform: 'neutral',
format: 'cjs',
mainFields: ['module', 'main'],
logLevel: 'silent',
plugins: [createNodeModuleErrorPlugin()],
});
throw new Error('Expected build to fail');
} catch (error: any) {
if (error && typeof error === 'object' && 'errors' in error) {
const failure = error as esbuild.BuildFailure;
expect(failure.errors).toHaveLength(1);
const violation = failure.errors[0];
expect(violation.text).toContain('"dual-entry-package"');
expect(violation.text).toContain('depends on Node.js modules');
expect(violation.location).toMatchObject({
file: relativeEntry,
});
} else {
throw error;
}
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('should find usage of namespace imports', async () => {
const testCode = `
import * as fs from "fs";
Expand Down
62 changes: 60 additions & 2 deletions packages/builders/src/node-module-esbuild-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ export function getPackageName(filePath: string) {
return packageName;
}

/**
* Get the package name from an import specifier.
* @param specifier - The import specifier to parse.
* @returns The package name (for bare package imports), otherwise null.
*/
function getPackageNameFromSpecifier(specifier: string) {
// Not a bare package specifier (relative, absolute, or URL-like)
if (
!specifier ||
specifier.startsWith('.') ||
specifier.startsWith('/') ||
/^[A-Za-z]:[\\/]/.test(specifier)
) {
return null;
}

// Ignore runtime built-ins and URL-like specifiers
if (
runtimeModulesRegex.test(specifier) ||
specifier.includes('://') ||
specifier.startsWith('#')
) {
return null;
}

const normalized = specifier.replace(/\\/g, '/');
if (normalized.startsWith('@')) {
const [scope, name] = normalized.split('/');
if (!scope || !name) {
return null;
}
return `${scope}/${name}`;
}

const [name] = normalized.split('/');
return name ?? null;
}

/**
* Escape a regular expression string.
* @param value - The string to escape.
Expand Down Expand Up @@ -242,6 +280,9 @@ export function createNodeModuleErrorPlugin(): esbuild.Plugin {
build.onResolve({ filter: /.*/ }, async (args) => {
if (!args.importer) return null;

const parentValue = normalize(args.importer);
const specifierPackageName = getPackageNameFromSpecifier(args.path);

try {
const resolvedChild = await enhancedResolve(
args.resolveDir,
Expand All @@ -250,14 +291,31 @@ export function createNodeModuleErrorPlugin(): esbuild.Plugin {

if (resolvedChild) {
const childKey = normalize(resolvedChild);
const parentValue = normalize(args.importer);
importParents.set(childKey, parentValue);

// Record the resolved package edge so transitive builtin usage can
// still trace back even when esbuild and enhanced-resolve pick
// different entry files (e.g. `module` vs `main`).
const resolvedPackageName = getPackageName(childKey);
if (resolvedPackageName) {
importParents.set(resolvedPackageName, parentValue);
}
}

// Also preserve the bare package-specifier edge (e.g. "postgres"),
// which is the fallback key used when tracing from files in
// node_modules back to user code.
if (specifierPackageName) {
importParents.set(specifierPackageName, parentValue);
}
} catch {
// For built-in modules that can't be resolved, still track using the import path
const childKey = args.path;
const parentValue = normalize(args.importer);
importParents.set(childKey, parentValue);

if (specifierPackageName) {
importParents.set(specifierPackageName, parentValue);
}
}
return null;
});
Expand Down
52 changes: 45 additions & 7 deletions packages/core/e2e/build-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ describe('build error messages', () => {
// Restore files in reverse order to handle dependencies
for (const item of restoreFiles.reverse()) {
if (item.content === null) {
await fs.unlink(item.path).catch(() => {});
await fs
.rm(item.path, { recursive: true, force: true })
.catch(() => {});
} else {
await fs.writeFile(item.path, item.content);
}
Expand Down Expand Up @@ -141,16 +143,52 @@ export async function nodeModuleViolationWorkflow() {
{ timeout: 120_000 },
async () => {
const appPath = getWorkbenchAppPath('nextjs-turbopack');
const packageName = 'workflow-test-dual-entry-package';
const packageDir = path.join(appPath, 'node_modules', packageName);
const esmDir = path.join(packageDir, 'esm');
const cjsDir = path.join(packageDir, 'cjs');

await fs.mkdir(esmDir, { recursive: true });
await fs.mkdir(cjsDir, { recursive: true });
restoreFiles.push({ path: packageDir, content: null });

// "module" entry uses Node.js built-ins while "main" does not.
// This reproduces entry-field divergence between esbuild and enhanced-resolve
// and verifies we still attribute the violation to the top-level package.
await fs.writeFile(
path.join(esmDir, 'index.js'),
`
import os from 'os';
export function getPlatform() {
return os.platform();
}
`
);
await fs.writeFile(
path.join(cjsDir, 'index.cjs'),
`
module.exports = {
getPlatform() {
return 'cjs';
},
};
`
);
await fs.writeFile(
path.join(packageDir, 'package.json'),
JSON.stringify({
name: packageName,
main: 'cjs/index.cjs',
module: 'esm/index.js',
})
);

// @vercel/blob internally uses Node.js modules (via undici)
// The error should show "@vercel/blob" not the internal Node.js module
const badWorkflowContent = `
import { put } from '@vercel/blob';
import { getPlatform } from '${packageName}';

export async function blobViolationWorkflow() {
'use workflow';
const result = await put('test.txt', 'hello', { access: 'public' });
return result.url;
return getPlatform();
}
`;

Expand All @@ -165,7 +203,7 @@ export async function blobViolationWorkflow() {

// Verify error shows the top-level package name, not internal Node.js module
expect(output).toContain(
'You are attempting to use "@vercel/blob" which depends on Node.js modules.'
`You are attempting to use "${packageName}" which depends on Node.js modules.`
);

// Verify the location points to our test file
Expand Down
Loading