From 9bd8bb2f63f6bfbdd65d5480b17016f6a03694b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 09:14:58 +0000 Subject: [PATCH] Auto-remove workflow packages from serverExternalPackages (#1481) * Warn when serverExternalPackages hides workflow-enabled packages Add a build-time warning when packages in serverExternalPackages contain workflow code ('use step', 'use workflow', or serialization classes). These packages are completely invisible to the workflow compiler when externalized, causing silent runtime failures. The warning detects workflow patterns via two methods: - Fast path: check package.json dependencies for @workflow/serde - Thorough path: read the package entry file and run pattern detection Also adds documentation in the serialization guide about the externalization footgun for 3rd-party packages. * Auto-remove workflow packages from serverExternalPackages When workflow-enabled dependencies are externalized in Next.js, compiler transforms are skipped and runtime failures follow. Detect those packages in withWorkflow, remove them from serverExternalPackages for the current build, and keep a generalized externalPackages warning fallback for non-Next builders. * Address review feedback: add entry-point limitation comment and missing test case Signed-off-by: Nathan Rajlich --- .changeset/warn-external-workflow-packages.md | 6 + .../workflow-next/with-workflow.mdx | 10 + packages/builders/src/base-builder.ts | 120 ++++++ .../src/external-package-warning.test.ts | 371 ++++++++++++++++++ packages/next/src/index.test.ts | 141 ++++++- packages/next/src/index.ts | 212 +++++++++- 6 files changed, 856 insertions(+), 4 deletions(-) create mode 100644 .changeset/warn-external-workflow-packages.md create mode 100644 packages/builders/src/external-package-warning.test.ts 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, ], }); })();