Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-stable-next-lazy-registration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/next': patch
---

Preserve deferred step registration with Turbopack content filtering.
2 changes: 1 addition & 1 deletion .github/workflows/e2e-community-world.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,7 +156,7 @@ jobs:
SERVICE_TYPE: ${{ inputs.service-type }}
WORLD_ID: ${{ inputs.world-id }}
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../.."}'
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","supportsDeferredStepCopies":false}'

- name: Generate E2E summary
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -649,7 +649,7 @@ jobs:
NODE_OPTIONS: "--enable-source-maps"
APP_NAME: "nextjs-turbopack"
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}'
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000,"supportsDeferredStepCopies":false}'

- name: Print Next.js server logs
if: always()
Expand Down
14 changes: 11 additions & 3 deletions packages/core/e2e/dev.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ export interface DevTestConfig {
apiFilePath: string;
apiFileImportPath: string;
canary?: boolean;
/** Whether the app emits deferred step copy files during dev. */
supportsDeferredStepCopies?: boolean;
/** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */
testWorkflowFile?: string;
/** The workflows directory relative to appPath. Defaults to 'workflows' */
Expand DownExpand Up@@ -44,9 +46,11 @@ export function createDevTests(config?: DevTestConfig) {
);
const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts';
const workflowsDir = finalConfig.workflowsDir ?? 'workflows';
const supportsDeferredStepCopies = generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const supportsDeferredStepCopies =
finalConfig.supportsDeferredStepCopies ??
generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const restoreFiles: Array<{ path: string; content: string }> = [];

const fetchWithTimeout = (pathname: string) => {
Expand DownExpand Up@@ -217,6 +221,10 @@ export async function myNewStep() {
if (stepRouteContent.includes('myNewStep')) {
return;
}
if (!supportsDeferredStepCopies) {
expect(stepRouteContent).toContain('myNewStep');
return;
}

const copiedStepFileNames = await fs.readdir(copiedStepDir);
const copiedStepContents = await Promise.all(
Expand Down
128 changes: 128 additions & 0 deletions packages/next/src/builder-deferred.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getNextBuilderDeferred } from './builder-deferred.js';
import {
DEFERRED_STEP_COPY_DIR_NAME,
parseDeferredStepSourceMetadata,
} from './step-copy-utils.js';

const tempDirs: string[] = [];
// biome-ignore lint/security/noGlobalEval: The test preserves the builder's dynamic import shim while stubbing one import.
const originalEval = globalThis.eval;

afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))
);
tempDirs.length = 0;
vi.unstubAllGlobals();
});

describe('NextDeferredBuilder', () => {
it('generates route imports for local, transitive, package, and built-in steps', async () => {
const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
tempDirs.push(workingDir);
vi.stubGlobal('eval', (source: string) => {
if (source === 'import("@workflow/builders")') {
return import('@workflow/builders');
}
return originalEval(source);
});

const workflowFile = join(workingDir, 'workflows/example.ts');
const localStepFile = join(workingDir, 'workflows/local-step.ts');
const importedStepFile = join(workingDir, 'shared/imported-step.ts');
const packageStepFile = join(
workingDir,
'node_modules/example-step-package/index.js'
);
await mkdir(join(workingDir, 'workflows'), { recursive: true });
await mkdir(join(workingDir, 'shared'), { recursive: true });
await mkdir(join(workingDir, 'node_modules/example-step-package'), {
recursive: true,
});
await writeFile(
workflowFile,
`import '../shared/imported-step';\nexport async function run() {\n 'use workflow';\n}`
);
await writeFile(
localStepFile,
`export async function localStep() {\n 'use step';\n}`
);
await writeFile(
importedStepFile,
`export async function importedStep() {\n 'use step';\n}`
);
await writeFile(
packageStepFile,
`export async function packageStep() {\n 'use step';\n}`
);

const NextDeferredBuilder = await getNextBuilderDeferred();
const builder = new NextDeferredBuilder({
dirs: [],
workingDir,
buildTarget: 'next',
workflowsBundlePath: '',
stepsBundlePath: '',
webhookBundlePath: '',
}) as any;
builder.createDeferredStepsManifest = vi.fn(async () => ({}));

const workflowGeneratedDir = join(
workingDir,
'app/.well-known/workflow/v1'
);
await builder.buildStepsFunction({
workflowGeneratedDir,
discoveredEntries: {
discoveredSteps: [localStepFile, packageStepFile],
discoveredWorkflows: [workflowFile],
discoveredSerdeFiles: [],
},
});

const stepRouteDir = join(workflowGeneratedDir, 'step');
const copiedStepsDir = join(stepRouteDir, DEFERRED_STEP_COPY_DIR_NAME);
const copiedFileNames = await readdir(copiedStepsDir);
const copiedSources = await Promise.all(
copiedFileNames.map(async (fileName) => ({
fileName,
source: await readFile(join(copiedStepsDir, fileName), 'utf-8'),
}))
);
const copiedSourcePaths = copiedSources
.map(
({ source }) => parseDeferredStepSourceMetadata(source)?.absolutePath
)
.filter((path): path is string => Boolean(path));

expect(copiedSourcePaths).toEqual(
expect.arrayContaining([localStepFile, importedStepFile, packageStepFile])
);
expect(
copiedSources.some(({ source }) =>
source.includes('__builtin_response_array_buffer')
)
).toBe(true);

const routeCode = await readFile(join(stepRouteDir, 'route.js'), 'utf-8');
for (const { fileName } of copiedSources) {
expect(routeCode).toContain(
`import './${DEFERRED_STEP_COPY_DIR_NAME}/${fileName}';`
);
}
expect(routeCode).toContain(
"export { stepEntrypoint as HEAD, stepEntrypoint as POST } from 'workflow/runtime';"
);
});
});
42 changes: 42 additions & 0 deletions packages/next/src/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,4 +244,46 @@ describe('withWorkflow builder config', () => {
rmSync(projectDir, { recursive: true, force: true });
}
});

it('lets Turbopack transform deferred step copies but not generated routes', async () => {
shouldUseDeferredBuilderMock.mockReturnValue(true);
const config = withWorkflow(
{},
{
workflows: { lazyDiscovery: true },
}
);

const resolvedConfig = await config('phase-production-build', {
defaultConfig: {},
});
const condition = (resolvedConfig.turbopack?.rules as any)['*.ts']
.condition;
const generatedPathCondition = condition.all.find(
(entry: Record<string, unknown>) => 'any' in entry
);
const contentCondition = condition.all.find(
(entry: Record<string, unknown>) => 'content' in entry
);
const [nonGeneratedPath, deferredStepCopyPath] = generatedPathCondition.any;

const matchesPathCondition = (path: string) =>
!nonGeneratedPath.not.path.test(path) ||
deferredStepCopyPath.path.test(path);

expect(matchesPathCondition('/repo/workflows/example.ts')).toBe(true);
expect(
matchesPathCondition(
'/repo/app/.well-known/workflow/v1/step/__workflow_step_files__/example.ts'
)
).toBe(true);
expect(
matchesPathCondition('/repo/app/.well-known/workflow/v1/step/route.js')
).toBe(false);
expect(
contentCondition.content.test(
`export async function step() {\n 'use step';\n}`
)
).toBe(true);
});
});
26 changes: 18 additions & 8 deletions packages/next/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import {
shouldUseDeferredBuilder,
WORKFLOW_DEFERRED_ENTRIES,
} from './builder.js';
import { DEFERRED_STEP_COPY_DIR_NAME } from './step-copy-utils.js';

const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m;
const useStepPattern = /^\s*(['"])use step\1;?\s*$/m;
Expand All@@ -14,6 +15,12 @@ const workflowSerdeSymbolPattern =
/Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\1\s*\)/;
const workflowSerdeComputedPropertyPattern =
/\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/;
const generatedWorkflowPathPattern = /[/\\]\.well-known[/\\]workflow[/\\]/;
const deferredStepCopyPathPattern = new RegExp(
String.raw`[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]${DEFERRED_STEP_COPY_DIR_NAME}[/\\]`
);
const turbopackWorkflowContentPattern =
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/;

const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']);
const warnedAutoRemovedServerExternalPackages = new Set<string>();
Expand DownExpand Up@@ -396,18 +403,21 @@ export function withWorkflow(
...(supportsTurboCondition
? {
condition: {
// Use 'all' to combine: must match content AND must NOT be in generated path
// Merge with any existing 'all' conditions from user config
// Merge with any existing 'all' conditions from user config.
all: [
...(existingRules[key]?.condition?.all || []),
// Exclude generated workflow route files from transformation
{ not: { path: /[/\\]\.well-known[/\\]workflow[/\\]/ } },
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{
content:
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/,
// Deferred step copies are generated source files that must
// still be transformed in step mode. Other generated route
// files have already been transformed and remain excluded.
any: [
{ not: { path: generatedWorkflowPathPattern } },
{ path: deferredStepCopyPathPattern },
],
},
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{ content: turbopackWorkflowContentPattern },
],
},
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/create-test-matrix.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,12 +5,14 @@ const DEV_TEST_CONFIGS = {
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
'nextjs-webpack': {
generatedStepPath: 'app/.well-known/workflow/v1/step/route.js',
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
nitro: {
generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs',
Expand Down
4 changes: 2 additions & 2 deletions workbench/nextjs-turbopack/next.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import type { NextConfig } from 'next';
import path from 'node:path';
import type { NextConfig } from 'next';
import { withWorkflow } from 'workflow/next';

const turbopackRoot = path.resolve(process.cwd(), '../..');
Expand All@@ -16,5 +16,5 @@ const nextConfig: NextConfig = {

// export default nextConfig;
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: true },
workflows: { lazyDiscovery: false },
});
4 changes: 3 additions & 1 deletion workbench/nextjs-webpack/next.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,4 +11,6 @@ const nextConfig: NextConfig = {
};

// export default nextConfig;
export default withWorkflow(nextConfig, { workflows: { lazyDiscovery: true } });
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: false },
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[stable] Fix Next deferred step registration under Turbopack by pranaygp · Pull Request #2298 · vercel/workflow · GitHub
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-stable-next-lazy-registration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/next': patch
---

Preserve deferred step registration with Turbopack content filtering.
2 changes: 1 addition & 1 deletion .github/workflows/e2e-community-world.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,7 +156,7 @@ jobs:
SERVICE_TYPE: ${{ inputs.service-type }}
WORLD_ID: ${{ inputs.world-id }}
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../.."}'
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","supportsDeferredStepCopies":false}'

- name: Generate E2E summary
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -649,7 +649,7 @@ jobs:
NODE_OPTIONS: "--enable-source-maps"
APP_NAME: "nextjs-turbopack"
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}'
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000,"supportsDeferredStepCopies":false}'

- name: Print Next.js server logs
if: always()
Expand Down
14 changes: 11 additions & 3 deletions packages/core/e2e/dev.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ export interface DevTestConfig {
apiFilePath: string;
apiFileImportPath: string;
canary?: boolean;
/** Whether the app emits deferred step copy files during dev. */
supportsDeferredStepCopies?: boolean;
/** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */
testWorkflowFile?: string;
/** The workflows directory relative to appPath. Defaults to 'workflows' */
Expand DownExpand Up@@ -44,9 +46,11 @@ export function createDevTests(config?: DevTestConfig) {
);
const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts';
const workflowsDir = finalConfig.workflowsDir ?? 'workflows';
const supportsDeferredStepCopies = generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const supportsDeferredStepCopies =
finalConfig.supportsDeferredStepCopies ??
generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const restoreFiles: Array<{ path: string; content: string }> = [];

const fetchWithTimeout = (pathname: string) => {
Expand DownExpand Up@@ -217,6 +221,10 @@ export async function myNewStep() {
if (stepRouteContent.includes('myNewStep')) {
return;
}
if (!supportsDeferredStepCopies) {
expect(stepRouteContent).toContain('myNewStep');
return;
}

const copiedStepFileNames = await fs.readdir(copiedStepDir);
const copiedStepContents = await Promise.all(
Expand Down
128 changes: 128 additions & 0 deletions packages/next/src/builder-deferred.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getNextBuilderDeferred } from './builder-deferred.js';
import {
DEFERRED_STEP_COPY_DIR_NAME,
parseDeferredStepSourceMetadata,
} from './step-copy-utils.js';

const tempDirs: string[] = [];
// biome-ignore lint/security/noGlobalEval: The test preserves the builder's dynamic import shim while stubbing one import.
const originalEval = globalThis.eval;

afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))
);
tempDirs.length = 0;
vi.unstubAllGlobals();
});

describe('NextDeferredBuilder', () => {
it('generates route imports for local, transitive, package, and built-in steps', async () => {
const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
tempDirs.push(workingDir);
vi.stubGlobal('eval', (source: string) => {
if (source === 'import("@workflow/builders")') {
return import('@workflow/builders');
}
return originalEval(source);
});

const workflowFile = join(workingDir, 'workflows/example.ts');
const localStepFile = join(workingDir, 'workflows/local-step.ts');
const importedStepFile = join(workingDir, 'shared/imported-step.ts');
const packageStepFile = join(
workingDir,
'node_modules/example-step-package/index.js'
);
await mkdir(join(workingDir, 'workflows'), { recursive: true });
await mkdir(join(workingDir, 'shared'), { recursive: true });
await mkdir(join(workingDir, 'node_modules/example-step-package'), {
recursive: true,
});
await writeFile(
workflowFile,
`import '../shared/imported-step';\nexport async function run() {\n 'use workflow';\n}`
);
await writeFile(
localStepFile,
`export async function localStep() {\n 'use step';\n}`
);
await writeFile(
importedStepFile,
`export async function importedStep() {\n 'use step';\n}`
);
await writeFile(
packageStepFile,
`export async function packageStep() {\n 'use step';\n}`
);

const NextDeferredBuilder = await getNextBuilderDeferred();
const builder = new NextDeferredBuilder({
dirs: [],
workingDir,
buildTarget: 'next',
workflowsBundlePath: '',
stepsBundlePath: '',
webhookBundlePath: '',
}) as any;
builder.createDeferredStepsManifest = vi.fn(async () => ({}));

const workflowGeneratedDir = join(
workingDir,
'app/.well-known/workflow/v1'
);
await builder.buildStepsFunction({
workflowGeneratedDir,
discoveredEntries: {
discoveredSteps: [localStepFile, packageStepFile],
discoveredWorkflows: [workflowFile],
discoveredSerdeFiles: [],
},
});

const stepRouteDir = join(workflowGeneratedDir, 'step');
const copiedStepsDir = join(stepRouteDir, DEFERRED_STEP_COPY_DIR_NAME);
const copiedFileNames = await readdir(copiedStepsDir);
const copiedSources = await Promise.all(
copiedFileNames.map(async (fileName) => ({
fileName,
source: await readFile(join(copiedStepsDir, fileName), 'utf-8'),
}))
);
const copiedSourcePaths = copiedSources
.map(
({ source }) => parseDeferredStepSourceMetadata(source)?.absolutePath
)
.filter((path): path is string => Boolean(path));

expect(copiedSourcePaths).toEqual(
expect.arrayContaining([localStepFile, importedStepFile, packageStepFile])
);
expect(
copiedSources.some(({ source }) =>
source.includes('__builtin_response_array_buffer')
)
).toBe(true);

const routeCode = await readFile(join(stepRouteDir, 'route.js'), 'utf-8');
for (const { fileName } of copiedSources) {
expect(routeCode).toContain(
`import './${DEFERRED_STEP_COPY_DIR_NAME}/${fileName}';`
);
}
expect(routeCode).toContain(
"export { stepEntrypoint as HEAD, stepEntrypoint as POST } from 'workflow/runtime';"
);
});
});
42 changes: 42 additions & 0 deletions packages/next/src/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,4 +244,46 @@ describe('withWorkflow builder config', () => {
rmSync(projectDir, { recursive: true, force: true });
}
});

it('lets Turbopack transform deferred step copies but not generated routes', async () => {
shouldUseDeferredBuilderMock.mockReturnValue(true);
const config = withWorkflow(
{},
{
workflows: { lazyDiscovery: true },
}
);

const resolvedConfig = await config('phase-production-build', {
defaultConfig: {},
});
const condition = (resolvedConfig.turbopack?.rules as any)['*.ts']
.condition;
const generatedPathCondition = condition.all.find(
(entry: Record<string, unknown>) => 'any' in entry
);
const contentCondition = condition.all.find(
(entry: Record<string, unknown>) => 'content' in entry
);
const [nonGeneratedPath, deferredStepCopyPath] = generatedPathCondition.any;

const matchesPathCondition = (path: string) =>
!nonGeneratedPath.not.path.test(path) ||
deferredStepCopyPath.path.test(path);

expect(matchesPathCondition('/repo/workflows/example.ts')).toBe(true);
expect(
matchesPathCondition(
'/repo/app/.well-known/workflow/v1/step/__workflow_step_files__/example.ts'
)
).toBe(true);
expect(
matchesPathCondition('/repo/app/.well-known/workflow/v1/step/route.js')
).toBe(false);
expect(
contentCondition.content.test(
`export async function step() {\n 'use step';\n}`
)
).toBe(true);
});
});
26 changes: 18 additions & 8 deletions packages/next/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import {
shouldUseDeferredBuilder,
WORKFLOW_DEFERRED_ENTRIES,
} from './builder.js';
import { DEFERRED_STEP_COPY_DIR_NAME } from './step-copy-utils.js';

const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m;
const useStepPattern = /^\s*(['"])use step\1;?\s*$/m;
Expand All@@ -14,6 +15,12 @@ const workflowSerdeSymbolPattern =
/Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\1\s*\)/;
const workflowSerdeComputedPropertyPattern =
/\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/;
const generatedWorkflowPathPattern = /[/\\]\.well-known[/\\]workflow[/\\]/;
const deferredStepCopyPathPattern = new RegExp(
String.raw`[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]${DEFERRED_STEP_COPY_DIR_NAME}[/\\]`
);
const turbopackWorkflowContentPattern =
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/;

const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']);
const warnedAutoRemovedServerExternalPackages = new Set<string>();
Expand DownExpand Up@@ -396,18 +403,21 @@ export function withWorkflow(
...(supportsTurboCondition
? {
condition: {
// Use 'all' to combine: must match content AND must NOT be in generated path
// Merge with any existing 'all' conditions from user config
// Merge with any existing 'all' conditions from user config.
all: [
...(existingRules[key]?.condition?.all || []),
// Exclude generated workflow route files from transformation
{ not: { path: /[/\\]\.well-known[/\\]workflow[/\\]/ } },
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{
content:
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/,
// Deferred step copies are generated source files that must
// still be transformed in step mode. Other generated route
// files have already been transformed and remain excluded.
any: [
{ not: { path: generatedWorkflowPathPattern } },
{ path: deferredStepCopyPathPattern },
],
},
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{ content: turbopackWorkflowContentPattern },
],
},
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/create-test-matrix.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,12 +5,14 @@ const DEV_TEST_CONFIGS = {
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
'nextjs-webpack': {
generatedStepPath: 'app/.well-known/workflow/v1/step/route.js',
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
nitro: {
generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs',
Expand Down
4 changes: 2 additions & 2 deletions workbench/nextjs-turbopack/next.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import type { NextConfig } from 'next';
import path from 'node:path';
import type { NextConfig } from 'next';
import { withWorkflow } from 'workflow/next';

const turbopackRoot = path.resolve(process.cwd(), '../..');
Expand All@@ -16,5 +16,5 @@ const nextConfig: NextConfig = {

// export default nextConfig;
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: true },
workflows: { lazyDiscovery: false },
});
4 changes: 3 additions & 1 deletion workbench/nextjs-webpack/next.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,4 +11,6 @@ const nextConfig: NextConfig = {
};

// export default nextConfig;
export default withWorkflow(nextConfig, { workflows: { lazyDiscovery: true } });
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: false },
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [stable] Fix Next deferred step registration under Turbopack by pranaygp · Pull Request #2298 · vercel/workflow · GitHub
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-stable-next-lazy-registration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/next': patch
---

Preserve deferred step registration with Turbopack content filtering.
2 changes: 1 addition & 1 deletion .github/workflows/e2e-community-world.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,7 +156,7 @@ jobs:
SERVICE_TYPE: ${{ inputs.service-type }}
WORLD_ID: ${{ inputs.world-id }}
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../.."}'
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","supportsDeferredStepCopies":false}'

- name: Generate E2E summary
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -649,7 +649,7 @@ jobs:
NODE_OPTIONS: "--enable-source-maps"
APP_NAME: "nextjs-turbopack"
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}'
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000,"supportsDeferredStepCopies":false}'

- name: Print Next.js server logs
if: always()
Expand Down
14 changes: 11 additions & 3 deletions packages/core/e2e/dev.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ export interface DevTestConfig {
apiFilePath: string;
apiFileImportPath: string;
canary?: boolean;
/** Whether the app emits deferred step copy files during dev. */
supportsDeferredStepCopies?: boolean;
/** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */
testWorkflowFile?: string;
/** The workflows directory relative to appPath. Defaults to 'workflows' */
Expand DownExpand Up@@ -44,9 +46,11 @@ export function createDevTests(config?: DevTestConfig) {
);
const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts';
const workflowsDir = finalConfig.workflowsDir ?? 'workflows';
const supportsDeferredStepCopies = generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const supportsDeferredStepCopies =
finalConfig.supportsDeferredStepCopies ??
generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const restoreFiles: Array<{ path: string; content: string }> = [];

const fetchWithTimeout = (pathname: string) => {
Expand DownExpand Up@@ -217,6 +221,10 @@ export async function myNewStep() {
if (stepRouteContent.includes('myNewStep')) {
return;
}
if (!supportsDeferredStepCopies) {
expect(stepRouteContent).toContain('myNewStep');
return;
}

const copiedStepFileNames = await fs.readdir(copiedStepDir);
const copiedStepContents = await Promise.all(
Expand Down
128 changes: 128 additions & 0 deletions packages/next/src/builder-deferred.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getNextBuilderDeferred } from './builder-deferred.js';
import {
DEFERRED_STEP_COPY_DIR_NAME,
parseDeferredStepSourceMetadata,
} from './step-copy-utils.js';

const tempDirs: string[] = [];
// biome-ignore lint/security/noGlobalEval: The test preserves the builder's dynamic import shim while stubbing one import.
const originalEval = globalThis.eval;

afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))
);
tempDirs.length = 0;
vi.unstubAllGlobals();
});

describe('NextDeferredBuilder', () => {
it('generates route imports for local, transitive, package, and built-in steps', async () => {
const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
tempDirs.push(workingDir);
vi.stubGlobal('eval', (source: string) => {
if (source === 'import("@workflow/builders")') {
return import('@workflow/builders');
}
return originalEval(source);
});

const workflowFile = join(workingDir, 'workflows/example.ts');
const localStepFile = join(workingDir, 'workflows/local-step.ts');
const importedStepFile = join(workingDir, 'shared/imported-step.ts');
const packageStepFile = join(
workingDir,
'node_modules/example-step-package/index.js'
);
await mkdir(join(workingDir, 'workflows'), { recursive: true });
await mkdir(join(workingDir, 'shared'), { recursive: true });
await mkdir(join(workingDir, 'node_modules/example-step-package'), {
recursive: true,
});
await writeFile(
workflowFile,
`import '../shared/imported-step';\nexport async function run() {\n 'use workflow';\n}`
);
await writeFile(
localStepFile,
`export async function localStep() {\n 'use step';\n}`
);
await writeFile(
importedStepFile,
`export async function importedStep() {\n 'use step';\n}`
);
await writeFile(
packageStepFile,
`export async function packageStep() {\n 'use step';\n}`
);

const NextDeferredBuilder = await getNextBuilderDeferred();
const builder = new NextDeferredBuilder({
dirs: [],
workingDir,
buildTarget: 'next',
workflowsBundlePath: '',
stepsBundlePath: '',
webhookBundlePath: '',
}) as any;
builder.createDeferredStepsManifest = vi.fn(async () => ({}));

const workflowGeneratedDir = join(
workingDir,
'app/.well-known/workflow/v1'
);
await builder.buildStepsFunction({
workflowGeneratedDir,
discoveredEntries: {
discoveredSteps: [localStepFile, packageStepFile],
discoveredWorkflows: [workflowFile],
discoveredSerdeFiles: [],
},
});

const stepRouteDir = join(workflowGeneratedDir, 'step');
const copiedStepsDir = join(stepRouteDir, DEFERRED_STEP_COPY_DIR_NAME);
const copiedFileNames = await readdir(copiedStepsDir);
const copiedSources = await Promise.all(
copiedFileNames.map(async (fileName) => ({
fileName,
source: await readFile(join(copiedStepsDir, fileName), 'utf-8'),
}))
);
const copiedSourcePaths = copiedSources
.map(
({ source }) => parseDeferredStepSourceMetadata(source)?.absolutePath
)
.filter((path): path is string => Boolean(path));

expect(copiedSourcePaths).toEqual(
expect.arrayContaining([localStepFile, importedStepFile, packageStepFile])
);
expect(
copiedSources.some(({ source }) =>
source.includes('__builtin_response_array_buffer')
)
).toBe(true);

const routeCode = await readFile(join(stepRouteDir, 'route.js'), 'utf-8');
for (const { fileName } of copiedSources) {
expect(routeCode).toContain(
`import './${DEFERRED_STEP_COPY_DIR_NAME}/${fileName}';`
);
}
expect(routeCode).toContain(
"export { stepEntrypoint as HEAD, stepEntrypoint as POST } from 'workflow/runtime';"
);
});
});
42 changes: 42 additions & 0 deletions packages/next/src/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,4 +244,46 @@ describe('withWorkflow builder config', () => {
rmSync(projectDir, { recursive: true, force: true });
}
});

it('lets Turbopack transform deferred step copies but not generated routes', async () => {
shouldUseDeferredBuilderMock.mockReturnValue(true);
const config = withWorkflow(
{},
{
workflows: { lazyDiscovery: true },
}
);

const resolvedConfig = await config('phase-production-build', {
defaultConfig: {},
});
const condition = (resolvedConfig.turbopack?.rules as any)['*.ts']
.condition;
const generatedPathCondition = condition.all.find(
(entry: Record<string, unknown>) => 'any' in entry
);
const contentCondition = condition.all.find(
(entry: Record<string, unknown>) => 'content' in entry
);
const [nonGeneratedPath, deferredStepCopyPath] = generatedPathCondition.any;

const matchesPathCondition = (path: string) =>
!nonGeneratedPath.not.path.test(path) ||
deferredStepCopyPath.path.test(path);

expect(matchesPathCondition('/repo/workflows/example.ts')).toBe(true);
expect(
matchesPathCondition(
'/repo/app/.well-known/workflow/v1/step/__workflow_step_files__/example.ts'
)
).toBe(true);
expect(
matchesPathCondition('/repo/app/.well-known/workflow/v1/step/route.js')
).toBe(false);
expect(
contentCondition.content.test(
`export async function step() {\n 'use step';\n}`
)
).toBe(true);
});
});
26 changes: 18 additions & 8 deletions packages/next/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import {
shouldUseDeferredBuilder,
WORKFLOW_DEFERRED_ENTRIES,
} from './builder.js';
import { DEFERRED_STEP_COPY_DIR_NAME } from './step-copy-utils.js';

const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m;
const useStepPattern = /^\s*(['"])use step\1;?\s*$/m;
Expand All@@ -14,6 +15,12 @@ const workflowSerdeSymbolPattern =
/Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\1\s*\)/;
const workflowSerdeComputedPropertyPattern =
/\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/;
const generatedWorkflowPathPattern = /[/\\]\.well-known[/\\]workflow[/\\]/;
const deferredStepCopyPathPattern = new RegExp(
String.raw`[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]${DEFERRED_STEP_COPY_DIR_NAME}[/\\]`
);
const turbopackWorkflowContentPattern =
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/;

const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']);
const warnedAutoRemovedServerExternalPackages = new Set<string>();
Expand DownExpand Up@@ -396,18 +403,21 @@ export function withWorkflow(
...(supportsTurboCondition
? {
condition: {
// Use 'all' to combine: must match content AND must NOT be in generated path
// Merge with any existing 'all' conditions from user config
// Merge with any existing 'all' conditions from user config.
all: [
...(existingRules[key]?.condition?.all || []),
// Exclude generated workflow route files from transformation
{ not: { path: /[/\\]\.well-known[/\\]workflow[/\\]/ } },
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{
content:
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/,
// Deferred step copies are generated source files that must
// still be transformed in step mode. Other generated route
// files have already been transformed and remain excluded.
any: [
{ not: { path: generatedWorkflowPathPattern } },
{ path: deferredStepCopyPathPattern },
],
},
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{ content: turbopackWorkflowContentPattern },
],
},
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/create-test-matrix.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,12 +5,14 @@ const DEV_TEST_CONFIGS = {
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
'nextjs-webpack': {
generatedStepPath: 'app/.well-known/workflow/v1/step/route.js',
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
nitro: {
generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs',
Expand Down
4 changes: 2 additions & 2 deletions workbench/nextjs-turbopack/next.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import type { NextConfig } from 'next';
import path from 'node:path';
import type { NextConfig } from 'next';
import { withWorkflow } from 'workflow/next';

const turbopackRoot = path.resolve(process.cwd(), '../..');
Expand All@@ -16,5 +16,5 @@ const nextConfig: NextConfig = {

// export default nextConfig;
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: true },
workflows: { lazyDiscovery: false },
});
4 changes: 3 additions & 1 deletion workbench/nextjs-webpack/next.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,4 +11,6 @@ const nextConfig: NextConfig = {
};

// export default nextConfig;
export default withWorkflow(nextConfig, { workflows: { lazyDiscovery: true } });
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: false },
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [stable] Fix Next deferred step registration under Turbopack by pranaygp · Pull Request #2298 · vercel/workflow · GitHub
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-stable-next-lazy-registration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/next': patch
---

Preserve deferred step registration with Turbopack content filtering.
2 changes: 1 addition & 1 deletion .github/workflows/e2e-community-world.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,7 +156,7 @@ jobs:
SERVICE_TYPE: ${{ inputs.service-type }}
WORLD_ID: ${{ inputs.world-id }}
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../.."}'
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","supportsDeferredStepCopies":false}'

- name: Generate E2E summary
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -649,7 +649,7 @@ jobs:
NODE_OPTIONS: "--enable-source-maps"
APP_NAME: "nextjs-turbopack"
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}'
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000,"supportsDeferredStepCopies":false}'

- name: Print Next.js server logs
if: always()
Expand Down
14 changes: 11 additions & 3 deletions packages/core/e2e/dev.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ export interface DevTestConfig {
apiFilePath: string;
apiFileImportPath: string;
canary?: boolean;
/** Whether the app emits deferred step copy files during dev. */
supportsDeferredStepCopies?: boolean;
/** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */
testWorkflowFile?: string;
/** The workflows directory relative to appPath. Defaults to 'workflows' */
Expand DownExpand Up@@ -44,9 +46,11 @@ export function createDevTests(config?: DevTestConfig) {
);
const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts';
const workflowsDir = finalConfig.workflowsDir ?? 'workflows';
const supportsDeferredStepCopies = generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const supportsDeferredStepCopies =
finalConfig.supportsDeferredStepCopies ??
generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const restoreFiles: Array<{ path: string; content: string }> = [];

const fetchWithTimeout = (pathname: string) => {
Expand DownExpand Up@@ -217,6 +221,10 @@ export async function myNewStep() {
if (stepRouteContent.includes('myNewStep')) {
return;
}
if (!supportsDeferredStepCopies) {
expect(stepRouteContent).toContain('myNewStep');
return;
}

const copiedStepFileNames = await fs.readdir(copiedStepDir);
const copiedStepContents = await Promise.all(
Expand Down
128 changes: 128 additions & 0 deletions packages/next/src/builder-deferred.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getNextBuilderDeferred } from './builder-deferred.js';
import {
DEFERRED_STEP_COPY_DIR_NAME,
parseDeferredStepSourceMetadata,
} from './step-copy-utils.js';

const tempDirs: string[] = [];
// biome-ignore lint/security/noGlobalEval: The test preserves the builder's dynamic import shim while stubbing one import.
const originalEval = globalThis.eval;

afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))
);
tempDirs.length = 0;
vi.unstubAllGlobals();
});

describe('NextDeferredBuilder', () => {
it('generates route imports for local, transitive, package, and built-in steps', async () => {
const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
tempDirs.push(workingDir);
vi.stubGlobal('eval', (source: string) => {
if (source === 'import("@workflow/builders")') {
return import('@workflow/builders');
}
return originalEval(source);
});

const workflowFile = join(workingDir, 'workflows/example.ts');
const localStepFile = join(workingDir, 'workflows/local-step.ts');
const importedStepFile = join(workingDir, 'shared/imported-step.ts');
const packageStepFile = join(
workingDir,
'node_modules/example-step-package/index.js'
);
await mkdir(join(workingDir, 'workflows'), { recursive: true });
await mkdir(join(workingDir, 'shared'), { recursive: true });
await mkdir(join(workingDir, 'node_modules/example-step-package'), {
recursive: true,
});
await writeFile(
workflowFile,
`import '../shared/imported-step';\nexport async function run() {\n 'use workflow';\n}`
);
await writeFile(
localStepFile,
`export async function localStep() {\n 'use step';\n}`
);
await writeFile(
importedStepFile,
`export async function importedStep() {\n 'use step';\n}`
);
await writeFile(
packageStepFile,
`export async function packageStep() {\n 'use step';\n}`
);

const NextDeferredBuilder = await getNextBuilderDeferred();
const builder = new NextDeferredBuilder({
dirs: [],
workingDir,
buildTarget: 'next',
workflowsBundlePath: '',
stepsBundlePath: '',
webhookBundlePath: '',
}) as any;
builder.createDeferredStepsManifest = vi.fn(async () => ({}));

const workflowGeneratedDir = join(
workingDir,
'app/.well-known/workflow/v1'
);
await builder.buildStepsFunction({
workflowGeneratedDir,
discoveredEntries: {
discoveredSteps: [localStepFile, packageStepFile],
discoveredWorkflows: [workflowFile],
discoveredSerdeFiles: [],
},
});

const stepRouteDir = join(workflowGeneratedDir, 'step');
const copiedStepsDir = join(stepRouteDir, DEFERRED_STEP_COPY_DIR_NAME);
const copiedFileNames = await readdir(copiedStepsDir);
const copiedSources = await Promise.all(
copiedFileNames.map(async (fileName) => ({
fileName,
source: await readFile(join(copiedStepsDir, fileName), 'utf-8'),
}))
);
const copiedSourcePaths = copiedSources
.map(
({ source }) => parseDeferredStepSourceMetadata(source)?.absolutePath
)
.filter((path): path is string => Boolean(path));

expect(copiedSourcePaths).toEqual(
expect.arrayContaining([localStepFile, importedStepFile, packageStepFile])
);
expect(
copiedSources.some(({ source }) =>
source.includes('__builtin_response_array_buffer')
)
).toBe(true);

const routeCode = await readFile(join(stepRouteDir, 'route.js'), 'utf-8');
for (const { fileName } of copiedSources) {
expect(routeCode).toContain(
`import './${DEFERRED_STEP_COPY_DIR_NAME}/${fileName}';`
);
}
expect(routeCode).toContain(
"export { stepEntrypoint as HEAD, stepEntrypoint as POST } from 'workflow/runtime';"
);
});
});
42 changes: 42 additions & 0 deletions packages/next/src/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,4 +244,46 @@ describe('withWorkflow builder config', () => {
rmSync(projectDir, { recursive: true, force: true });
}
});

it('lets Turbopack transform deferred step copies but not generated routes', async () => {
shouldUseDeferredBuilderMock.mockReturnValue(true);
const config = withWorkflow(
{},
{
workflows: { lazyDiscovery: true },
}
);

const resolvedConfig = await config('phase-production-build', {
defaultConfig: {},
});
const condition = (resolvedConfig.turbopack?.rules as any)['*.ts']
.condition;
const generatedPathCondition = condition.all.find(
(entry: Record<string, unknown>) => 'any' in entry
);
const contentCondition = condition.all.find(
(entry: Record<string, unknown>) => 'content' in entry
);
const [nonGeneratedPath, deferredStepCopyPath] = generatedPathCondition.any;

const matchesPathCondition = (path: string) =>
!nonGeneratedPath.not.path.test(path) ||
deferredStepCopyPath.path.test(path);

expect(matchesPathCondition('/repo/workflows/example.ts')).toBe(true);
expect(
matchesPathCondition(
'/repo/app/.well-known/workflow/v1/step/__workflow_step_files__/example.ts'
)
).toBe(true);
expect(
matchesPathCondition('/repo/app/.well-known/workflow/v1/step/route.js')
).toBe(false);
expect(
contentCondition.content.test(
`export async function step() {\n 'use step';\n}`
)
).toBe(true);
});
});
26 changes: 18 additions & 8 deletions packages/next/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import {
shouldUseDeferredBuilder,
WORKFLOW_DEFERRED_ENTRIES,
} from './builder.js';
import { DEFERRED_STEP_COPY_DIR_NAME } from './step-copy-utils.js';

const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m;
const useStepPattern = /^\s*(['"])use step\1;?\s*$/m;
Expand All@@ -14,6 +15,12 @@ const workflowSerdeSymbolPattern =
/Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\1\s*\)/;
const workflowSerdeComputedPropertyPattern =
/\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/;
const generatedWorkflowPathPattern = /[/\\]\.well-known[/\\]workflow[/\\]/;
const deferredStepCopyPathPattern = new RegExp(
String.raw`[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]${DEFERRED_STEP_COPY_DIR_NAME}[/\\]`
);
const turbopackWorkflowContentPattern =
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/;

const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']);
const warnedAutoRemovedServerExternalPackages = new Set<string>();
Expand DownExpand Up@@ -396,18 +403,21 @@ export function withWorkflow(
...(supportsTurboCondition
? {
condition: {
// Use 'all' to combine: must match content AND must NOT be in generated path
// Merge with any existing 'all' conditions from user config
// Merge with any existing 'all' conditions from user config.
all: [
...(existingRules[key]?.condition?.all || []),
// Exclude generated workflow route files from transformation
{ not: { path: /[/\\]\.well-known[/\\]workflow[/\\]/ } },
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{
content:
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/,
// Deferred step copies are generated source files that must
// still be transformed in step mode. Other generated route
// files have already been transformed and remain excluded.
any: [
{ not: { path: generatedWorkflowPathPattern } },
{ path: deferredStepCopyPathPattern },
],
},
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{ content: turbopackWorkflowContentPattern },
],
},
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/create-test-matrix.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,12 +5,14 @@ const DEV_TEST_CONFIGS = {
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
'nextjs-webpack': {
generatedStepPath: 'app/.well-known/workflow/v1/step/route.js',
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
nitro: {
generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs',
Expand Down
4 changes: 2 additions & 2 deletions workbench/nextjs-turbopack/next.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import type { NextConfig } from 'next';
import path from 'node:path';
import type { NextConfig } from 'next';
import { withWorkflow } from 'workflow/next';

const turbopackRoot = path.resolve(process.cwd(), '../..');
Expand All@@ -16,5 +16,5 @@ const nextConfig: NextConfig = {

// export default nextConfig;
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: true },
workflows: { lazyDiscovery: false },
});
4 changes: 3 additions & 1 deletion workbench/nextjs-webpack/next.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,4 +11,6 @@ const nextConfig: NextConfig = {
};

// export default nextConfig;
export default withWorkflow(nextConfig, { workflows: { lazyDiscovery: true } });
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: false },
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [stable] Fix Next deferred step registration under Turbopack by pranaygp · Pull Request #2298 · vercel/workflow · GitHub
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-stable-next-lazy-registration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/next': patch
---

Preserve deferred step registration with Turbopack content filtering.
2 changes: 1 addition & 1 deletion .github/workflows/e2e-community-world.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,7 +156,7 @@ jobs:
SERVICE_TYPE: ${{ inputs.service-type }}
WORLD_ID: ${{ inputs.world-id }}
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../.."}'
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","supportsDeferredStepCopies":false}'

- name: Generate E2E summary
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -649,7 +649,7 @@ jobs:
NODE_OPTIONS: "--enable-source-maps"
APP_NAME: "nextjs-turbopack"
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}'
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000,"supportsDeferredStepCopies":false}'

- name: Print Next.js server logs
if: always()
Expand Down
14 changes: 11 additions & 3 deletions packages/core/e2e/dev.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ export interface DevTestConfig {
apiFilePath: string;
apiFileImportPath: string;
canary?: boolean;
/** Whether the app emits deferred step copy files during dev. */
supportsDeferredStepCopies?: boolean;
/** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */
testWorkflowFile?: string;
/** The workflows directory relative to appPath. Defaults to 'workflows' */
Expand DownExpand Up@@ -44,9 +46,11 @@ export function createDevTests(config?: DevTestConfig) {
);
const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts';
const workflowsDir = finalConfig.workflowsDir ?? 'workflows';
const supportsDeferredStepCopies = generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const supportsDeferredStepCopies =
finalConfig.supportsDeferredStepCopies ??
generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const restoreFiles: Array<{ path: string; content: string }> = [];

const fetchWithTimeout = (pathname: string) => {
Expand DownExpand Up@@ -217,6 +221,10 @@ export async function myNewStep() {
if (stepRouteContent.includes('myNewStep')) {
return;
}
if (!supportsDeferredStepCopies) {
expect(stepRouteContent).toContain('myNewStep');
return;
}

const copiedStepFileNames = await fs.readdir(copiedStepDir);
const copiedStepContents = await Promise.all(
Expand Down
128 changes: 128 additions & 0 deletions packages/next/src/builder-deferred.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getNextBuilderDeferred } from './builder-deferred.js';
import {
DEFERRED_STEP_COPY_DIR_NAME,
parseDeferredStepSourceMetadata,
} from './step-copy-utils.js';

const tempDirs: string[] = [];
// biome-ignore lint/security/noGlobalEval: The test preserves the builder's dynamic import shim while stubbing one import.
const originalEval = globalThis.eval;

afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))
);
tempDirs.length = 0;
vi.unstubAllGlobals();
});

describe('NextDeferredBuilder', () => {
it('generates route imports for local, transitive, package, and built-in steps', async () => {
const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
tempDirs.push(workingDir);
vi.stubGlobal('eval', (source: string) => {
if (source === 'import("@workflow/builders")') {
return import('@workflow/builders');
}
return originalEval(source);
});

const workflowFile = join(workingDir, 'workflows/example.ts');
const localStepFile = join(workingDir, 'workflows/local-step.ts');
const importedStepFile = join(workingDir, 'shared/imported-step.ts');
const packageStepFile = join(
workingDir,
'node_modules/example-step-package/index.js'
);
await mkdir(join(workingDir, 'workflows'), { recursive: true });
await mkdir(join(workingDir, 'shared'), { recursive: true });
await mkdir(join(workingDir, 'node_modules/example-step-package'), {
recursive: true,
});
await writeFile(
workflowFile,
`import '../shared/imported-step';\nexport async function run() {\n 'use workflow';\n}`
);
await writeFile(
localStepFile,
`export async function localStep() {\n 'use step';\n}`
);
await writeFile(
importedStepFile,
`export async function importedStep() {\n 'use step';\n}`
);
await writeFile(
packageStepFile,
`export async function packageStep() {\n 'use step';\n}`
);

const NextDeferredBuilder = await getNextBuilderDeferred();
const builder = new NextDeferredBuilder({
dirs: [],
workingDir,
buildTarget: 'next',
workflowsBundlePath: '',
stepsBundlePath: '',
webhookBundlePath: '',
}) as any;
builder.createDeferredStepsManifest = vi.fn(async () => ({}));

const workflowGeneratedDir = join(
workingDir,
'app/.well-known/workflow/v1'
);
await builder.buildStepsFunction({
workflowGeneratedDir,
discoveredEntries: {
discoveredSteps: [localStepFile, packageStepFile],
discoveredWorkflows: [workflowFile],
discoveredSerdeFiles: [],
},
});

const stepRouteDir = join(workflowGeneratedDir, 'step');
const copiedStepsDir = join(stepRouteDir, DEFERRED_STEP_COPY_DIR_NAME);
const copiedFileNames = await readdir(copiedStepsDir);
const copiedSources = await Promise.all(
copiedFileNames.map(async (fileName) => ({
fileName,
source: await readFile(join(copiedStepsDir, fileName), 'utf-8'),
}))
);
const copiedSourcePaths = copiedSources
.map(
({ source }) => parseDeferredStepSourceMetadata(source)?.absolutePath
)
.filter((path): path is string => Boolean(path));

expect(copiedSourcePaths).toEqual(
expect.arrayContaining([localStepFile, importedStepFile, packageStepFile])
);
expect(
copiedSources.some(({ source }) =>
source.includes('__builtin_response_array_buffer')
)
).toBe(true);

const routeCode = await readFile(join(stepRouteDir, 'route.js'), 'utf-8');
for (const { fileName } of copiedSources) {
expect(routeCode).toContain(
`import './${DEFERRED_STEP_COPY_DIR_NAME}/${fileName}';`
);
}
expect(routeCode).toContain(
"export { stepEntrypoint as HEAD, stepEntrypoint as POST } from 'workflow/runtime';"
);
});
});
42 changes: 42 additions & 0 deletions packages/next/src/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,4 +244,46 @@ describe('withWorkflow builder config', () => {
rmSync(projectDir, { recursive: true, force: true });
}
});

it('lets Turbopack transform deferred step copies but not generated routes', async () => {
shouldUseDeferredBuilderMock.mockReturnValue(true);
const config = withWorkflow(
{},
{
workflows: { lazyDiscovery: true },
}
);

const resolvedConfig = await config('phase-production-build', {
defaultConfig: {},
});
const condition = (resolvedConfig.turbopack?.rules as any)['*.ts']
.condition;
const generatedPathCondition = condition.all.find(
(entry: Record<string, unknown>) => 'any' in entry
);
const contentCondition = condition.all.find(
(entry: Record<string, unknown>) => 'content' in entry
);
const [nonGeneratedPath, deferredStepCopyPath] = generatedPathCondition.any;

const matchesPathCondition = (path: string) =>
!nonGeneratedPath.not.path.test(path) ||
deferredStepCopyPath.path.test(path);

expect(matchesPathCondition('/repo/workflows/example.ts')).toBe(true);
expect(
matchesPathCondition(
'/repo/app/.well-known/workflow/v1/step/__workflow_step_files__/example.ts'
)
).toBe(true);
expect(
matchesPathCondition('/repo/app/.well-known/workflow/v1/step/route.js')
).toBe(false);
expect(
contentCondition.content.test(
`export async function step() {\n 'use step';\n}`
)
).toBe(true);
});
});
26 changes: 18 additions & 8 deletions packages/next/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import {
shouldUseDeferredBuilder,
WORKFLOW_DEFERRED_ENTRIES,
} from './builder.js';
import { DEFERRED_STEP_COPY_DIR_NAME } from './step-copy-utils.js';

const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m;
const useStepPattern = /^\s*(['"])use step\1;?\s*$/m;
Expand All@@ -14,6 +15,12 @@ const workflowSerdeSymbolPattern =
/Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\1\s*\)/;
const workflowSerdeComputedPropertyPattern =
/\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/;
const generatedWorkflowPathPattern = /[/\\]\.well-known[/\\]workflow[/\\]/;
const deferredStepCopyPathPattern = new RegExp(
String.raw`[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]${DEFERRED_STEP_COPY_DIR_NAME}[/\\]`
);
const turbopackWorkflowContentPattern =
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/;

const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']);
const warnedAutoRemovedServerExternalPackages = new Set<string>();
Expand DownExpand Up@@ -396,18 +403,21 @@ export function withWorkflow(
...(supportsTurboCondition
? {
condition: {
// Use 'all' to combine: must match content AND must NOT be in generated path
// Merge with any existing 'all' conditions from user config
// Merge with any existing 'all' conditions from user config.
all: [
...(existingRules[key]?.condition?.all || []),
// Exclude generated workflow route files from transformation
{ not: { path: /[/\\]\.well-known[/\\]workflow[/\\]/ } },
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{
content:
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/,
// Deferred step copies are generated source files that must
// still be transformed in step mode. Other generated route
// files have already been transformed and remain excluded.
any: [
{ not: { path: generatedWorkflowPathPattern } },
{ path: deferredStepCopyPathPattern },
],
},
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{ content: turbopackWorkflowContentPattern },
],
},
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/create-test-matrix.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,12 +5,14 @@ const DEV_TEST_CONFIGS = {
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
'nextjs-webpack': {
generatedStepPath: 'app/.well-known/workflow/v1/step/route.js',
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
nitro: {
generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs',
Expand Down
4 changes: 2 additions & 2 deletions workbench/nextjs-turbopack/next.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import type { NextConfig } from 'next';
import path from 'node:path';
import type { NextConfig } from 'next';
import { withWorkflow } from 'workflow/next';

const turbopackRoot = path.resolve(process.cwd(), '../..');
Expand All@@ -16,5 +16,5 @@ const nextConfig: NextConfig = {

// export default nextConfig;
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: true },
workflows: { lazyDiscovery: false },
});
4 changes: 3 additions & 1 deletion workbench/nextjs-webpack/next.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,4 +11,6 @@ const nextConfig: NextConfig = {
};

// export default nextConfig;
export default withWorkflow(nextConfig, { workflows: { lazyDiscovery: true } });
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: false },
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [stable] Fix Next deferred step registration under Turbopack by pranaygp · Pull Request #2298 · vercel/workflow · GitHub
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-stable-next-lazy-registration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/next': patch
---

Preserve deferred step registration with Turbopack content filtering.
2 changes: 1 addition & 1 deletion .github/workflows/e2e-community-world.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,7 +156,7 @@ jobs:
SERVICE_TYPE: ${{ inputs.service-type }}
WORLD_ID: ${{ inputs.world-id }}
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../.."}'
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","supportsDeferredStepCopies":false}'

- name: Generate E2E summary
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -649,7 +649,7 @@ jobs:
NODE_OPTIONS: "--enable-source-maps"
APP_NAME: "nextjs-turbopack"
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}'
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000,"supportsDeferredStepCopies":false}'

- name: Print Next.js server logs
if: always()
Expand Down
14 changes: 11 additions & 3 deletions packages/core/e2e/dev.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ export interface DevTestConfig {
apiFilePath: string;
apiFileImportPath: string;
canary?: boolean;
/** Whether the app emits deferred step copy files during dev. */
supportsDeferredStepCopies?: boolean;
/** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */
testWorkflowFile?: string;
/** The workflows directory relative to appPath. Defaults to 'workflows' */
Expand DownExpand Up@@ -44,9 +46,11 @@ export function createDevTests(config?: DevTestConfig) {
);
const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts';
const workflowsDir = finalConfig.workflowsDir ?? 'workflows';
const supportsDeferredStepCopies = generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const supportsDeferredStepCopies =
finalConfig.supportsDeferredStepCopies ??
generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const restoreFiles: Array<{ path: string; content: string }> = [];

const fetchWithTimeout = (pathname: string) => {
Expand DownExpand Up@@ -217,6 +221,10 @@ export async function myNewStep() {
if (stepRouteContent.includes('myNewStep')) {
return;
}
if (!supportsDeferredStepCopies) {
expect(stepRouteContent).toContain('myNewStep');
return;
}

const copiedStepFileNames = await fs.readdir(copiedStepDir);
const copiedStepContents = await Promise.all(
Expand Down
128 changes: 128 additions & 0 deletions packages/next/src/builder-deferred.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getNextBuilderDeferred } from './builder-deferred.js';
import {
DEFERRED_STEP_COPY_DIR_NAME,
parseDeferredStepSourceMetadata,
} from './step-copy-utils.js';

const tempDirs: string[] = [];
// biome-ignore lint/security/noGlobalEval: The test preserves the builder's dynamic import shim while stubbing one import.
const originalEval = globalThis.eval;

afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))
);
tempDirs.length = 0;
vi.unstubAllGlobals();
});

