Skip to content

Commit 7830169

Browse files
authored
fix(builders): avoid false-positive node-module errors for step-only usage in shared modules (vercel#1821)
The workflow-node-module-error plugin records violations in onResolve, which runs before esbuild's tree-shaker. A shared module with both a workflow-safe export and a step-only export that imports node:* would be flagged even when the workflow never reached the step-only export. Mark node:/bun: imports as sideEffects: false so esbuild can tree-shake them when unused in surviving code, enable metafile, and filter recorded violations in onEnd against outputs[].imports so only builtins that actually survive in the emitted bundle are reported. Fixes vercel#1817
1 parent 9f3516e commit 7830169

3 files changed

Lines changed: 196 additions & 26 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/builders": patch
3+
---
4+
5+
Fix false-positive `workflow-node-module-error` for step-only Node.js usage in shared modules

packages/builders/src/node-module-esbuild-plugin.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,116 @@ describe('workflow-node-module-error plugin', () => {
378378
}
379379
});
380380

381+
it('should not error when a shared module exposes a workflow-safe export alongside an unused Node.js-using export', async () => {
382+
// Repro for https://github.com/vercel/workflow/issues/1817
383+
//
384+
// When a shared module has both a workflow-safe export and a Node.js-using
385+
// export, and the workflow bundle only uses the workflow-safe one, esbuild
386+
// should be able to tree-shake the Node.js-using export (and its imports)
387+
// and the plugin should not emit a false-positive violation.
388+
const tempDir = mkdtempSync(join(realTmpdir, 'node-module-plugin-test-'));
389+
const sharedFile = join(tempDir, 'shared.ts');
390+
const entryFile = join(tempDir, 'workflow.ts');
391+
392+
writeFileSync(
393+
sharedFile,
394+
`
395+
import { readFile } from "node:fs/promises";
396+
import path from "node:path";
397+
398+
export const isSupportedValue = (value) => value === "ok";
399+
400+
export async function readFixtureFile() {
401+
return readFile(path.join(process.cwd(), "fixture.txt"), "utf8");
402+
}
403+
`
404+
);
405+
writeFileSync(
406+
entryFile,
407+
`
408+
import { isSupportedValue } from "./shared.js";
409+
export function workflow() {
410+
return isSupportedValue("ok");
411+
}
412+
`
413+
);
414+
415+
try {
416+
const result = await esbuild.build({
417+
entryPoints: [entryFile],
418+
bundle: true,
419+
write: false,
420+
platform: 'neutral',
421+
logLevel: 'silent',
422+
plugins: [createNodeModuleErrorPlugin()],
423+
});
424+
expect(result.errors).toHaveLength(0);
425+
} finally {
426+
rmSync(tempDir, { recursive: true, force: true });
427+
}
428+
});
429+
430+
it("should still error when a shared module's Node.js-using export is actually referenced", async () => {
431+
// Counterpart to the previous test: when the workflow really does pull in
432+
// the Node.js-using export (directly or transitively), the plugin must
433+
// still emit the violation.
434+
const tempDir = mkdtempSync(join(realTmpdir, 'node-module-plugin-test-'));
435+
const sharedFile = join(tempDir, 'shared.ts');
436+
const entryFile = join(tempDir, 'workflow.ts');
437+
const relativeShared = relative(process.cwd(), sharedFile);
438+
439+
writeFileSync(
440+
sharedFile,
441+
`
442+
import { readFile } from "node:fs/promises";
443+
import path from "node:path";
444+
445+
export const isSupportedValue = (value) => value === "ok";
446+
447+
export async function readFixtureFile() {
448+
return readFile(path.join(process.cwd(), "fixture.txt"), "utf8");
449+
}
450+
`
451+
);
452+
writeFileSync(
453+
entryFile,
454+
`
455+
import { isSupportedValue, readFixtureFile } from "./shared.js";
456+
export async function workflow() {
457+
if (!isSupportedValue("ok")) throw new Error("nope");
458+
return readFixtureFile();
459+
}
460+
`
461+
);
462+
463+
try {
464+
await esbuild.build({
465+
entryPoints: [entryFile],
466+
bundle: true,
467+
write: false,
468+
platform: 'neutral',
469+
logLevel: 'silent',
470+
plugins: [createNodeModuleErrorPlugin()],
471+
});
472+
throw new Error('Expected build to fail');
473+
} catch (error: any) {
474+
if (error && typeof error === 'object' && 'errors' in error) {
475+
const failure = error as esbuild.BuildFailure;
476+
const paths = failure.errors.map((e) => e.text);
477+
expect(paths.some((t) => t.includes('"node:fs/promises"'))).toBe(true);
478+
expect(paths.some((t) => t.includes('"node:path"'))).toBe(true);
479+
// Violations should be attributed to the shared module, not the entry.
480+
for (const err of failure.errors) {
481+
expect(err.location?.file).toBe(relativeShared);
482+
}
483+
} else {
484+
throw error;
485+
}
486+
} finally {
487+
rmSync(tempDir, { recursive: true, force: true });
488+
}
489+
});
490+
381491
it('should not point to JSDoc comments when finding usage', async () => {
382492
const testCode = `
383493
import { Writable } from "stream";

packages/builders/src/node-module-esbuild-plugin.ts

Lines changed: 81 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,15 @@ export function createNodeModuleErrorPlugin(): esbuild.Plugin {
305305
const packageViolations: PackageViolation[] = [];
306306
const seenViolations = new Set<string>();
307307

308+
// Enable the esbuild metafile so we can inspect which Node.js / Bun
309+
// built-in imports actually survive tree-shaking. We need this to
310+
// suppress false positives where a shared module has a workflow-safe
311+
// export alongside a step-only export that references Node.js builtins
312+
// — esbuild's tree-shaker can drop the step-only branch, but `onResolve`
313+
// fires before tree-shaking, so we defer the final error decision to
314+
// `onEnd` (see the metafile-based filter below).
315+
build.initialOptions.metafile = true;
316+
308317
// Track ALL import relationships to build the dependency graph.
309318
// This is necessary to trace transitive dependencies back to user code.
310319
// Performance impact is minimal as we only store path mappings.
@@ -388,9 +397,14 @@ export function createNodeModuleErrorPlugin(): esbuild.Plugin {
388397
const workflowFile = filteredChain[0] ?? importerPath;
389398

390399
if (!workflowFile || workflowFile.includes('node_modules')) {
400+
// Mark builtin imports as side-effect-free so esbuild can tree-shake
401+
// them when they're only referenced by dead (e.g. step-only) code
402+
// paths in shared modules. The onEnd filter below uses the metafile
403+
// to decide which imports actually survive.
391404
return {
392405
path: args.path,
393406
external: true,
407+
sideEffects: false,
394408
};
395409
}
396410

@@ -422,39 +436,80 @@ export function createNodeModuleErrorPlugin(): esbuild.Plugin {
422436
}
423437
}
424438

439+
// Mark builtin imports as side-effect-free so esbuild can tree-shake
440+
// them when they're only referenced by dead (e.g. step-only) code
441+
// paths in shared modules. The onEnd filter below uses the metafile
442+
// to decide which imports actually survive.
425443
return {
426444
path: args.path,
427445
external: true,
446+
sideEffects: false,
428447
};
429448
});
430449

431-
// Report all violations at the end of the build
432-
build.onEnd(() => {
433-
if (packageViolations.length > 0) {
434-
return {
435-
errors: packageViolations.map((violation) => {
436-
const isBuiltinModule = runtimeModulesRegex.test(
437-
violation.packageName
438-
);
439-
const moduleType = getModuleTypeLabel(violation.path);
440-
441-
// Different messages for built-in modules vs npm packages that use them
442-
const text = isBuiltinModule
443-
? `You are attempting to use "${violation.packageName}" which is a ${moduleType} module. ${moduleType} modules are not available in workflow functions.\n\nLearn more: https://workflow-sdk.dev/err/${ERROR_SLUGS.NODE_JS_MODULE_IN_WORKFLOW}`
444-
: `You are attempting to use "${violation.packageName}" which depends on ${moduleType} modules. Packages that depend on ${moduleType} modules are not available in workflow functions.\n\nLearn more: https://workflow-sdk.dev/err/${ERROR_SLUGS.NODE_JS_MODULE_IN_WORKFLOW}`;
445-
446-
return {
447-
text,
448-
location: violation.location
449-
? {
450-
...violation.location,
451-
suggestion: 'Move this function into a step function.',
452-
}
453-
: undefined,
454-
};
455-
}),
456-
};
450+
// Report violations at the end of the build, filtered to only those
451+
// whose Node.js / Bun builtin imports actually survived tree-shaking.
452+
//
453+
// `onResolve` fires during module loading, before esbuild's tree-shaker
454+
// runs. That means a shared module whose step-only export references a
455+
// builtin will record a violation even if the workflow never touches
456+
// that export. By marking builtin imports as `sideEffects: false` in
457+
// `onResolve` above, esbuild can drop such imports from the output.
458+
// Here we consult the metafile to see which builtin imports remain
459+
// in the emitted bundle and only report those.
460+
build.onEnd((result) => {
461+
if (packageViolations.length === 0) return;
462+
463+
const survivingBuiltins = new Set<string>();
464+
const outputs = result.metafile?.outputs;
465+
if (outputs) {
466+
for (const output of Object.values(outputs)) {
467+
for (const imp of output.imports) {
468+
// External imports in the metafile are recorded with the path
469+
// that was returned from `onResolve` (e.g. "node:fs/promises",
470+
// "fs", "bun:sqlite"). Only collect the ones that match
471+
// Node.js / Bun builtins.
472+
if (imp.external && runtimeModulesRegex.test(imp.path)) {
473+
survivingBuiltins.add(imp.path);
474+
}
475+
}
476+
}
457477
}
478+
479+
// If the metafile is unavailable for some reason, fall back to
480+
// reporting all recorded violations so we never silently suppress
481+
// real ones.
482+
const liveViolations = outputs
483+
? packageViolations.filter((violation) =>
484+
survivingBuiltins.has(violation.path)
485+
)
486+
: packageViolations;
487+
488+
if (liveViolations.length === 0) return;
489+
490+
return {
491+
errors: liveViolations.map((violation) => {
492+
const isBuiltinModule = runtimeModulesRegex.test(
493+
violation.packageName
494+
);
495+
const moduleType = getModuleTypeLabel(violation.path);
496+
497+
// Different messages for built-in modules vs npm packages that use them
498+
const text = isBuiltinModule
499+
? `You are attempting to use "${violation.packageName}" which is a ${moduleType} module. ${moduleType} modules are not available in workflow functions.\n\nLearn more: https://workflow-sdk.dev/err/${ERROR_SLUGS.NODE_JS_MODULE_IN_WORKFLOW}`
500+
: `You are attempting to use "${violation.packageName}" which depends on ${moduleType} modules. Packages that depend on ${moduleType} modules are not available in workflow functions.\n\nLearn more: https://workflow-sdk.dev/err/${ERROR_SLUGS.NODE_JS_MODULE_IN_WORKFLOW}`;
501+
502+
return {
503+
text,
504+
location: violation.location
505+
? {
506+
...violation.location,
507+
suggestion: 'Move this function into a step function.',
508+
}
509+
: undefined,
510+
};
511+
}),
512+
};
458513
});
459514
},
460515
};

0 commit comments

Comments
 (0)