diff --git a/.changeset/warn-external-workflow-packages.md b/.changeset/warn-external-workflow-packages.md
new file mode 100644
index 0000000000..3b82c92235
--- /dev/null
+++ b/.changeset/warn-external-workflow-packages.md
@@ -0,0 +1,6 @@
+---
+"@workflow/builders": patch
+"@workflow/next": patch
+---
+
+Auto-remove workflow-enabled packages from Next.js `serverExternalPackages` so they can be transformed, and retain a best-effort `externalPackages` warning fallback for non-Next builders.
diff --git a/docs/content/docs/api-reference/workflow-next/with-workflow.mdx b/docs/content/docs/api-reference/workflow-next/with-workflow.mdx
index 85090227d0..c13647aaf5 100644
--- a/docs/content/docs/api-reference/workflow-next/with-workflow.mdx
+++ b/docs/content/docs/api-reference/workflow-next/with-workflow.mdx
@@ -27,6 +27,15 @@ const workflowConfig = {}
export default withWorkflow(nextConfig, workflowConfig); // [!code highlight]
```
+
+If a package in `serverExternalPackages` contains workflow code (`"use step"`,
+`"use workflow"`, or serialization classes), `withWorkflow()` automatically
+removes it from `serverExternalPackages` for the current build and prints a
+warning. This ensures the package still gets transformed by the Workflow
+compiler. Remove that package from `serverExternalPackages` in your
+`next.config` to silence the warning.
+
+
### Monorepos and Workspace Imports
By default, Next.js detects the correct workspace root automatically. If your Next.js app lives in a subdirectory such as `apps/web` and workspace resolution is not working correctly, you can set `outputFileTracingRoot` as a workaround:
@@ -78,6 +87,7 @@ The `workflows.local` options only affect local development. When deployed to Ve
## Exporting a Function
+
If you are exporting a function in your `next.config` you will need to ensure you call the function returned from `withWorkflow`.
```typescript title="next.config.ts" lineNumbers
diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts
index 40a716ff5e..27c605a7c3 100644
--- a/packages/builders/src/base-builder.ts
+++ b/packages/builders/src/base-builder.ts
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, realpath, rename, writeFile } from 'node:fs/promises';
+import { createRequire } from 'node:module';
import { basename, dirname, join, relative, resolve } from 'node:path';
import { promisify } from 'node:util';
import { pluralize, usesVercelWorld } from '@workflow/utils';
@@ -18,10 +19,12 @@ import { getImportPath } from './module-specifier.js';
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js';
import { createSwcPlugin } from './swc-esbuild-plugin.js';
+import { detectWorkflowPatterns } from './transform-utils.js';
import type { WorkflowConfig } from './types.js';
import { extractWorkflowGraphs } from './workflows-extractor.js';
const enhancedResolve = promisify(enhancedResolveOriginal);
+const require = createRequire(import.meta.url);
const EMIT_SOURCEMAPS_FOR_DEBUGGING =
process.env.WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING === '1';
@@ -60,6 +63,12 @@ export interface DiscoveredEntries {
export abstract class BaseBuilder {
protected config: WorkflowConfig;
+ /**
+ * Tracks which external packages have already been warned about
+ * to avoid duplicate warnings across multiple discoverEntries() calls.
+ */
+ private warnedExternalPackages = new Set();
+
constructor(config: WorkflowConfig) {
this.config = config;
}
@@ -196,6 +205,114 @@ export abstract class BaseBuilder {
}
> = new WeakMap();
+ /**
+ * Pseudo-packages that should not be checked for workflow patterns.
+ */
+ private static readonly PSEUDO_PACKAGES = new Set([
+ 'server-only',
+ 'client-only',
+ ]);
+
+ /**
+ * Checks each package in externalPackages for workflow patterns and emits
+ * warnings if any contain "use step", "use workflow" directives, or
+ * serialization classes. These patterns will not be transformed by the
+ * workflow compiler when the package is externalized.
+ */
+ private async warnAboutExternalWorkflowPackages(): Promise {
+ const externalPackages = this.config.externalPackages;
+ if (!externalPackages?.length) return;
+
+ for (const pkg of externalPackages) {
+ if (BaseBuilder.PSEUDO_PACKAGES.has(pkg)) continue;
+ if (this.warnedExternalPackages.has(pkg)) continue;
+
+ if (
+ pkg.startsWith('.') ||
+ pkg.startsWith('/') ||
+ pkg.startsWith('$') ||
+ pkg.includes('*') ||
+ pkg.includes(':')
+ ) {
+ continue;
+ }
+
+ try {
+ // Check package.json dependencies for @workflow/serde (fast path)
+ let hasWorkflowSerdeDep = false;
+ try {
+ const pkgJsonPath = require.resolve(`${pkg}/package.json`, {
+ paths: [this.config.workingDir],
+ });
+ const pkgJsonSource = await readFile(pkgJsonPath, 'utf-8');
+ const pkgJson = JSON.parse(pkgJsonSource) as {
+ dependencies?: unknown;
+ peerDependencies?: unknown;
+ };
+ const dependencies =
+ typeof pkgJson.dependencies === 'object' &&
+ pkgJson.dependencies !== null &&
+ !Array.isArray(pkgJson.dependencies)
+ ? (pkgJson.dependencies as Record)
+ : {};
+ const peerDependencies =
+ typeof pkgJson.peerDependencies === 'object' &&
+ pkgJson.peerDependencies !== null &&
+ !Array.isArray(pkgJson.peerDependencies)
+ ? (pkgJson.peerDependencies as Record)
+ : {};
+ hasWorkflowSerdeDep =
+ Object.hasOwn(dependencies, '@workflow/serde') ||
+ Object.hasOwn(peerDependencies, '@workflow/serde');
+ } catch {
+ // package.json not resolvable - continue to source check
+ }
+
+ // Check source patterns (thorough path).
+ // Note: require.resolve only inspects the package's main entry point.
+ // If workflow constructs live in sub-paths (e.g. `my-pkg/workflows`),
+ // they won't be detected here. The @workflow/serde dep check above
+ // partially covers serde cases. This is acceptable as a best-effort
+ // heuristic — the primary fix is auto-removal in withWorkflow().
+ let hasUseStep = false;
+ let hasUseWorkflow = false;
+ let hasSerde = hasWorkflowSerdeDep;
+ try {
+ const entryPath = require.resolve(pkg, {
+ paths: [this.config.workingDir],
+ });
+ const source = await readFile(entryPath, 'utf-8');
+ const patterns = detectWorkflowPatterns(source);
+ hasUseStep = patterns.hasUseStep;
+ hasUseWorkflow = patterns.hasUseWorkflow;
+ if (!hasSerde) {
+ hasSerde = patterns.hasSerde;
+ }
+ } catch {
+ // Entry file not resolvable or not readable - use what we have
+ }
+
+ if (!hasUseStep && !hasUseWorkflow && !hasSerde) continue;
+
+ // Build a specific description of what was found
+ const issues: string[] = [];
+ if (hasUseWorkflow) issues.push('"use workflow" functions');
+ if (hasUseStep) issues.push('"use step" functions');
+ if (hasSerde) issues.push('serialization classes');
+
+ this.warnedExternalPackages.add(pkg);
+
+ console.warn(
+ `\n${chalk.yellow('⚠')} Warning: ${chalk.bold(`"${pkg}"`)} is listed in ${chalk.bold('externalPackages')} (${chalk.bold('serverExternalPackages')} in Next.js) but contains workflow code (${issues.join(', ')}).` +
+ `\n This code will ${chalk.bold('not')} be transformed by the workflow compiler, which can cause runtime failures.` +
+ `\n Remove ${chalk.bold(`"${pkg}"`)} from ${chalk.bold('externalPackages')} (${chalk.bold('serverExternalPackages')} in Next.js) to fix this.\n`
+ );
+ } catch {
+ // Best-effort: if anything goes wrong, skip this package silently
+ }
+ }
+ }
+
protected async discoverEntries(
inputs: string[],
outdir: string,
@@ -247,6 +364,9 @@ export abstract class BaseBuilder {
`${Date.now() - discoverStart}ms`
);
+ // Warn about external packages that contain workflow code
+ await this.warnAboutExternalWorkflowPackages();
+
this.discoveredEntries.set(inputs, state);
return state;
}
diff --git a/packages/builders/src/external-package-warning.test.ts b/packages/builders/src/external-package-warning.test.ts
new file mode 100644
index 0000000000..3636a6c9c1
--- /dev/null
+++ b/packages/builders/src/external-package-warning.test.ts
@@ -0,0 +1,371 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { BaseBuilder, type DiscoveredEntries } from './base-builder.js';
+import type { StandaloneConfig } from './types.js';
+
+/**
+ * Minimal subclass to expose the protected `discoverEntries()` for testing.
+ */
+class TestBuilder extends BaseBuilder {
+ async build(): Promise {
+ // no-op
+ }
+
+ // Expose for tests
+ public discoverEntriesPublic(
+ inputs: string[],
+ outdir: string
+ ): Promise {
+ return this.discoverEntries(inputs, outdir);
+ }
+}
+
+// Resolve symlinks in tmpdir to avoid macOS /var -> /private/var issues
+const realTmpdir = realpathSync(tmpdir());
+
+function writeFile(path: string, contents: string): void {
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, contents, 'utf-8');
+}
+
+function createBuilder(
+ workingDir: string,
+ externalPackages: string[]
+): TestBuilder {
+ const config: StandaloneConfig = {
+ buildTarget: 'standalone',
+ workingDir,
+ dirs: ['.'],
+ externalPackages,
+ stepsBundlePath: join(workingDir, 'steps.js'),
+ workflowsBundlePath: join(workingDir, 'workflows.js'),
+ webhookBundlePath: join(workingDir, 'webhook.js'),
+ };
+ return new TestBuilder(config);
+}
+
+describe('warnAboutExternalWorkflowPackages', () => {
+ let testRoot: string;
+ let warnSpy: ReturnType;
+
+ beforeEach(() => {
+ testRoot = mkdtempSync(join(realTmpdir, 'workflow-external-pkg-warning-'));
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ rmSync(testRoot, { recursive: true, force: true });
+ });
+
+ it('warns when an external package depends on @workflow/serde', async () => {
+ // Create a mock project with a node_modules package
+ const projectDir = join(testRoot, 'project');
+
+ // Create a minimal entry file for the project
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+
+ // Create the external package with @workflow/serde dependency
+ writeFile(
+ join(projectDir, 'node_modules', 'my-serde-pkg', 'package.json'),
+ JSON.stringify({
+ name: 'my-serde-pkg',
+ version: '1.0.0',
+ main: 'index.js',
+ dependencies: {
+ '@workflow/serde': '^1.0.0',
+ },
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'my-serde-pkg', 'index.js'),
+ 'export class Foo {}'
+ );
+
+ const builder = createBuilder(projectDir, ['my-serde-pkg']);
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'index.ts')],
+ join(projectDir, 'out')
+ );
+
+ expect(warnSpy).toHaveBeenCalled();
+ const warnMessage = warnSpy.mock.calls[0]?.[0] as string;
+ expect(warnMessage).toContain('my-serde-pkg');
+ expect(warnMessage).toContain('serverExternalPackages');
+ expect(warnMessage).toContain('serialization classes');
+ });
+
+ it('warns when an external package source contains serde symbols', async () => {
+ const projectDir = join(testRoot, 'project');
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+
+ // Package without @workflow/serde dep but with Symbol.for patterns in source
+ writeFile(
+ join(projectDir, 'node_modules', 'my-symbol-pkg', 'package.json'),
+ JSON.stringify({
+ name: 'my-symbol-pkg',
+ version: '1.0.0',
+ main: 'index.js',
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'my-symbol-pkg', 'index.js'),
+ `export class Bar {
+ static [Symbol.for('workflow-serialize')](instance) { return {}; }
+ static [Symbol.for('workflow-deserialize')](data) { return new Bar(); }
+}`
+ );
+
+ const builder = createBuilder(projectDir, ['my-symbol-pkg']);
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'index.ts')],
+ join(projectDir, 'out')
+ );
+
+ expect(warnSpy).toHaveBeenCalled();
+ const warnMessage = warnSpy.mock.calls[0]?.[0] as string;
+ expect(warnMessage).toContain('my-symbol-pkg');
+ expect(warnMessage).toContain('serialization classes');
+ });
+
+ it('warns when an external package contains "use step" directives', async () => {
+ const projectDir = join(testRoot, 'project');
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+
+ writeFile(
+ join(projectDir, 'node_modules', 'my-step-pkg', 'package.json'),
+ JSON.stringify({
+ name: 'my-step-pkg',
+ version: '1.0.0',
+ main: 'index.js',
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'my-step-pkg', 'index.js'),
+ `export async function doWork() {
+ "use step";
+ return 42;
+}`
+ );
+
+ const builder = createBuilder(projectDir, ['my-step-pkg']);
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'index.ts')],
+ join(projectDir, 'out')
+ );
+
+ expect(warnSpy).toHaveBeenCalled();
+ const warnMessage = warnSpy.mock.calls[0]?.[0] as string;
+ expect(warnMessage).toContain('my-step-pkg');
+ expect(warnMessage).toContain('"use step" functions');
+ });
+
+ it('warns when an external package contains "use workflow" directives', async () => {
+ const projectDir = join(testRoot, 'project');
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+
+ writeFile(
+ join(projectDir, 'node_modules', 'my-workflow-pkg', 'package.json'),
+ JSON.stringify({
+ name: 'my-workflow-pkg',
+ version: '1.0.0',
+ main: 'index.js',
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'my-workflow-pkg', 'index.js'),
+ `export async function runJob() {
+ "use workflow";
+ return "done";
+}`
+ );
+
+ const builder = createBuilder(projectDir, ['my-workflow-pkg']);
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'index.ts')],
+ join(projectDir, 'out')
+ );
+
+ expect(warnSpy).toHaveBeenCalled();
+ const warnMessage = warnSpy.mock.calls[0]?.[0] as string;
+ expect(warnMessage).toContain('my-workflow-pkg');
+ expect(warnMessage).toContain('"use workflow" functions');
+ });
+
+ it('does not warn for packages without workflow patterns', async () => {
+ const projectDir = join(testRoot, 'project');
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+
+ writeFile(
+ join(projectDir, 'node_modules', 'plain-pkg', 'package.json'),
+ JSON.stringify({
+ name: 'plain-pkg',
+ version: '1.0.0',
+ main: 'index.js',
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'plain-pkg', 'index.js'),
+ 'export const hello = "world";'
+ );
+
+ const builder = createBuilder(projectDir, ['plain-pkg']);
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'index.ts')],
+ join(projectDir, 'out')
+ );
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('does not warn for pseudo-packages (server-only, client-only)', async () => {
+ const projectDir = join(testRoot, 'project');
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+
+ const builder = createBuilder(projectDir, ['server-only', 'client-only']);
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'index.ts')],
+ join(projectDir, 'out')
+ );
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('does not warn when externalPackages is empty', async () => {
+ const projectDir = join(testRoot, 'project');
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+
+ const builder = createBuilder(projectDir, []);
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'index.ts')],
+ join(projectDir, 'out')
+ );
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('warns only once per package across multiple discoverEntries calls', async () => {
+ const projectDir = join(testRoot, 'project');
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+ writeFile(join(projectDir, 'other.ts'), 'export const y = 2;');
+
+ writeFile(
+ join(projectDir, 'node_modules', 'my-serde-pkg', 'package.json'),
+ JSON.stringify({
+ name: 'my-serde-pkg',
+ version: '1.0.0',
+ main: 'index.js',
+ dependencies: { '@workflow/serde': '^1.0.0' },
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'my-serde-pkg', 'index.js'),
+ 'export class Foo {}'
+ );
+
+ const builder = createBuilder(projectDir, ['my-serde-pkg']);
+
+ // Call discoverEntries twice with different inputs
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'index.ts')],
+ join(projectDir, 'out')
+ );
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'other.ts')],
+ join(projectDir, 'out2')
+ );
+
+ // Should only warn once
+ const warnCalls = warnSpy.mock.calls.filter((call: unknown[]) =>
+ (call[0] as string).includes('my-serde-pkg')
+ );
+ expect(warnCalls).toHaveLength(1);
+ });
+
+ it('lists multiple detected issues in the warning message', async () => {
+ const projectDir = join(testRoot, 'project');
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+
+ writeFile(
+ join(projectDir, 'node_modules', 'multi-pkg', 'package.json'),
+ JSON.stringify({
+ name: 'multi-pkg',
+ version: '1.0.0',
+ main: 'index.js',
+ dependencies: { '@workflow/serde': '^1.0.0' },
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'multi-pkg', 'index.js'),
+ `export async function doWork() {
+ "use step";
+ return 42;
+}
+export class Foo {
+ static [Symbol.for('workflow-serialize')](instance) { return {}; }
+ static [Symbol.for('workflow-deserialize')](data) { return new Foo(); }
+}`
+ );
+
+ const builder = createBuilder(projectDir, ['multi-pkg']);
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'index.ts')],
+ join(projectDir, 'out')
+ );
+
+ expect(warnSpy).toHaveBeenCalled();
+ const warnMessage = warnSpy.mock.calls[0]?.[0] as string;
+ expect(warnMessage).toContain('"use step" functions');
+ expect(warnMessage).toContain('serialization classes');
+ });
+
+ it('warns via entry-file detection when package.json has no serde dep', async () => {
+ const projectDir = join(testRoot, 'project');
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+
+ // Package without @workflow/serde in deps, but entry has "use step"
+ writeFile(
+ join(projectDir, 'node_modules', 'no-serde-dep-pkg', 'package.json'),
+ JSON.stringify({
+ name: 'no-serde-dep-pkg',
+ version: '1.0.0',
+ main: 'index.js',
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'no-serde-dep-pkg', 'index.js'),
+ `export async function doWork() {
+ "use step";
+ return 42;
+}`
+ );
+
+ const builder = createBuilder(projectDir, ['no-serde-dep-pkg']);
+ await builder.discoverEntriesPublic(
+ [join(projectDir, 'index.ts')],
+ join(projectDir, 'out')
+ );
+
+ expect(warnSpy).toHaveBeenCalled();
+ const warnMessage = warnSpy.mock.calls[0]?.[0] as string;
+ expect(warnMessage).toContain('no-serde-dep-pkg');
+ expect(warnMessage).toContain('"use step" functions');
+ });
+});
diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts
index 08d22b78df..249ebe307c 100644
--- a/packages/next/src/index.test.ts
+++ b/packages/next/src/index.test.ts
@@ -1,5 +1,13 @@
-import { existsSync, rmSync, writeFileSync } from 'node:fs';
-import { join } from 'node:path';
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ realpathSync,
+ rmSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
@@ -49,8 +57,15 @@ const loaderStubPath = join(
'loader.js'
);
const hadLoaderStub = existsSync(loaderStubPath);
+const realTmpDir = realpathSync(tmpdir());
-describe('withWorkflow outputFileTracingRoot', () => {
+function writeFile(path: string, contents: string): void {
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, contents, 'utf-8');
+}
+
+describe('withWorkflow builder config', () => {
+ const originalCwd = process.cwd();
const originalEnv = {
PORT: process.env.PORT,
VERCEL_DEPLOYMENT_ID: process.env.VERCEL_DEPLOYMENT_ID,
@@ -83,6 +98,10 @@ describe('withWorkflow outputFileTracingRoot', () => {
rmSync(loaderStubPath);
}
+ if (process.cwd() !== originalCwd) {
+ process.chdir(originalCwd);
+ }
+
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
@@ -109,4 +128,120 @@ describe('withWorkflow outputFileTracingRoot', () => {
workingDir: process.cwd(),
});
});
+
+ it('removes workflow packages from serverExternalPackages for this build', async () => {
+ const projectDir = mkdtempSync(
+ join(realTmpDir, 'workflow-next-server-external-')
+ );
+ process.chdir(projectDir);
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+ writeFile(
+ join(
+ projectDir,
+ 'node_modules',
+ 'workflow-auto-remove-a',
+ 'package.json'
+ ),
+ JSON.stringify({
+ name: 'workflow-auto-remove-a',
+ version: '1.0.0',
+ main: 'index.js',
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'workflow-auto-remove-a', 'index.js'),
+ `export async function runJob() {
+ "use workflow";
+ return "ok";
+}`
+ );
+
+ writeFile(
+ join(projectDir, 'node_modules', 'plain-external-a', 'package.json'),
+ JSON.stringify({
+ name: 'plain-external-a',
+ version: '1.0.0',
+ main: 'index.js',
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'plain-external-a', 'index.js'),
+ 'export const plain = true;'
+ );
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ try {
+ const config = withWorkflow({
+ serverExternalPackages: ['workflow-auto-remove-a', 'plain-external-a'],
+ });
+
+ const resolvedConfig = await config('phase-production-build', {
+ defaultConfig: {},
+ });
+
+ expect(resolvedConfig.serverExternalPackages).toEqual([
+ 'plain-external-a',
+ ]);
+ expect(builderConfigs).toHaveLength(1);
+ expect(builderConfigs[0]).toMatchObject({
+ externalPackages: ['server-only', 'client-only', 'plain-external-a'],
+ });
+
+ expect(warnSpy).toHaveBeenCalledOnce();
+ const warning = warnSpy.mock.calls[0]?.[0] as string;
+ expect(warning).toContain('workflow-auto-remove-a');
+ expect(warning).toContain('serverExternalPackages');
+ expect(warning).toContain('removed');
+ } finally {
+ warnSpy.mockRestore();
+ process.chdir(originalCwd);
+ rmSync(projectDir, { recursive: true, force: true });
+ }
+ });
+
+ it('keeps plain serverExternalPackages unchanged', async () => {
+ const projectDir = mkdtempSync(
+ join(realTmpDir, 'workflow-next-server-external-')
+ );
+ process.chdir(projectDir);
+
+ writeFile(join(projectDir, 'index.ts'), 'export const x = 1;');
+ writeFile(
+ join(projectDir, 'node_modules', 'plain-external-b', 'package.json'),
+ JSON.stringify({
+ name: 'plain-external-b',
+ version: '1.0.0',
+ main: 'index.js',
+ })
+ );
+ writeFile(
+ join(projectDir, 'node_modules', 'plain-external-b', 'index.js'),
+ 'export const plain = true;'
+ );
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ try {
+ const config = withWorkflow({
+ serverExternalPackages: ['plain-external-b'],
+ });
+
+ const resolvedConfig = await config('phase-production-build', {
+ defaultConfig: {},
+ });
+
+ expect(resolvedConfig.serverExternalPackages).toEqual([
+ 'plain-external-b',
+ ]);
+ expect(builderConfigs).toHaveLength(1);
+ expect(builderConfigs[0]).toMatchObject({
+ externalPackages: ['server-only', 'client-only', 'plain-external-b'],
+ });
+ expect(warnSpy).not.toHaveBeenCalled();
+ } finally {
+ warnSpy.mockRestore();
+ process.chdir(originalCwd);
+ rmSync(projectDir, { recursive: true, force: true });
+ }
+ });
});
diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts
index fce1beb34f..e95e30e223 100644
--- a/packages/next/src/index.ts
+++ b/packages/next/src/index.ts
@@ -1,3 +1,4 @@
+import { readFile } from 'node:fs/promises';
import type { NextConfig } from 'next';
import semver from 'semver';
import {
@@ -6,6 +7,175 @@ import {
WORKFLOW_DEFERRED_ENTRIES,
} from './builder.js';
+const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m;
+const useStepPattern = /^\s*(['"])use step\1;?\s*$/m;
+const workflowSerdeImportPattern = /from\s+(['"])@workflow\/serde\1/;
+const workflowSerdeSymbolPattern =
+ /Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\1\s*\)/;
+const workflowSerdeComputedPropertyPattern =
+ /\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/;
+
+const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']);
+const warnedAutoRemovedServerExternalPackages = new Set();
+
+interface WorkflowPatternMatch {
+ hasUseWorkflow: boolean;
+ hasUseStep: boolean;
+ hasSerde: boolean;
+}
+
+interface DetectedServerExternalPackage {
+ packageName: string;
+ hasUseWorkflow: boolean;
+ hasUseStep: boolean;
+ hasSerde: boolean;
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isResolvablePackageSpecifier(specifier: string): boolean {
+ if (specifier.startsWith('.') || specifier.startsWith('/')) {
+ return false;
+ }
+ if (specifier.startsWith('$')) {
+ return false;
+ }
+ if (specifier.includes('*') || specifier.includes(':')) {
+ return false;
+ }
+
+ return true;
+}
+
+function detectWorkflowPatterns(source: string): WorkflowPatternMatch {
+ const hasUseWorkflow = useWorkflowPattern.test(source);
+ const hasUseStep = useStepPattern.test(source);
+ const hasSerdeImport = workflowSerdeImportPattern.test(source);
+ const hasSerdeSymbol = workflowSerdeSymbolPattern.test(source);
+ const hasSerdeComputedProperty =
+ workflowSerdeComputedPropertyPattern.test(source);
+
+ return {
+ hasUseWorkflow,
+ hasUseStep,
+ hasSerde: hasSerdeImport || hasSerdeSymbol || hasSerdeComputedProperty,
+ };
+}
+
+function getIssueLabels(detected: DetectedServerExternalPackage): string[] {
+ const issues: string[] = [];
+ if (detected.hasUseWorkflow) {
+ issues.push('"use workflow" functions');
+ }
+ if (detected.hasUseStep) {
+ issues.push('"use step" functions');
+ }
+ if (detected.hasSerde) {
+ issues.push('serialization classes');
+ }
+ return issues;
+}
+
+function hasWorkflowSerdeDependency(packageJson: unknown): boolean {
+ if (!isPlainObject(packageJson)) {
+ return false;
+ }
+
+ const dependencies = isPlainObject(packageJson.dependencies)
+ ? packageJson.dependencies
+ : {};
+ const peerDependencies = isPlainObject(packageJson.peerDependencies)
+ ? packageJson.peerDependencies
+ : {};
+
+ return (
+ Object.hasOwn(dependencies, '@workflow/serde') ||
+ Object.hasOwn(peerDependencies, '@workflow/serde')
+ );
+}
+
+async function detectServerExternalPackage(
+ packageName: string,
+ workingDir: string
+): Promise {
+ if (!isResolvablePackageSpecifier(packageName)) {
+ return null;
+ }
+
+ let hasUseWorkflow = false;
+ let hasUseStep = false;
+ let hasSerde = false;
+
+ try {
+ const packageJsonPath = require.resolve(`${packageName}/package.json`, {
+ paths: [workingDir],
+ });
+ const packageJsonSource = await readFile(packageJsonPath, 'utf-8');
+ const packageJson = JSON.parse(packageJsonSource) as unknown;
+ hasSerde = hasWorkflowSerdeDependency(packageJson);
+ } catch {
+ // Best-effort only. Continue to source scanning.
+ }
+
+ try {
+ const entryPath = require.resolve(packageName, {
+ paths: [workingDir],
+ });
+ const source = await readFile(entryPath, 'utf-8');
+ const patterns = detectWorkflowPatterns(source);
+ hasUseWorkflow = patterns.hasUseWorkflow;
+ hasUseStep = patterns.hasUseStep;
+ hasSerde ||= patterns.hasSerde;
+ } catch {
+ // Best-effort only. Use whichever signal we already have.
+ }
+
+ if (!hasUseWorkflow && !hasUseStep && !hasSerde) {
+ return null;
+ }
+
+ return {
+ packageName,
+ hasUseWorkflow,
+ hasUseStep,
+ hasSerde,
+ };
+}
+
+function warnAboutAutoRemovedServerExternalPackages(
+ detectedPackages: DetectedServerExternalPackage[]
+): void {
+ const newlyDetectedPackages = detectedPackages.filter(({ packageName }) => {
+ return !warnedAutoRemovedServerExternalPackages.has(packageName);
+ });
+
+ if (newlyDetectedPackages.length === 0) {
+ return;
+ }
+
+ for (const { packageName } of newlyDetectedPackages) {
+ warnedAutoRemovedServerExternalPackages.add(packageName);
+ }
+
+ const packageDescriptions = newlyDetectedPackages
+ .map(
+ (detected) =>
+ `"${detected.packageName}" (${getIssueLabels(detected).join(', ')})`
+ )
+ .join(', ');
+ const packageNames = newlyDetectedPackages
+ .map((detected) => `"${detected.packageName}"`)
+ .join(', ');
+
+ console.warn(
+ `\n⚠ Workflow removed ${packageDescriptions} from serverExternalPackages for this build.` +
+ `\n These packages contain workflow code and must be transformed by the workflow compiler.` +
+ `\n Remove ${packageNames} from serverExternalPackages in next.config to silence this warning.\n`
+ );
+}
+
function resolveNextVersion(workingDir: string): string {
const fallbackVersion = require('next/package.json').version as string;
@@ -78,6 +248,46 @@ export function withWorkflow(
// shallow clone to avoid read-only on top-level
nextConfig = Object.assign({}, nextConfig);
+ const configuredServerExternalPackages = Array.isArray(
+ nextConfig.serverExternalPackages
+ )
+ ? nextConfig.serverExternalPackages
+ : [];
+ let effectiveServerExternalPackages = configuredServerExternalPackages;
+
+ if (configuredServerExternalPackages.length > 0) {
+ const detectedWorkflowPackages: DetectedServerExternalPackage[] = [];
+ for (const packageName of configuredServerExternalPackages) {
+ if (PSEUDO_EXTERNAL_PACKAGES.has(packageName)) {
+ continue;
+ }
+
+ try {
+ const detected = await detectServerExternalPackage(
+ packageName,
+ process.cwd()
+ );
+ if (detected) {
+ detectedWorkflowPackages.push(detected);
+ }
+ } catch {
+ // Best-effort only. Never block config generation.
+ }
+ }
+
+ if (detectedWorkflowPackages.length > 0) {
+ const removedPackages = new Set(
+ detectedWorkflowPackages.map((detected) => detected.packageName)
+ );
+ effectiveServerExternalPackages =
+ configuredServerExternalPackages.filter(
+ (packageName) => !removedPackages.has(packageName)
+ );
+ nextConfig.serverExternalPackages = effectiveServerExternalPackages;
+ warnAboutAutoRemovedServerExternalPackages(detectedWorkflowPackages);
+ }
+ }
+
// configure the loader if turbopack is being used
if (!nextConfig.turbopack) {
nextConfig.turbopack = {};
@@ -123,7 +333,7 @@ export function withWorkflow(
// See: https://nextjs.org/docs/app/getting-started/server-and-client-components
'server-only',
'client-only',
- ...(nextConfig.serverExternalPackages || []),
+ ...effectiveServerExternalPackages,
],
});
})();