describe('NextDeferredBuilder', () => {
it('generates route imports for local, transitive, package, and built-in steps', async () => {
const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
tempDirs.push(workingDir);
vi.stubGlobal('eval', (source: string) => {
if (source === 'import("@workflow/builders")') {
return import('@workflow/builders');
}
return originalEval(source);
});

const workflowFile = join(workingDir, 'workflows/example.ts');
const localStepFile = join(workingDir, 'workflows/local-step.ts');
const importedStepFile = join(workingDir, 'shared/imported-step.ts');
const packageStepFile = join(
workingDir,
'node_modules/example-step-package/index.js'
);
await mkdir(join(workingDir, 'workflows'), { recursive: true });
await mkdir(join(workingDir, 'shared'), { recursive: true });
await mkdir(join(workingDir, 'node_modules/example-step-package'), {
recursive: true,
});
await writeFile(
workflowFile,
`import '../shared/imported-step';\nexport async function run() {\n 'use workflow';\n}`
);
await writeFile(
localStepFile,
`export async function localStep() {\n 'use step';\n}`
);
await writeFile(
importedStepFile,
`export async function importedStep() {\n 'use step';\n}`
);
await writeFile(
packageStepFile,
`export async function packageStep() {\n 'use step';\n}`
);

const NextDeferredBuilder = await getNextBuilderDeferred();
const builder = new NextDeferredBuilder({
dirs: [],
workingDir,
buildTarget: 'next',
workflowsBundlePath: '',
stepsBundlePath: '',
webhookBundlePath: '',
}) as any;
builder.createDeferredStepsManifest = vi.fn(async () => ({}));

const workflowGeneratedDir = join(
workingDir,
'app/.well-known/workflow/v1'
);
await builder.buildStepsFunction({
workflowGeneratedDir,
discoveredEntries: {
discoveredSteps: [localStepFile, packageStepFile],
discoveredWorkflows: [workflowFile],
discoveredSerdeFiles: [],
},
});

const stepRouteDir = join(workflowGeneratedDir, 'step');
const copiedStepsDir = join(stepRouteDir, DEFERRED_STEP_COPY_DIR_NAME);
const copiedFileNames = await readdir(copiedStepsDir);
const copiedSources = await Promise.all(
copiedFileNames.map(async (fileName) => ({
fileName,
source: await readFile(join(copiedStepsDir, fileName), 'utf-8'),
}))
);
const copiedSourcePaths = copiedSources
.map(
({ source }) => parseDeferredStepSourceMetadata(source)?.absolutePath
)
.filter((path): path is string => Boolean(path));

expect(copiedSourcePaths).toEqual(
expect.arrayContaining([localStepFile, importedStepFile, packageStepFile])
);
expect(
copiedSources.some(({ source }) =>
source.includes('__builtin_response_array_buffer')
)
).toBe(true);

const routeCode = await readFile(join(stepRouteDir, 'route.js'), 'utf-8');
for (const { fileName } of copiedSources) {
expect(routeCode).toContain(
`import './${DEFERRED_STEP_COPY_DIR_NAME}/${fileName}';`
);
}
expect(routeCode).toContain(
"export { stepEntrypoint as HEAD, stepEntrypoint as POST } from 'workflow/runtime';"
);
});
});
42 changes: 42 additions & 0 deletions packages/next/src/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,4 +244,46 @@ describe('withWorkflow builder config', () => {
rmSync(projectDir, { recursive: true, force: true });
}
});

it('lets Turbopack transform deferred step copies but not generated routes', async () => {
shouldUseDeferredBuilderMock.mockReturnValue(true);
const config = withWorkflow(
{},
{
workflows: { lazyDiscovery: true },
}
);

const resolvedConfig = await config('phase-production-build', {
defaultConfig: {},
});
const condition = (resolvedConfig.turbopack?.rules as any)['*.ts']
.condition;
const generatedPathCondition = condition.all.find(
(entry: Record<string, unknown>) => 'any' in entry
);
const contentCondition = condition.all.find(
(entry: Record<string, unknown>) => 'content' in entry
);
const [nonGeneratedPath, deferredStepCopyPath] = generatedPathCondition.any;

const matchesPathCondition = (path: string) =>
!nonGeneratedPath.not.path.test(path) ||
deferredStepCopyPath.path.test(path);

expect(matchesPathCondition('/repo/workflows/example.ts')).toBe(true);
expect(
matchesPathCondition(
'/repo/app/.well-known/workflow/v1/step/__workflow_step_files__/example.ts'
)
).toBe(true);
expect(
matchesPathCondition('/repo/app/.well-known/workflow/v1/step/route.js')
).toBe(false);
expect(
contentCondition.content.test(
`export async function step() {\n 'use step';\n}`
)
).toBe(true);
});
});
26 changes: 18 additions & 8 deletions packages/next/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import {
shouldUseDeferredBuilder,
WORKFLOW_DEFERRED_ENTRIES,
} from './builder.js';
import { DEFERRED_STEP_COPY_DIR_NAME } from './step-copy-utils.js';

const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m;
const useStepPattern = /^\s*(['"])use step\1;?\s*$/m;
Expand All@@ -14,6 +15,12 @@ const workflowSerdeSymbolPattern =
/Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\1\s*\)/;
const workflowSerdeComputedPropertyPattern =
/\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/;
const generatedWorkflowPathPattern = /[/\\]\.well-known[/\\]workflow[/\\]/;
const deferredStepCopyPathPattern = new RegExp(
String.raw`[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]${DEFERRED_STEP_COPY_DIR_NAME}[/\\]`
);
const turbopackWorkflowContentPattern =
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/;

const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']);
const warnedAutoRemovedServerExternalPackages = new Set<string>();
Expand DownExpand Up@@ -396,18 +403,21 @@ export function withWorkflow(
...(supportsTurboCondition
? {
condition: {
// Use 'all' to combine: must match content AND must NOT be in generated path
// Merge with any existing 'all' conditions from user config
// Merge with any existing 'all' conditions from user config.
all: [
...(existingRules[key]?.condition?.all || []),
// Exclude generated workflow route files from transformation
{ not: { path: /[/\\]\.well-known[/\\]workflow[/\\]/ } },
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{
content:
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/,
// Deferred step copies are generated source files that must
// still be transformed in step mode. Other generated route
// files have already been transformed and remain excluded.
any: [
{ not: { path: generatedWorkflowPathPattern } },
{ path: deferredStepCopyPathPattern },
],
},
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{ content: turbopackWorkflowContentPattern },
],
},
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/create-test-matrix.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,12 +5,14 @@ const DEV_TEST_CONFIGS = {
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
'nextjs-webpack': {
generatedStepPath: 'app/.well-known/workflow/v1/step/route.js',
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
nitro: {
generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs',
Expand Down
4 changes: 2 additions & 2 deletions workbench/nextjs-turbopack/next.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import type { NextConfig } from 'next';
import path from 'node:path';
import type { NextConfig } from 'next';
import { withWorkflow } from 'workflow/next';

const turbopackRoot = path.resolve(process.cwd(), '../..');
Expand All@@ -16,5 +16,5 @@ const nextConfig: NextConfig = {

// export default nextConfig;
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: true },
workflows: { lazyDiscovery: false },
});
4 changes: 3 additions & 1 deletion workbench/nextjs-webpack/next.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,4 +11,6 @@ const nextConfig: NextConfig = {
};

// export default nextConfig;
export default withWorkflow(nextConfig, { workflows: { lazyDiscovery: true } });
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: false },
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [stable] Fix Next deferred step registration under Turbopack by pranaygp · Pull Request #2298 · vercel/workflow · GitHub
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-stable-next-lazy-registration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/next': patch
---

Preserve deferred step registration with Turbopack content filtering.
2 changes: 1 addition & 1 deletion .github/workflows/e2e-community-world.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,7 +156,7 @@ jobs:
SERVICE_TYPE: ${{ inputs.service-type }}
WORLD_ID: ${{ inputs.world-id }}
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../.."}'
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","supportsDeferredStepCopies":false}'

- name: Generate E2E summary
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -649,7 +649,7 @@ jobs:
NODE_OPTIONS: "--enable-source-maps"
APP_NAME: "nextjs-turbopack"
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}'
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000,"supportsDeferredStepCopies":false}'

- name: Print Next.js server logs
if: always()
Expand Down
14 changes: 11 additions & 3 deletions packages/core/e2e/dev.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ export interface DevTestConfig {
apiFilePath: string;
apiFileImportPath: string;
canary?: boolean;
/** Whether the app emits deferred step copy files during dev. */
supportsDeferredStepCopies?: boolean;
/** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */
testWorkflowFile?: string;
/** The workflows directory relative to appPath. Defaults to 'workflows' */
Expand DownExpand Up@@ -44,9 +46,11 @@ export function createDevTests(config?: DevTestConfig) {
);
const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts';
const workflowsDir = finalConfig.workflowsDir ?? 'workflows';
const supportsDeferredStepCopies = generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const supportsDeferredStepCopies =
finalConfig.supportsDeferredStepCopies ??
generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const restoreFiles: Array<{ path: string; content: string }> = [];

const fetchWithTimeout = (pathname: string) => {
Expand DownExpand Up@@ -217,6 +221,10 @@ export async function myNewStep() {
if (stepRouteContent.includes('myNewStep')) {
return;
}
if (!supportsDeferredStepCopies) {
expect(stepRouteContent).toContain('myNewStep');
return;
}

const copiedStepFileNames = await fs.readdir(copiedStepDir);
const copiedStepContents = await Promise.all(
Expand Down
128 changes: 128 additions & 0 deletions packages/next/src/builder-deferred.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getNextBuilderDeferred } from './builder-deferred.js';
import {
DEFERRED_STEP_COPY_DIR_NAME,
parseDeferredStepSourceMetadata,
} from './step-copy-utils.js';

const tempDirs: string[] = [];
// biome-ignore lint/security/noGlobalEval: The test preserves the builder's dynamic import shim while stubbing one import.
const originalEval = globalThis.eval;

afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))
);
tempDirs.length = 0;
vi.unstubAllGlobals();
});

