Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/warn-external-workflow-packages.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions docs/content/docs/api-reference/workflow-next/with-workflow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ const workflowConfig = {}
export default withWorkflow(nextConfig, workflowConfig); // [!code highlight]
```

<Callout type="warn">
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.
</Callout>

### 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:
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<string>();

constructor(config: WorkflowConfig) {
this.config = config;
}
Expand Down Expand Up @@ -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<void> {
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<string, unknown>)
: {};
const peerDependencies =
typeof pkgJson.peerDependencies === 'object' &&
pkgJson.peerDependencies !== null &&
!Array.isArray(pkgJson.peerDependencies)
? (pkgJson.peerDependencies as Record<string, unknown>)
: {};
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,
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading