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, ], }); })();