describe('NextDeferredBuilder', () => {
it('generates route imports for local, transitive, package, and built-in steps', async () => {
const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
tempDirs.push(workingDir);
vi.stubGlobal('eval', (source: string) => {
if (source === 'import("@workflow/builders")') {
return import('@workflow/builders');
}
return originalEval(source);
});

const workflowFile = join(workingDir, 'workflows/example.ts');
const localStepFile = join(workingDir, 'workflows/local-step.ts');
const importedStepFile = join(workingDir, 'shared/imported-step.ts');
const packageStepFile = join(
workingDir,
'node_modules/example-step-package/index.js'
);
await mkdir(join(workingDir, 'workflows'), { recursive: true });
await mkdir(join(workingDir, 'shared'), { recursive: true });
await mkdir(join(workingDir, 'node_modules/example-step-package'), {
recursive: true,
});
await writeFile(
workflowFile,
`import '../shared/imported-step';\nexport async function run() {\n 'use workflow';\n}`
);
await writeFile(
localStepFile,
`export async function localStep() {\n 'use step';\n}`
);
await writeFile(
importedStepFile,
`export async function importedStep() {\n 'use step';\n}`
);
await writeFile(
packageStepFile,
`export async function packageStep() {\n 'use step';\n}`
);

const NextDeferredBuilder = await getNextBuilderDeferred();
const builder = new NextDeferredBuilder({
dirs: [],
workingDir,
buildTarget: 'next',
workflowsBundlePath: '',
stepsBundlePath: '',
webhookBundlePath: '',
}) as any;
builder.createDeferredStepsManifest = vi.fn(async () => ({}));

const workflowGeneratedDir = join(
workingDir,
'app/.well-known/workflow/v1'
);
await builder.buildStepsFunction({
workflowGeneratedDir,
discoveredEntries: {
discoveredSteps: [localStepFile, packageStepFile],
discoveredWorkflows: [workflowFile],
discoveredSerdeFiles: [],
},
});

const stepRouteDir = join(workflowGeneratedDir, 'step');
const copiedStepsDir = join(stepRouteDir, DEFERRED_STEP_COPY_DIR_NAME);
const copiedFileNames = await readdir(copiedStepsDir);
const copiedSources = await Promise.all(
copiedFileNames.map(async (fileName) => ({
fileName,
source: await readFile(join(copiedStepsDir, fileName), 'utf-8'),
}))
);
const copiedSourcePaths = copiedSources
.map(
({ source }) => parseDeferredStepSourceMetadata(source)?.absolutePath
)
.filter((path): path is string => Boolean(path));

expect(copiedSourcePaths).toEqual(
expect.arrayContaining([localStepFile, importedStepFile, packageStepFile])
);
expect(
copiedSources.some(({ source }) =>
source.includes('__builtin_response_array_buffer')
)
).toBe(true);

const routeCode = await readFile(join(stepRouteDir, 'route.js'), 'utf-8');
for (const { fileName } of copiedSources) {
expect(routeCode).toContain(
`import './${DEFERRED_STEP_COPY_DIR_NAME}/${fileName}';`
);
}
expect(routeCode).toContain(
"export { stepEntrypoint as HEAD, stepEntrypoint as POST } from 'workflow/runtime';"
);
});
});
42 changes: 42 additions & 0 deletions packages/next/src/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,4 +244,46 @@ describe('withWorkflow builder config', () => {
rmSync(projectDir, { recursive: true, force: true });
}
});

it('lets Turbopack transform deferred step copies but not generated routes', async () => {
shouldUseDeferredBuilderMock.mockReturnValue(true);
const config = withWorkflow(
{},
{
workflows: { lazyDiscovery: true },
}
);

const resolvedConfig = await config('phase-production-build', {
defaultConfig: {},
});
const condition = (resolvedConfig.turbopack?.rules as any)['*.ts']
.condition;
const generatedPathCondition = condition.all.find(
(entry: Record<string, unknown>) => 'any' in entry
);
const contentCondition = condition.all.find(
(entry: Record<string, unknown>) => 'content' in entry
);
const [nonGeneratedPath, deferredStepCopyPath] = generatedPathCondition.any;

const matchesPathCondition = (path: string) =>
!nonGeneratedPath.not.path.test(path) ||
deferredStepCopyPath.path.test(path);

expect(matchesPathCondition('/repo/workflows/example.ts')).toBe(true);
expect(
matchesPathCondition(
'/repo/app/.well-known/workflow/v1/step/__workflow_step_files__/example.ts'
)
).toBe(true);
expect(
matchesPathCondition('/repo/app/.well-known/workflow/v1/step/route.js')
).toBe(false);
expect(
contentCondition.content.test(
`export async function step() {\n 'use step';\n}`
)
).toBe(true);
});
});
26 changes: 18 additions & 8 deletions packages/next/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import {
shouldUseDeferredBuilder,
WORKFLOW_DEFERRED_ENTRIES,
} from './builder.js';
import { DEFERRED_STEP_COPY_DIR_NAME } from './step-copy-utils.js';

const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m;
const useStepPattern = /^\s*(['"])use step\1;?\s*$/m;
Expand All@@ -14,6 +15,12 @@ const workflowSerdeSymbolPattern =
/Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\1\s*\)/;
const workflowSerdeComputedPropertyPattern =
/\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/;
const generatedWorkflowPathPattern = /[/\\]\.well-known[/\\]workflow[/\\]/;
const deferredStepCopyPathPattern = new RegExp(
String.raw`[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]${DEFERRED_STEP_COPY_DIR_NAME}[/\\]`
);
const turbopackWorkflowContentPattern =
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/;

const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']);
const warnedAutoRemovedServerExternalPackages = new Set<string>();
Expand DownExpand Up@@ -396,18 +403,21 @@ export function withWorkflow(
...(supportsTurboCondition
? {
condition: {
// Use 'all' to combine: must match content AND must NOT be in generated path
// Merge with any existing 'all' conditions from user config
// Merge with any existing 'all' conditions from user config.
all: [
...(existingRules[key]?.condition?.all || []),
// Exclude generated workflow route files from transformation
{ not: { path: /[/\\]\.well-known[/\\]workflow[/\\]/ } },
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{
content:
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/,
// Deferred step copies are generated source files that must
// still be transformed in step mode. Other generated route
// files have already been transformed and remain excluded.
any: [
{ not: { path: generatedWorkflowPathPattern } },
{ path: deferredStepCopyPathPattern },
],
},
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{ content: turbopackWorkflowContentPattern },
],
},
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/create-test-matrix.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,12 +5,14 @@ const DEV_TEST_CONFIGS = {
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
'nextjs-webpack': {
generatedStepPath: 'app/.well-known/workflow/v1/step/route.js',
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
nitro: {
generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs',
Expand Down
4 changes: 2 additions & 2 deletions workbench/nextjs-turbopack/next.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import type { NextConfig } from 'next';
import path from 'node:path';
import type { NextConfig } from 'next';
import { withWorkflow } from 'workflow/next';

const turbopackRoot = path.resolve(process.cwd(), '../..');
Expand All@@ -16,5 +16,5 @@ const nextConfig: NextConfig = {

// export default nextConfig;
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: true },
workflows: { lazyDiscovery: false },
});
4 changes: 3 additions & 1 deletion workbench/nextjs-webpack/next.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,4 +11,6 @@ const nextConfig: NextConfig = {
};

// export default nextConfig;
export default withWorkflow(nextConfig, { workflows: { lazyDiscovery: true } });
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: false },
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [stable] Fix Next deferred step registration under Turbopack by pranaygp · Pull Request #2298 · vercel/workflow · GitHub
Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-stable-next-lazy-registration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@workflow/next': patch
---

Preserve deferred step registration with Turbopack content filtering.
2 changes: 1 addition & 1 deletion .github/workflows/e2e-community-world.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,7 +156,7 @@ jobs:
SERVICE_TYPE: ${{ inputs.service-type }}
WORLD_ID: ${{ inputs.world-id }}
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../.."}'
DEV_TEST_CONFIG: '{"name":"${{ inputs.app-name }}","project":"workbench-${{ inputs.app-name }}-workflow","generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","supportsDeferredStepCopies":false}'

- name: Generate E2E summary
if: always()
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -649,7 +649,7 @@ jobs:
NODE_OPTIONS: "--enable-source-maps"
APP_NAME: "nextjs-turbopack"
DEPLOYMENT_URL: "http://localhost:3000"
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000}'
DEV_TEST_CONFIG: '{"generatedStepPath":"app/.well-known/workflow/v1/step/route.js","generatedWorkflowPath":"app/.well-known/workflow/v1/flow/route.js","apiFilePath":"app/api/chat/route.ts","apiFileImportPath":"../../..","port":3000,"supportsDeferredStepCopies":false}'

- name: Print Next.js server logs
if: always()
Expand Down
14 changes: 11 additions & 3 deletions packages/core/e2e/dev.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ export interface DevTestConfig {
apiFilePath: string;
apiFileImportPath: string;
canary?: boolean;
/** Whether the app emits deferred step copy files during dev. */
supportsDeferredStepCopies?: boolean;
/** The workflow file to modify for testing HMR. Defaults to '3_streams.ts' */
testWorkflowFile?: string;
/** The workflows directory relative to appPath. Defaults to 'workflows' */
Expand DownExpand Up@@ -44,9 +46,11 @@ export function createDevTests(config?: DevTestConfig) {
);
const testWorkflowFile = finalConfig.testWorkflowFile ?? '3_streams.ts';
const workflowsDir = finalConfig.workflowsDir ?? 'workflows';
const supportsDeferredStepCopies = generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const supportsDeferredStepCopies =
finalConfig.supportsDeferredStepCopies ??
generatedStep.includes(
path.join('.well-known', 'workflow', 'v1', 'step', 'route.js')
);
const restoreFiles: Array<{ path: string; content: string }> = [];

const fetchWithTimeout = (pathname: string) => {
Expand DownExpand Up@@ -217,6 +221,10 @@ export async function myNewStep() {
if (stepRouteContent.includes('myNewStep')) {
return;
}
if (!supportsDeferredStepCopies) {
expect(stepRouteContent).toContain('myNewStep');
return;
}

const copiedStepFileNames = await fs.readdir(copiedStepDir);
const copiedStepContents = await Promise.all(
Expand Down
128 changes: 128 additions & 0 deletions packages/next/src/builder-deferred.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getNextBuilderDeferred } from './builder-deferred.js';
import {
DEFERRED_STEP_COPY_DIR_NAME,
parseDeferredStepSourceMetadata,
} from './step-copy-utils.js';

const tempDirs: string[] = [];
// biome-ignore lint/security/noGlobalEval: The test preserves the builder's dynamic import shim while stubbing one import.
const originalEval = globalThis.eval;

afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))
);
tempDirs.length = 0;
vi.unstubAllGlobals();
});

describe('NextDeferredBuilder', () => {
it('generates route imports for local, transitive, package, and built-in steps', async () => {
const workingDir = await mkdtemp(join(tmpdir(), 'workflow-next-deferred-'));
tempDirs.push(workingDir);
vi.stubGlobal('eval', (source: string) => {
if (source === 'import("@workflow/builders")') {
return import('@workflow/builders');
}
return originalEval(source);
});

const workflowFile = join(workingDir, 'workflows/example.ts');
const localStepFile = join(workingDir, 'workflows/local-step.ts');
const importedStepFile = join(workingDir, 'shared/imported-step.ts');
const packageStepFile = join(
workingDir,
'node_modules/example-step-package/index.js'
);
await mkdir(join(workingDir, 'workflows'), { recursive: true });
await mkdir(join(workingDir, 'shared'), { recursive: true });
await mkdir(join(workingDir, 'node_modules/example-step-package'), {
recursive: true,
});
await writeFile(
workflowFile,
`import '../shared/imported-step';\nexport async function run() {\n 'use workflow';\n}`
);
await writeFile(
localStepFile,
`export async function localStep() {\n 'use step';\n}`
);
await writeFile(
importedStepFile,
`export async function importedStep() {\n 'use step';\n}`
);
await writeFile(
packageStepFile,
`export async function packageStep() {\n 'use step';\n}`
);

const NextDeferredBuilder = await getNextBuilderDeferred();
const builder = new NextDeferredBuilder({
dirs: [],
workingDir,
buildTarget: 'next',
workflowsBundlePath: '',
stepsBundlePath: '',
webhookBundlePath: '',
}) as any;
builder.createDeferredStepsManifest = vi.fn(async () => ({}));

const workflowGeneratedDir = join(
workingDir,
'app/.well-known/workflow/v1'
);
await builder.buildStepsFunction({
workflowGeneratedDir,
discoveredEntries: {
discoveredSteps: [localStepFile, packageStepFile],
discoveredWorkflows: [workflowFile],
discoveredSerdeFiles: [],
},
});

const stepRouteDir = join(workflowGeneratedDir, 'step');
const copiedStepsDir = join(stepRouteDir, DEFERRED_STEP_COPY_DIR_NAME);
const copiedFileNames = await readdir(copiedStepsDir);
const copiedSources = await Promise.all(
copiedFileNames.map(async (fileName) => ({
fileName,
source: await readFile(join(copiedStepsDir, fileName), 'utf-8'),
}))
);
const copiedSourcePaths = copiedSources
.map(
({ source }) => parseDeferredStepSourceMetadata(source)?.absolutePath
)
.filter((path): path is string => Boolean(path));

expect(copiedSourcePaths).toEqual(
expect.arrayContaining([localStepFile, importedStepFile, packageStepFile])
);
expect(
copiedSources.some(({ source }) =>
source.includes('__builtin_response_array_buffer')
)
).toBe(true);

const routeCode = await readFile(join(stepRouteDir, 'route.js'), 'utf-8');
for (const { fileName } of copiedSources) {
expect(routeCode).toContain(
`import './${DEFERRED_STEP_COPY_DIR_NAME}/${fileName}';`
);
}
expect(routeCode).toContain(
"export { stepEntrypoint as HEAD, stepEntrypoint as POST } from 'workflow/runtime';"
);
});
});
42 changes: 42 additions & 0 deletions packages/next/src/index.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,4 +244,46 @@ describe('withWorkflow builder config', () => {
rmSync(projectDir, { recursive: true, force: true });
}
});

it('lets Turbopack transform deferred step copies but not generated routes', async () => {
shouldUseDeferredBuilderMock.mockReturnValue(true);
const config = withWorkflow(
{},
{
workflows: { lazyDiscovery: true },
}
);

const resolvedConfig = await config('phase-production-build', {
defaultConfig: {},
});
const condition = (resolvedConfig.turbopack?.rules as any)['*.ts']
.condition;
const generatedPathCondition = condition.all.find(
(entry: Record<string, unknown>) => 'any' in entry
);
const contentCondition = condition.all.find(
(entry: Record<string, unknown>) => 'content' in entry
);
const [nonGeneratedPath, deferredStepCopyPath] = generatedPathCondition.any;

const matchesPathCondition = (path: string) =>
!nonGeneratedPath.not.path.test(path) ||
deferredStepCopyPath.path.test(path);

expect(matchesPathCondition('/repo/workflows/example.ts')).toBe(true);
expect(
matchesPathCondition(
'/repo/app/.well-known/workflow/v1/step/__workflow_step_files__/example.ts'
)
).toBe(true);
expect(
matchesPathCondition('/repo/app/.well-known/workflow/v1/step/route.js')
).toBe(false);
expect(
contentCondition.content.test(
`export async function step() {\n 'use step';\n}`
)
).toBe(true);
});
});
26 changes: 18 additions & 8 deletions packages/next/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import {
shouldUseDeferredBuilder,
WORKFLOW_DEFERRED_ENTRIES,
} from './builder.js';
import { DEFERRED_STEP_COPY_DIR_NAME } from './step-copy-utils.js';

const useWorkflowPattern = /^\s*(['"])use workflow\1;?\s*$/m;
const useStepPattern = /^\s*(['"])use step\1;?\s*$/m;
Expand All@@ -14,6 +15,12 @@ const workflowSerdeSymbolPattern =
/Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\1\s*\)/;
const workflowSerdeComputedPropertyPattern =
/\[\s*WORKFLOW_(?:SERIALIZE|DESERIALIZE)\s*\]/;
const generatedWorkflowPathPattern = /[/\\]\.well-known[/\\]workflow[/\\]/;
const deferredStepCopyPathPattern = new RegExp(
String.raw`[/\\]\.well-known[/\\]workflow[/\\]v1[/\\]step[/\\]${DEFERRED_STEP_COPY_DIR_NAME}[/\\]`
);
const turbopackWorkflowContentPattern =
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/;

const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']);
const warnedAutoRemovedServerExternalPackages = new Set<string>();
Expand DownExpand Up@@ -396,18 +403,21 @@ export function withWorkflow(
...(supportsTurboCondition
? {
condition: {
// Use 'all' to combine: must match content AND must NOT be in generated path
// Merge with any existing 'all' conditions from user config
// Merge with any existing 'all' conditions from user config.
all: [
...(existingRules[key]?.condition?.all || []),
// Exclude generated workflow route files from transformation
{ not: { path: /[/\\]\.well-known[/\\]workflow[/\\]/ } },
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{
content:
/(use workflow|use step|from\s+(['"])@workflow\/serde\2|Symbol\.for\s*\(\s*(['"])workflow-(?:serialize|deserialize)\3\s*\))/,
// Deferred step copies are generated source files that must
// still be transformed in step mode. Other generated route
// files have already been transformed and remain excluded.
any: [
{ not: { path: generatedWorkflowPathPattern } },
{ path: deferredStepCopyPathPattern },
],
},
// Match files with workflow directives or custom serialization patterns
// Uses backreferences (\2, \3) to ensure matching quote types
{ content: turbopackWorkflowContentPattern },
],
},
}
Expand Down
2 changes: 2 additions & 0 deletions scripts/create-test-matrix.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,12 +5,14 @@ const DEV_TEST_CONFIGS = {
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
'nextjs-webpack': {
generatedStepPath: 'app/.well-known/workflow/v1/step/route.js',
generatedWorkflowPath: 'app/.well-known/workflow/v1/flow/route.js',
apiFilePath: 'app/api/chat/route.ts',
apiFileImportPath: '../../..',
supportsDeferredStepCopies: false,
},
nitro: {
generatedStepPath: 'node_modules/.nitro/workflow/steps.mjs',
Expand Down
4 changes: 2 additions & 2 deletions workbench/nextjs-turbopack/next.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
import type { NextConfig } from 'next';
import path from 'node:path';
import type { NextConfig } from 'next';
import { withWorkflow } from 'workflow/next';

const turbopackRoot = path.resolve(process.cwd(), '../..');
Expand All@@ -16,5 +16,5 @@ const nextConfig: NextConfig = {

// export default nextConfig;
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: true },
workflows: { lazyDiscovery: false },
});
4 changes: 3 additions & 1 deletion workbench/nextjs-webpack/next.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,4 +11,6 @@ const nextConfig: NextConfig = {
};

// export default nextConfig;
export default withWorkflow(nextConfig, { workflows: { lazyDiscovery: true } });
export default withWorkflow(nextConfig, {
workflows: { lazyDiscovery: false },
});
Loading