From c11a515df3805436fe25c98d85922e307a728beb Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 20 Mar 2026 11:34:05 -0700 Subject: [PATCH 1/7] Revert "[ci] Fix nextjs lazy disocvery impacting e2e tests, disable experimental DurableAgent tests (#1400)" This reverts commit a2c0c7e6d9d7349bd49aac6e6ea072c68efb7620. --- .changeset/strong-dryers-begin.md | 6 --- packages/core/e2e/utils.ts | 20 ++-------- packages/next/src/builder-deferred.ts | 54 --------------------------- packages/next/src/index.ts | 11 +----- 4 files changed, 5 insertions(+), 86 deletions(-) delete mode 100644 .changeset/strong-dryers-begin.md diff --git a/.changeset/strong-dryers-begin.md b/.changeset/strong-dryers-begin.md deleted file mode 100644 index 042c9b4769..0000000000 --- a/.changeset/strong-dryers-begin.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@workflow/core": patch -"@workflow/next": patch ---- - -Seed lazy workflow file discovery in NextJS. Require workflow definitions to be in manifest for Vercel environments. diff --git a/packages/core/e2e/utils.ts b/packages/core/e2e/utils.ts index 34897393e0..c50dda6dbe 100644 --- a/packages/core/e2e/utils.ts +++ b/packages/core/e2e/utils.ts @@ -391,23 +391,9 @@ export async function getWorkflowMetadata( await sleep(manifestRetryIntervalMs); } - // For Vercel deployments, the workflow must be in the manifest. A missing - // workflow means the deployment's build didn't include it, so a fallback ID - // would just create a run that never executes and times out silently. - if (!isLocalDeployment()) { - const availableWorkflows = Object.entries(manifest.workflows) - .flatMap(([file, fns]) => Object.keys(fns).map((fn) => `${file}:${fn}`)) - .join(', '); - throw new Error( - `Workflow "${workflowFn}" not found in manifest for "${workflowFile}" ` + - `after ${manifestRetryTimeoutMs}ms. The deployment may not include this workflow. ` + - `Available workflows: ${availableWorkflows || '(none)'}` - ); - } - - // For local development, fall back to the deterministic workflow ID format - // used by the transform. Deferred discovery can lag behind manifest - // publication in staged/out-of-monorepo tests. + // Deferred discovery can lag behind manifest publication in staged/out-of- + // monorepo tests. Fall back to the deterministic workflow ID format used by + // the transform so tests can continue exercising runtime behavior. const fallbackWorkflowId = getFallbackWorkflowId(workflowFile, workflowFn); console.warn( `Workflow "${workflowFn}" not found in manifest for "${workflowFile}" after ${manifestRetryTimeoutMs}ms; ` + diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 80d8c00324..cca3e0abaf 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -661,54 +661,9 @@ export async function getNextBuilderDeferred() { } await this.loadWorkflowsCache(); - - // The cache only contains files discovered in previous builds. On the - // first build after adding new workflow files, those files won't be in - // the cache. In production builds (`watch: false`), the loader's socket - // notifications arrive too late — `scheduleDeferredRebuild()` is a no-op - // so newly discovered files never trigger a rebuild. - // - // To fix this, eagerly scan the dirs for files with workflow/step - // directives and seed the discovered sets before the build runs. - await this.seedDiscoveredFilesFromDirs(); - this.cacheInitialized = true; } - /** - * Scans the configured dirs for files containing `'use workflow'` or - * `'use step'` directives and adds them to the discovered sets. This - * ensures new workflow files are included in the very first production - * build, without waiting for the loader's async socket notifications. - */ - private async seedDiscoveredFilesFromDirs(): Promise { - // Use the base builder's file scanning (which searches all TS/JS files - // in dirs, not just entrypoints). Call the base class version directly - // to bypass the deferred builder's entrypoint-only filter. - const allInputFiles = await this.getAllDirFiles(); - - await Promise.all( - allInputFiles.map(async (filePath) => { - try { - const source = await readFile(filePath, 'utf-8'); - const patterns = detectWorkflowPatterns(source); - - if (patterns.hasUseWorkflow) { - this.discoveredWorkflowFiles.add(filePath); - } - if (patterns.hasUseStep) { - this.discoveredStepFiles.add(filePath); - } - if (patterns.hasSerde && !isWorkflowSdkFile(filePath)) { - this.discoveredSerdeFiles.add(filePath); - } - } catch { - // File may have been deleted between glob and read — skip it. - } - }) - ); - } - private getDistDir(): string { return (this.config as { distDir?: string }).distDir || '.next'; } @@ -1045,15 +1000,6 @@ export async function getNextBuilderDeferred() { ); } - /** - * Returns ALL files from the configured dirs without filtering to - * entrypoints. Used by `seedDiscoveredFilesFromDirs` to scan for - * workflow/step directives on first build. - */ - private async getAllDirFiles(): Promise { - return super.getInputFiles(); - } - protected async getInputFiles(): Promise { const inputFiles = await super.getInputFiles(); return inputFiles.filter((item) => { diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 42c22b7426..62f24bae19 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -86,15 +86,8 @@ export function withWorkflow( const NextBuilder = await getNextBuilder(nextVersion); return new NextBuilder({ watch: shouldWatch, - // discover workflows from pages/app entries and common workflow dirs - dirs: [ - 'pages', - 'app', - 'src/pages', - 'src/app', - 'workflows', - 'src/workflows', - ], + // discover workflows from pages/app entries + dirs: ['pages', 'app', 'src/pages', 'src/app'], projectRoot: nextConfig.outputFileTracingRoot, workingDir: process.cwd(), distDir: nextConfig.distDir || '.next', From 820d60188602b8beb91748fa996dab1f125f4b74 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 20 Mar 2026 13:18:00 -0700 Subject: [PATCH 2/7] fix(next): stabilize deferred discovery on canary builders --- .changeset/slow-zebras-fry.md | 6 + packages/builders/src/workflow-alias.test.ts | 32 +++- packages/builders/src/workflow-alias.ts | 78 +++++---- packages/next/src/builder-deferred.ts | 42 +++++ packages/next/src/index.ts | 20 ++- pnpm-lock.yaml | 148 ++++++++++-------- workbench/nextjs-turbopack/package.json | 2 +- workbench/nextjs-webpack/package.json | 4 +- .../nextjs-webpack/pages/api/trigger-pages.ts | 4 +- 9 files changed, 232 insertions(+), 104 deletions(-) create mode 100644 .changeset/slow-zebras-fry.md diff --git a/.changeset/slow-zebras-fry.md b/.changeset/slow-zebras-fry.md new file mode 100644 index 0000000000..7d2bede727 --- /dev/null +++ b/.changeset/slow-zebras-fry.md @@ -0,0 +1,6 @@ +--- +'@workflow/builders': patch +'@workflow/next': patch +--- + +Fix deferred Next.js discovery bootstrap and improve workflow alias path resolution for app/pages/workflows sources. diff --git a/packages/builders/src/workflow-alias.test.ts b/packages/builders/src/workflow-alias.test.ts index f39d6e8e24..631ba2b917 100644 --- a/packages/builders/src/workflow-alias.test.ts +++ b/packages/builders/src/workflow-alias.test.ts @@ -1,4 +1,10 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdtempSync, + mkdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -19,7 +25,7 @@ describe('resolveWorkflowAliasRelativePath', () => { beforeEach(() => { clearWorkflowAliasResolutionCache(); testRoot = mkdtempSync(join(tmpdir(), 'workflow-alias-')); - workingDir = join(testRoot, 'app'); + workingDir = join(testRoot, 'project'); mkdirSync(workingDir, { recursive: true }); }); @@ -65,4 +71,26 @@ describe('resolveWorkflowAliasRelativePath', () => { resolveWorkflowAliasRelativePath(externalFilePath, workingDir) ).resolves.toBeUndefined(); }); + + it('maps symlinked app files to app/* aliases', async () => { + const externalAppDir = join(testRoot, 'external', 'app'); + const externalFilePath = join( + externalAppDir, + '.well-known', + 'agent', + 'v1', + 'steps.ts' + ); + writeFile(externalFilePath); + + symlinkSync( + externalAppDir, + join(workingDir, 'app'), + process.platform === 'win32' ? 'junction' : 'dir' + ); + + await expect( + resolveWorkflowAliasRelativePath(externalFilePath, workingDir) + ).resolves.toBe('app/.well-known/agent/v1/steps.ts'); + }); }); diff --git a/packages/builders/src/workflow-alias.ts b/packages/builders/src/workflow-alias.ts index 30d2c3b727..f4a1f9799e 100644 --- a/packages/builders/src/workflow-alias.ts +++ b/packages/builders/src/workflow-alias.ts @@ -1,11 +1,35 @@ import { access, realpath } from 'node:fs/promises'; -import { basename, resolve } from 'node:path'; +import { resolve } from 'node:path'; const workflowAliasResolutionCache = new Map< string, Promise >(); +const WORKFLOW_ALIAS_ROOTS = [ + 'src/workflows', + 'workflows', + 'src/app', + 'app', + 'src/pages', + 'pages', +] as const; + +function getAliasRelativePathCandidates( + normalizedAbsolutePath: string +): string[] { + const candidates = new Set(); + for (const aliasRoot of WORKFLOW_ALIAS_ROOTS) { + const marker = `/${aliasRoot}/`; + const markerIndex = normalizedAbsolutePath.lastIndexOf(marker); + if (markerIndex === -1) { + continue; + } + candidates.add(normalizedAbsolutePath.slice(markerIndex + 1)); + } + return Array.from(candidates); +} + export function clearWorkflowAliasResolutionCache(): void { workflowAliasResolutionCache.clear(); } @@ -15,10 +39,6 @@ export async function resolveWorkflowAliasRelativePath( workingDir: string ): Promise { const normalizedAbsolutePath = absoluteFilePath.replace(/\\/g, '/'); - // Only workflow source files can map to app-level `workflows/*` aliases. - if (!normalizedAbsolutePath.includes('/workflows/')) { - return undefined; - } const cacheKey = `${workingDir}::${normalizedAbsolutePath}`; const cached = workflowAliasResolutionCache.get(cacheKey); @@ -27,36 +47,40 @@ export async function resolveWorkflowAliasRelativePath( } const resolutionPromise = (async () => { - const fileName = basename(absoluteFilePath); - const aliasDirs = ['workflows', 'src/workflows']; const resolvedFilePath = await realpath(absoluteFilePath).catch( () => undefined ); if (!resolvedFilePath) { return undefined; } - - const aliases = await Promise.all( - aliasDirs.map(async (aliasDir) => { - const candidatePath = resolve(workingDir, aliasDir, fileName); - try { - await access(candidatePath); - } catch { - return undefined; - } - const resolvedCandidatePath = await realpath(candidatePath).catch( - () => undefined - ); - if (!resolvedCandidatePath) { - return undefined; - } - return resolvedCandidatePath === resolvedFilePath - ? `${aliasDir}/${fileName}` - : undefined; - }) + const normalizedResolvedFilePath = resolvedFilePath.replace(/\\/g, '/'); + const aliasCandidates = getAliasRelativePathCandidates( + normalizedAbsolutePath ); + if (aliasCandidates.length === 0) { + return undefined; + } - return aliases.find((aliasPath): aliasPath is string => Boolean(aliasPath)); + for (const aliasRelativePath of aliasCandidates) { + const candidatePath = resolve(workingDir, aliasRelativePath); + try { + await access(candidatePath); + } catch { + continue; + } + const resolvedCandidatePath = await realpath(candidatePath).catch( + () => undefined + ); + if (!resolvedCandidatePath) { + continue; + } + if ( + resolvedCandidatePath.replace(/\\/g, '/') === normalizedResolvedFilePath + ) { + return aliasRelativePath; + } + } + return undefined; })(); workflowAliasResolutionCache.set(cacheKey, resolutionPromise); diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index cca3e0abaf..ddcc0d5547 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -661,6 +661,7 @@ export async function getNextBuilderDeferred() { } await this.loadWorkflowsCache(); + await this.loadDiscoveredEntriesFromInputGraph(); this.cacheInitialized = true; } @@ -944,6 +945,47 @@ export async function getNextBuilderDeferred() { } } + private async loadDiscoveredEntriesFromInputGraph(): Promise { + const inputFiles = await this.getInputFiles(); + if (inputFiles.length === 0) { + return; + } + + const { discoveredWorkflows, discoveredSteps, discoveredSerdeFiles } = + await this.discoverEntries(inputFiles, this.config.workingDir); + const { workflowFiles, stepFiles, serdeFiles } = + await this.reconcileDiscoveredEntries({ + workflowCandidates: discoveredWorkflows, + stepCandidates: discoveredSteps, + serdeCandidates: discoveredSerdeFiles, + validatePatterns: true, + }); + + let hasChanges = false; + for (const filePath of workflowFiles) { + if (!this.discoveredWorkflowFiles.has(filePath)) { + this.discoveredWorkflowFiles.add(filePath); + hasChanges = true; + } + } + for (const filePath of stepFiles) { + if (!this.discoveredStepFiles.has(filePath)) { + this.discoveredStepFiles.add(filePath); + hasChanges = true; + } + } + for (const filePath of serdeFiles) { + if (!this.discoveredSerdeFiles.has(filePath)) { + this.discoveredSerdeFiles.add(filePath); + hasChanges = true; + } + } + + if (hasChanges) { + this.scheduleWorkflowsCacheWrite(); + } + } + private async writeWorkflowsCache(): Promise { const cacheFilePath = this.getWorkflowsCacheFilePath(); const cacheDir = join(this.config.workingDir, this.getDistDir(), 'cache'); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 62f24bae19..31abb45c5e 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -6,6 +6,24 @@ import { WORKFLOW_DEFERRED_ENTRIES, } from './builder.js'; +function resolveNextVersion(workingDir: string): string { + const fallbackVersion = require('next/package.json').version as string; + + try { + const packageJsonPath = require.resolve('next/package.json', { + paths: [workingDir], + }); + const resolvedPackageJson = require(packageJsonPath) as { + version?: unknown; + }; + return typeof resolvedPackageJson.version === 'string' + ? resolvedPackageJson.version + : fallbackVersion; + } catch { + return fallbackVersion; + } +} + export function withWorkflow( nextConfigOrFn: | NextConfig @@ -69,7 +87,7 @@ export function withWorkflow( nextConfig.turbopack.rules = {}; } const existingRules = nextConfig.turbopack.rules as any; - const nextVersion = require('next/package.json').version; + const nextVersion = resolveNextVersion(process.cwd()); const supportsTurboCondition = semver.gte(nextVersion, 'v16.0.0'); const useDeferredBuilder = shouldUseDeferredBuilder(nextVersion); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e644f7416..559be576d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1097,7 +1097,7 @@ importers: version: 4.1.0 geist: specifier: ^1.7.0 - version: 1.7.0(next@16.2.0-canary.65(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) + version: 1.7.0(next@16.2.1-canary.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) isbot: specifier: ^5 version: 5.1.35 @@ -1782,8 +1782,8 @@ importers: specifier: 5.1.6 version: 5.1.6 next: - specifier: 16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.1-canary.3 + version: 16.2.1-canary.3(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) openai: specifier: 6.9.1 version: 6.9.1(ws@8.18.3)(zod@4.3.6) @@ -1906,8 +1906,8 @@ importers: specifier: 5.1.6 version: 5.1.6 next: - specifier: 16.1.6 - version: 16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.1-canary.3 + version: 16.2.1-canary.3(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) openai: specifier: 6.9.1 version: 6.9.1(ws@8.18.3)(zod@4.3.6) @@ -2148,7 +2148,7 @@ importers: version: 1.15.3 '@vercel/analytics': specifier: latest - version: 2.0.0(3eb18ee0ef09bb7b6ddb50c31f32f06d) + version: 2.0.1(3eb18ee0ef09bb7b6ddb50c31f32f06d) '@workflow/swc-plugin': specifier: workspace:* version: link:../../packages/swc-plugin-workflow @@ -4554,8 +4554,8 @@ packages: '@next/env@16.1.6': resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} - '@next/env@16.2.0-canary.65': - resolution: {integrity: sha512-75gSDG2jpvqNCKyaHqGx9AbPhPERWzYU5wY8fFx0eTHrQXK0bClmW8fgn/+5BZmeaa3/TAaJM3JR+b31FdLegA==} + '@next/env@16.2.1-canary.3': + resolution: {integrity: sha512-+daC5Z1Y4kFJv2o0mhXsLdRHYAV7cbpjF2FMD6SVzBPiWshpMeHugmywN/SunB55igfKFfA3BdSYS4HeXabrBw==} '@next/swc-darwin-arm64@16.1.6': resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} @@ -4563,8 +4563,8 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@16.2.0-canary.65': - resolution: {integrity: sha512-V4QrVYe8tKG5wQHYOu2JqTisCO5S6O1zTlH453uPd2kh3HzKLcCMcc1ksWbK1rh2W8WDxQygbCbZeI5UAf5Dcw==} + '@next/swc-darwin-arm64@16.2.1-canary.3': + resolution: {integrity: sha512-ZbfIag4s88rD0j58XQgeBdG3+n1FjIRwJaP7mwWBW4PJVhp59Rj/B3gBSkpaNNBdAoTSdKxp3THG46Ni8ds+PQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -4575,8 +4575,8 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@16.2.0-canary.65': - resolution: {integrity: sha512-QKVsHp0VfNRr6ePSukkzFTlY3BlCVGGyOisLqL+Yrb5Fve8mMXmBbeHT3034O/xzq2/QIcxBpvoUl9keEwEkhg==} + '@next/swc-darwin-x64@16.2.1-canary.3': + resolution: {integrity: sha512-wyM0LxSbBnug8+IlninDgig2edLTjksR6iYmyF4Q+3Hmyj3rzJI5d/AtBPh+ZDVAs16mG/U2TmfygdiQmk93IQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -4587,8 +4587,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-gnu@16.2.0-canary.65': - resolution: {integrity: sha512-3+Rw84v+bvjTKPnBQTP0bdkq1WJJISlC4h8Kdo3AYLAq32CezF/QjEIHHG3ugKOO1TCx7nNuNDtN5t9JCTa8Ow==} + '@next/swc-linux-arm64-gnu@16.2.1-canary.3': + resolution: {integrity: sha512-9zdkRkLAd0Utzr3VBKNjc2rIzqN6plkIEcTWaZpxc781VJVlel22cvpGjsy1x7xylhtGmQ6OCYRPpPpVeqgBDg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -4599,8 +4599,8 @@ packages: cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.2.0-canary.65': - resolution: {integrity: sha512-4NqyWr2NvNc8vVord4tlONJkmJq4DcBp/p0g9gETgnbkCu3wrE6q+Qtn6qfLi3QhBkUWaILgtWt9vGC6FCwWgg==} + '@next/swc-linux-arm64-musl@16.2.1-canary.3': + resolution: {integrity: sha512-GPlFunUnHv0IIAb21XBxo8IONNMLpQT8NS5RQrmRq4VOo2RGBQhtIc9hxdiDBR+I+gYPwyUdyXWtbaxdHYcyvA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -4611,8 +4611,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-gnu@16.2.0-canary.65': - resolution: {integrity: sha512-lrQTUA3ijEcbgMBBDj22bGeulN3CNqyf3tNLHgAkmgAne1ZgbJwHPEIKM2rU2KRCaFsqyGRFXrUAQU8fcO12Rw==} + '@next/swc-linux-x64-gnu@16.2.1-canary.3': + resolution: {integrity: sha512-doGXSJOQ7pow9Wm9UDOymX6LjPIEBmT1gOIPcvxvzDcmoZP7HFJBTP3nDo8yeUPwRXHSi1gdvtW/SmyhjvS+BQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -4623,8 +4623,8 @@ packages: cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.2.0-canary.65': - resolution: {integrity: sha512-8P5hM9WXWH+DES4La5bvDKIhMxhFJ7mI1J+zW62HlwB4h9UX7lhA0VnkStqvkM6RcfUGGYvBF1k9YxxVBbTYwQ==} + '@next/swc-linux-x64-musl@16.2.1-canary.3': + resolution: {integrity: sha512-KLrVRp9XPc9ReTicI2Sf2BdMKDTwxyjzedCBwBqF04YBsttFTe4Y3WcFFXV51QwLjxDu9nwvTX8ytNK4VrKpsg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -4635,8 +4635,8 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@16.2.0-canary.65': - resolution: {integrity: sha512-+7sUTlvtbD8uCWl7Kn6OH1K2Qsg8W2vKdGLbOnNrICSRRb86Y1ZBFnrfmmyjV0Lr+gsdps+AcPEShFcTwLvs/g==} + '@next/swc-win32-arm64-msvc@16.2.1-canary.3': + resolution: {integrity: sha512-ILGVCZUjrnvSd+fcqSLiujS0O34MYlwhel7p0vLBG962T2KaukmlICAXIYQjL8uSdI8cqaTmvRq8aEWtU8CVeg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -4647,8 +4647,8 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.0-canary.65': - resolution: {integrity: sha512-miAzz8L1JMCU037c4xWHrFKq+1PNQZFlWhDMvzhkBzD3o47e8rI+3/GHXCLZXtgmV+HYoOs1P8ylOjgJ1eV+7w==} + '@next/swc-win32-x64-msvc@16.2.1-canary.3': + resolution: {integrity: sha512-VchnfyGs9mFNAnn2/fD5I1QqQuoRbNtzIvD8txHYQpL2vehdR3GhZYucIHzm2XSXnLRZJsGEt4vWgErivtX9yg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -8152,8 +8152,8 @@ packages: vue-router: optional: true - '@vercel/analytics@2.0.0': - resolution: {integrity: sha512-fP/ASXXz+1K/C2vWTnocd8RsGnkO9f1qOIDrhgQ3DagJtnea1EsM9AV9fDzjXlPIPb2vBQapxOIMCjtGIW8PZw==} + '@vercel/analytics@2.0.1': + resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} peerDependencies: '@remix-run/react': ^2 '@sveltejs/kit': ^1 || ^2 @@ -8170,6 +8170,8 @@ packages: optional: true next: optional: true + nuxt: + optional: true react: optional: true svelte: @@ -8926,10 +8928,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - baseline-browser-mapping@2.9.18: - resolution: {integrity: sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==} - hasBin: true - bcp-47-match@2.0.3: resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} @@ -12553,8 +12551,8 @@ packages: sass: optional: true - next@16.2.0-canary.65: - resolution: {integrity: sha512-vFyjZTV2n3OPwZhU+7POExhCLCSxQBfUd5cHXlFSCWvy71AcCwDPMeSX54J+7LQX+FrwLvo5ivECOqW2kZkxow==} + next@16.2.1-canary.3: + resolution: {integrity: sha512-joTbAm2ppdxNIobKjVLqmabSHmu8GEJ5pAt0lQ2hsFY/W3zHivd3AEDZFs/iGyh1nlxR1xEnTPZY12PVSh36iA==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -18417,54 +18415,54 @@ snapshots: '@next/env@16.1.6': {} - '@next/env@16.2.0-canary.65': {} + '@next/env@16.2.1-canary.3': {} '@next/swc-darwin-arm64@16.1.6': optional: true - '@next/swc-darwin-arm64@16.2.0-canary.65': + '@next/swc-darwin-arm64@16.2.1-canary.3': optional: true '@next/swc-darwin-x64@16.1.6': optional: true - '@next/swc-darwin-x64@16.2.0-canary.65': + '@next/swc-darwin-x64@16.2.1-canary.3': optional: true '@next/swc-linux-arm64-gnu@16.1.6': optional: true - '@next/swc-linux-arm64-gnu@16.2.0-canary.65': + '@next/swc-linux-arm64-gnu@16.2.1-canary.3': optional: true '@next/swc-linux-arm64-musl@16.1.6': optional: true - '@next/swc-linux-arm64-musl@16.2.0-canary.65': + '@next/swc-linux-arm64-musl@16.2.1-canary.3': optional: true '@next/swc-linux-x64-gnu@16.1.6': optional: true - '@next/swc-linux-x64-gnu@16.2.0-canary.65': + '@next/swc-linux-x64-gnu@16.2.1-canary.3': optional: true '@next/swc-linux-x64-musl@16.1.6': optional: true - '@next/swc-linux-x64-musl@16.2.0-canary.65': + '@next/swc-linux-x64-musl@16.2.1-canary.3': optional: true '@next/swc-win32-arm64-msvc@16.1.6': optional: true - '@next/swc-win32-arm64-msvc@16.2.0-canary.65': + '@next/swc-win32-arm64-msvc@16.2.1-canary.3': optional: true '@next/swc-win32-x64-msvc@16.1.6': optional: true - '@next/swc-win32-x64-msvc@16.2.0-canary.65': + '@next/swc-win32-x64-msvc@16.2.1-canary.3': optional: true '@node-rs/xxhash-android-arm-eabi@1.7.6': @@ -23231,12 +23229,11 @@ snapshots: vue: 3.5.22(typescript@5.9.3) vue-router: 4.6.3(vue@3.5.22(typescript@5.9.3)) - '@vercel/analytics@2.0.0(3eb18ee0ef09bb7b6ddb50c31f32f06d)': - dependencies: - nuxt: 4.1.3(@biomejs/biome@2.4.4)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@22.19.0)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vue/compiler-sfc@3.5.22)(better-sqlite3@11.10.0)(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.8)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.8))(eslint@9.38.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) + '@vercel/analytics@2.0.1(3eb18ee0ef09bb7b6ddb50c31f32f06d)': optionalDependencies: '@sveltejs/kit': 2.48.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.43.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) next: 16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nuxt: 4.1.3(@biomejs/biome@2.4.4)(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@22.19.0)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vue/compiler-sfc@3.5.22)(better-sqlite3@11.10.0)(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.8)))(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.8))(eslint@9.38.0(jiti@2.6.1))(ioredis@5.8.2)(lightningcss@1.30.2)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.53.2)(terser@5.44.0)(tsx@4.20.6)(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(yaml@2.8.1) react: 19.2.4 svelte: 5.43.3 vue: 3.5.22(typescript@5.9.3) @@ -23456,14 +23453,6 @@ snapshots: optionalDependencies: vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) - '@vitest/mocker@4.0.18(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 4.0.18 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) - '@vitest/mocker@4.0.18(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.18 @@ -24399,8 +24388,6 @@ snapshots: baseline-browser-mapping@2.10.0: {} - baseline-browser-mapping@2.9.18: {} - bcp-47-match@2.0.3: {} bcp-47-normalize@2.3.0: @@ -24534,7 +24521,7 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.18 + baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001766 electron-to-chromium: 1.5.279 node-releases: 2.0.27 @@ -26628,9 +26615,9 @@ snapshots: fuse.js@7.1.0: {} - geist@1.7.0(next@16.2.0-canary.65(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)): + geist@1.7.0(next@16.2.1-canary.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)): dependencies: - next: 16.2.0-canary.65(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 16.2.1-canary.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) gensync@1.0.0-beta.2: {} @@ -28706,9 +28693,9 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.2.0-canary.65(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@16.2.1-canary.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@next/env': 16.2.0-canary.65 + '@next/env': 16.2.1-canary.3 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001766 @@ -28717,14 +28704,39 @@ snapshots: react-dom: 19.1.0(react@19.1.0) styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.1.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.0-canary.65 - '@next/swc-darwin-x64': 16.2.0-canary.65 - '@next/swc-linux-arm64-gnu': 16.2.0-canary.65 - '@next/swc-linux-arm64-musl': 16.2.0-canary.65 - '@next/swc-linux-x64-gnu': 16.2.0-canary.65 - '@next/swc-linux-x64-musl': 16.2.0-canary.65 - '@next/swc-win32-arm64-msvc': 16.2.0-canary.65 - '@next/swc-win32-x64-msvc': 16.2.0-canary.65 + '@next/swc-darwin-arm64': 16.2.1-canary.3 + '@next/swc-darwin-x64': 16.2.1-canary.3 + '@next/swc-linux-arm64-gnu': 16.2.1-canary.3 + '@next/swc-linux-arm64-musl': 16.2.1-canary.3 + '@next/swc-linux-x64-gnu': 16.2.1-canary.3 + '@next/swc-linux-x64-musl': 16.2.1-canary.3 + '@next/swc-win32-arm64-msvc': 16.2.1-canary.3 + '@next/swc-win32-x64-msvc': 16.2.1-canary.3 + '@opentelemetry/api': 1.9.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + next@16.2.1-canary.3(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@next/env': 16.2.1-canary.3 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001766 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.1-canary.3 + '@next/swc-darwin-x64': 16.2.1-canary.3 + '@next/swc-linux-arm64-gnu': 16.2.1-canary.3 + '@next/swc-linux-arm64-musl': 16.2.1-canary.3 + '@next/swc-linux-x64-gnu': 16.2.1-canary.3 + '@next/swc-linux-x64-musl': 16.2.1-canary.3 + '@next/swc-win32-arm64-msvc': 16.2.1-canary.3 + '@next/swc-win32-x64-msvc': 16.2.1-canary.3 '@opentelemetry/api': 1.9.0 sharp: 0.34.5 transitivePeerDependencies: @@ -32855,7 +32867,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.18(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 diff --git a/workbench/nextjs-turbopack/package.json b/workbench/nextjs-turbopack/package.json index 073693b4d4..4b68fcaeb5 100644 --- a/workbench/nextjs-turbopack/package.json +++ b/workbench/nextjs-turbopack/package.json @@ -34,7 +34,7 @@ "mixpart": "0.0.4", "motion": "^12.29.0", "nanoid": "5.1.6", - "next": "16.1.6", + "next": "16.2.1-canary.3", "openai": "6.9.1", "radix-ui": "1.4.3", "react": "19.2.4", diff --git a/workbench/nextjs-webpack/package.json b/workbench/nextjs-webpack/package.json index d420b1cba0..48105cb41e 100644 --- a/workbench/nextjs-webpack/package.json +++ b/workbench/nextjs-webpack/package.json @@ -34,7 +34,7 @@ "mixpart": "0.0.4", "motion": "^12.29.0", "nanoid": "5.1.6", - "next": "16.1.6", + "next": "16.2.1-canary.3", "openai": "6.9.1", "radix-ui": "1.4.3", "react": "19.2.4", @@ -50,9 +50,9 @@ "@tailwindcss/postcss": "^4", "@types/lodash.chunk": "^4.2.9", "@types/node": "catalog:", - "@vercel/blob": "2.0.0", "@types/react": "^19", "@types/react-dom": "^19", + "@vercel/blob": "2.0.0", "@workflow/world-postgres": "workspace:*", "tailwindcss": "^4", "typescript": "catalog:" diff --git a/workbench/nextjs-webpack/pages/api/trigger-pages.ts b/workbench/nextjs-webpack/pages/api/trigger-pages.ts index 2be79f32b3..3509c7ef3e 100644 --- a/workbench/nextjs-webpack/pages/api/trigger-pages.ts +++ b/workbench/nextjs-webpack/pages/api/trigger-pages.ts @@ -4,9 +4,7 @@ import { WorkflowRunFailedError, WorkflowRunNotCompletedError, } from 'workflow/internal/errors'; -// Use a relative import instead of @/_workflows path alias because esbuild's -// discovery build cannot resolve tsconfig path aliases without baseUrl set. -import { allWorkflows } from '../../_workflows'; +import { allWorkflows } from '@/_workflows'; export default async function handler( req: NextApiRequest, From a9959e0c39514bd54b7f442026797585295dfc40 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 20 Mar 2026 13:21:55 -0700 Subject: [PATCH 3/7] revert canary bump --- pnpm-lock.yaml | 33 +++---------------------- workbench/nextjs-turbopack/package.json | 2 +- workbench/nextjs-webpack/package.json | 2 +- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 559be576d4..5e2cb564e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1782,8 +1782,8 @@ importers: specifier: 5.1.6 version: 5.1.6 next: - specifier: 16.2.1-canary.3 - version: 16.2.1-canary.3(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.1.6 + version: 16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) openai: specifier: 6.9.1 version: 6.9.1(ws@8.18.3)(zod@4.3.6) @@ -1906,8 +1906,8 @@ importers: specifier: 5.1.6 version: 5.1.6 next: - specifier: 16.2.1-canary.3 - version: 16.2.1-canary.3(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.1.6 + version: 16.1.6(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) openai: specifier: 6.9.1 version: 6.9.1(ws@8.18.3)(zod@4.3.6) @@ -28718,31 +28718,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.2.1-canary.3(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@next/env': 16.2.1-canary.3 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001766 - postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) - optionalDependencies: - '@next/swc-darwin-arm64': 16.2.1-canary.3 - '@next/swc-darwin-x64': 16.2.1-canary.3 - '@next/swc-linux-arm64-gnu': 16.2.1-canary.3 - '@next/swc-linux-arm64-musl': 16.2.1-canary.3 - '@next/swc-linux-x64-gnu': 16.2.1-canary.3 - '@next/swc-linux-x64-musl': 16.2.1-canary.3 - '@next/swc-win32-arm64-msvc': 16.2.1-canary.3 - '@next/swc-win32-x64-msvc': 16.2.1-canary.3 - '@opentelemetry/api': 1.9.0 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - nf3@0.1.10: {} nitro@3.0.1-alpha.1(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(better-sqlite3@11.10.0)(chokidar@4.0.3)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(better-sqlite3@11.10.0)(pg@8.16.3)(postgres@3.4.8))(ioredis@5.8.2)(lru-cache@11.2.2)(rollup@4.53.2)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)): diff --git a/workbench/nextjs-turbopack/package.json b/workbench/nextjs-turbopack/package.json index 4b68fcaeb5..073693b4d4 100644 --- a/workbench/nextjs-turbopack/package.json +++ b/workbench/nextjs-turbopack/package.json @@ -34,7 +34,7 @@ "mixpart": "0.0.4", "motion": "^12.29.0", "nanoid": "5.1.6", - "next": "16.2.1-canary.3", + "next": "16.1.6", "openai": "6.9.1", "radix-ui": "1.4.3", "react": "19.2.4", diff --git a/workbench/nextjs-webpack/package.json b/workbench/nextjs-webpack/package.json index 48105cb41e..d72a3a5684 100644 --- a/workbench/nextjs-webpack/package.json +++ b/workbench/nextjs-webpack/package.json @@ -34,7 +34,7 @@ "mixpart": "0.0.4", "motion": "^12.29.0", "nanoid": "5.1.6", - "next": "16.2.1-canary.3", + "next": "16.1.6", "openai": "6.9.1", "radix-ui": "1.4.3", "react": "19.2.4", From 019f5f86aa893b9832e7e666fbf0061abf75883d Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 20 Mar 2026 14:50:48 -0700 Subject: [PATCH 4/7] fix(next): stabilize local e2e discovery for webpack and turbopack --- packages/builders/src/base-builder.ts | 28 ++- .../builders/src/esbuild-tsconfig.test.ts | 179 ++++++++++++++++++ packages/builders/src/esbuild-tsconfig.ts | 33 ++++ packages/next/src/index.ts | 8 + packages/next/src/loader.ts | 15 +- 5 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 packages/builders/src/esbuild-tsconfig.test.ts create mode 100644 packages/builders/src/esbuild-tsconfig.ts diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 498e10f42b..2ef2610985 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -13,6 +13,7 @@ import { type WorkflowManifest, } from './apply-swc-transform.js'; import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js'; +import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js'; import { getImportPath } from './module-specifier.js'; import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; import { createPseudoPackagePlugin } from './pseudo-package-esbuild-plugin.js'; @@ -153,7 +154,8 @@ export abstract class BaseBuilder { protected async discoverEntries( inputs: string[], - outdir: string + outdir: string, + tsconfigPath?: string ): Promise { const previousResult = this.discoveredEntries.get(inputs); @@ -171,6 +173,11 @@ export abstract class BaseBuilder { }; const discoverStart = Date.now(); + const effectiveTsconfigPath = + tsconfigPath ?? (await this.findTsConfigPath()); + const esbuildTsconfigOptions = await getEsbuildTsconfigOptions( + effectiveTsconfigPath + ); try { await esbuild.build({ treeShaking: true, @@ -185,6 +192,7 @@ export abstract class BaseBuilder { sourcemap: false, absWorkingDir: this.config.workingDir, logLevel: 'silent', + ...esbuildTsconfigOptions, // External packages that should not be bundled during discovery external: this.config.externalPackages || [], }); @@ -345,7 +353,7 @@ export abstract class BaseBuilder { // new entries and changes to existing ones const discovered = discoveredEntries ?? - (await this.discoverEntries(inputFiles, dirname(outfile))); + (await this.discoverEntries(inputFiles, dirname(outfile), tsconfigPath)); const stepFiles = [...discovered.discoveredSteps].sort(); const workflowFiles = [...discovered.discoveredWorkflows].sort(); const serdeFiles = [...discovered.discoveredSerdeFiles].sort(); @@ -457,6 +465,8 @@ export abstract class BaseBuilder { ) ) : undefined; + const esbuildTsconfigOptions = + await getEsbuildTsconfigOptions(tsconfigPath); const esbuildCtx = await esbuild.context({ banner: { js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n', @@ -480,8 +490,9 @@ export abstract class BaseBuilder { minify: false, jsx: 'preserve', logLevel: 'error', - // Use tsconfig for path alias resolution - tsconfig: tsconfigPath, + // Use tsconfig for path alias resolution. + // For symlinked configs this uses tsconfigRaw to preserve cwd-relative aliases. + ...esbuildTsconfigOptions, resolveExtensions: [ '.ts', '.tsx', @@ -599,7 +610,7 @@ export abstract class BaseBuilder { }> { const discovered = discoveredEntries ?? - (await this.discoverEntries(inputFiles, dirname(outfile))); + (await this.discoverEntries(inputFiles, dirname(outfile), tsconfigPath)); const workflowFiles = [...discovered.discoveredWorkflows].sort(); const serdeFiles = [...discovered.discoveredSerdeFiles].sort(); @@ -660,6 +671,8 @@ export abstract class BaseBuilder { const bundleStartTime = Date.now(); const workflowManifest: WorkflowManifest = {}; + const esbuildTsconfigOptions = + await getEsbuildTsconfigOptions(tsconfigPath); // Bundle with esbuild and our custom SWC plugin in workflow mode. // this bundle will be run inside a vm isolate @@ -691,8 +704,9 @@ export abstract class BaseBuilder { // This intermediate bundle is executed via runInContext() in a VM, so we need // inline source maps to get meaningful stack traces instead of "evalmachine.". sourcemap: 'inline', - // Use tsconfig for path alias resolution - tsconfig: tsconfigPath, + // Use tsconfig for path alias resolution. + // For symlinked configs this uses tsconfigRaw to preserve cwd-relative aliases. + ...esbuildTsconfigOptions, resolveExtensions: [ '.ts', '.tsx', diff --git a/packages/builders/src/esbuild-tsconfig.test.ts b/packages/builders/src/esbuild-tsconfig.test.ts new file mode 100644 index 0000000000..f9c82aeabe --- /dev/null +++ b/packages/builders/src/esbuild-tsconfig.test.ts @@ -0,0 +1,179 @@ +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import * as esbuild from 'esbuild'; +import { afterEach, describe, expect, it } from 'vitest'; +import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js'; + +const realTmpdir = realpathSync(tmpdir()); + +function writeFile(path: string, contents: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents, 'utf8'); +} + +function normalize(paths: string[]): string[] { + return paths.map((p) => p.replace(/\\/g, '/')); +} + +async function buildInputs({ + workingDir, + tsconfigOptions, +}: { + workingDir: string; + tsconfigOptions: { tsconfig?: string; tsconfigRaw?: string }; +}): Promise { + const result = await esbuild.build({ + absWorkingDir: workingDir, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + logLevel: 'silent', + metafile: true, + stdin: { + contents: `import '@/workflows/target'; import '@/app/.well-known/agent/v1/steps';`, + resolveDir: workingDir, + sourcefile: 'entry.ts', + loader: 'ts', + }, + ...tsconfigOptions, + }); + + return normalize(Object.keys(result.metafile.inputs)); +} + +describe('getEsbuildTsconfigOptions', () => { + const createdRoots: string[] = []; + const symlinkTest = process.platform === 'win32' ? it.skip : it; + + afterEach(() => { + for (const root of createdRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('returns tsconfig for regular files', async () => { + const root = mkdtempSync(join(realTmpdir, 'workflow-tsconfig-regular-')); + createdRoots.push(root); + const workingDir = join(root, 'app'); + const tsconfigPath = join(workingDir, 'tsconfig.json'); + + writeFile( + tsconfigPath, + JSON.stringify( + { + compilerOptions: { + paths: { + '@/*': ['./*'], + }, + }, + }, + null, + 2 + ) + ); + + const options = await getEsbuildTsconfigOptions(tsconfigPath); + expect(options).toEqual({ tsconfig: tsconfigPath }); + }); + + symlinkTest( + 'uses tsconfigRaw for symlinked configs so aliases resolve from working dir', + async () => { + const root = mkdtempSync(join(realTmpdir, 'workflow-tsconfig-symlink-')); + createdRoots.push(root); + const workingDir = join(root, 'webpack-app'); + const sourceDir = join(root, 'turbopack-app'); + const symlinkTsconfigPath = join(workingDir, 'tsconfig.json'); + + writeFile( + join(sourceDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + paths: { + '@/*': ['./*'], + }, + }, + }, + null, + 2 + ) + ); + + writeFile( + join(workingDir, 'workflows/target.ts'), + 'export const id = "webpack";' + ); + writeFile( + join(workingDir, 'app/.well-known/agent/v1/steps.ts'), + 'export const id = "webpack-app";' + ); + writeFile( + join(sourceDir, 'workflows/target.ts'), + 'export const id = "turbopack";' + ); + writeFile( + join(sourceDir, 'app/.well-known/agent/v1/steps.ts'), + 'export const id = "turbopack-app";' + ); + + symlinkSync( + join(sourceDir, 'tsconfig.json'), + symlinkTsconfigPath, + 'file' + ); + + const directInputs = await buildInputs({ + workingDir, + tsconfigOptions: { tsconfig: symlinkTsconfigPath }, + }); + expect( + directInputs.some((input) => + input.includes('turbopack-app/workflows/target.ts') + ) + ).toBe(true); + expect( + directInputs.some((input) => + input.includes('turbopack-app/app/.well-known/agent/v1/steps.ts') + ) + ).toBe(true); + + const options = await getEsbuildTsconfigOptions(symlinkTsconfigPath); + expect(typeof options.tsconfigRaw).toBe('string'); + expect(options.tsconfig).toBeUndefined(); + + const normalizedInputs = await buildInputs({ + workingDir, + tsconfigOptions: options, + }); + + expect( + normalizedInputs.some((input) => input.endsWith('workflows/target.ts')) + ).toBe(true); + expect( + normalizedInputs.some((input) => + input.endsWith('app/.well-known/agent/v1/steps.ts') + ) + ).toBe(true); + expect( + normalizedInputs.some((input) => + input.includes('turbopack-app/workflows/target.ts') + ) + ).toBe(false); + expect( + normalizedInputs.some((input) => + input.includes('turbopack-app/app/.well-known/agent/v1/steps.ts') + ) + ).toBe(false); + } + ); +}); diff --git a/packages/builders/src/esbuild-tsconfig.ts b/packages/builders/src/esbuild-tsconfig.ts new file mode 100644 index 0000000000..84a652fef2 --- /dev/null +++ b/packages/builders/src/esbuild-tsconfig.ts @@ -0,0 +1,33 @@ +import { lstat, readFile } from 'node:fs/promises'; + +export type EsbuildTsconfigOptions = { + tsconfig?: string; + tsconfigRaw?: string; +}; + +/** + * Returns the appropriate tsconfig options for esbuild. + * + * For symlinked tsconfig files we pass `tsconfigRaw` instead of `tsconfig` so + * path aliases (for example `@/*`) are resolved relative to the current + * working directory instead of the symlink target directory. + */ +export async function getEsbuildTsconfigOptions( + tsconfigPath: string | undefined +): Promise { + if (!tsconfigPath) { + return {}; + } + + try { + const stats = await lstat(tsconfigPath); + if (!stats.isSymbolicLink()) { + return { tsconfig: tsconfigPath }; + } + + const tsconfigRaw = await readFile(tsconfigPath, 'utf8'); + return { tsconfigRaw }; + } catch { + return { tsconfig: tsconfigPath }; + } +} diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 31abb45c5e..641aa14762 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -66,6 +66,14 @@ export function withWorkflow( phase: string, ctx: { defaultConfig: NextConfig } ) { + if ( + phase === 'phase-development-server' && + !process.env.WORKFLOW_PUBLIC_MANIFEST + ) { + // Keep local dev/e2e manifest lookup stable by default. + process.env.WORKFLOW_PUBLIC_MANIFEST = '1'; + } + const loaderPath = require.resolve('./loader'); let runDeferredBuildFromCallback: (() => Promise) | undefined; diff --git a/packages/next/src/loader.ts b/packages/next/src/loader.ts index a121bfd065..3f5ed7f075 100644 --- a/packages/next/src/loader.ts +++ b/packages/next/src/loader.ts @@ -125,11 +125,13 @@ async function writeSocketMessage( }); } -function getSocketInfoFilePath(): string { - return ( - process.env.WORKFLOW_SOCKET_INFO_PATH ?? - join(process.cwd(), '.next', 'cache', 'workflow-socket.json') - ); +function getSocketInfoFilePath(): string | null { + const configuredPath = process.env.WORKFLOW_SOCKET_INFO_PATH; + if (!configuredPath) { + return null; + } + + return configuredPath; } function getSocketCredentialsFromEnv(): SocketCredentials | null { @@ -149,6 +151,9 @@ function getSocketCredentialsFromEnv(): SocketCredentials | null { async function getSocketCredentialsFromFile(): Promise { const socketInfoFilePath = getSocketInfoFilePath(); + if (!socketInfoFilePath) { + return null; + } if (!existsSync(socketInfoFilePath)) { return null; } From 8fff4a5ec24f9a30462ebeb9dbef984086a914a9 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Fri, 20 Mar 2026 16:24:03 -0700 Subject: [PATCH 5/7] fix(next): avoid copying sdk builtins in deferred step routes --- packages/next/src/builder-deferred.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index ddcc0d5547..da77c3552a 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -1827,7 +1827,13 @@ export async function getNextBuilderDeferred() { (file) => !stepFileSet.has(file) ); const builtInStepFilePath = this.resolveBuiltInStepFilePath(); - const copiedStepSourceFiles = builtInStepFilePath + // Keep SDK step sources (including workflow/internal/builtins) imported + // from package context so transitive SDK imports resolve correctly in + // staged/tarball workbenches. + const copiedStepSourceFiles = stepFiles.filter( + (stepFile) => !isWorkflowSdkFile(stepFile) + ); + const manifestStepFiles = builtInStepFilePath ? [ builtInStepFilePath, ...stepFiles.filter((stepFile) => stepFile !== builtInStepFilePath), @@ -1870,7 +1876,7 @@ export async function getNextBuilderDeferred() { const routeContents = [ '// biome-ignore-all lint: generated file', '/* eslint-disable */', - builtInStepFilePath ? '' : "import 'workflow/internal/builtins';", + "import 'workflow/internal/builtins';", copiedStepImports, serdeImports ? `// Serde files for cross-context class registration\n${serdeImports}` @@ -1883,7 +1889,7 @@ export async function getNextBuilderDeferred() { await this.writeFileIfChanged(stepRouteFile, routeContents); const manifest = await this.createDeferredStepsManifest({ - stepFiles: copiedStepSourceFiles, + stepFiles: manifestStepFiles, workflowFiles, serdeOnlyFiles, }); From ae97e1a27c232e6be6e4e4e4d3de6593e70ab9c4 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 21 Mar 2026 15:19:01 -0700 Subject: [PATCH 6/7] fix(next): restore deferred sdk step registration on canary --- packages/core/src/private.ts | 24 +++++ packages/core/src/serialization.test.ts | 11 +++ packages/next/src/builder-deferred.ts | 122 ++++++++++++++++-------- 3 files changed, 118 insertions(+), 39 deletions(-) diff --git a/packages/core/src/private.ts b/packages/core/src/private.ts index 0eabc7b70f..97b028b018 100644 --- a/packages/core/src/private.ts +++ b/packages/core/src/private.ts @@ -16,6 +16,11 @@ export type StepFunction< }; const registeredSteps = new Map(); +const BUILTIN_RESPONSE_STEP_NAMES = new Set([ + '__builtin_response_array_buffer', + '__builtin_response_json', + '__builtin_response_text', +]); function getStepIdAliasCandidates(stepId: string): string[] { const parts = stepId.split('//'); @@ -53,6 +58,20 @@ function getStepIdAliasCandidates(stepId: string): string[] { ); } +function getBuiltinResponseStepAlias(stepId: string): StepFunction | undefined { + if (!BUILTIN_RESPONSE_STEP_NAMES.has(stepId)) { + return undefined; + } + + for (const [registeredStepId, stepFn] of registeredSteps.entries()) { + if (registeredStepId.endsWith(`//${stepId}`)) { + return stepFn; + } + } + + return undefined; +} + /** * Register a step function to be served in the server bundle. * Also sets the stepId property on the function for serialization support. @@ -79,6 +98,11 @@ export function getStepFunction(stepId: string): StepFunction | undefined { } } + const builtinAliasMatch = getBuiltinResponseStepAlias(stepId); + if (builtinAliasMatch) { + return builtinAliasMatch; + } + return undefined; } diff --git a/packages/core/src/serialization.test.ts b/packages/core/src/serialization.test.ts index aee951ca25..b95d83c8e1 100644 --- a/packages/core/src/serialization.test.ts +++ b/packages/core/src/serialization.test.ts @@ -1996,6 +1996,17 @@ describe('step function serialization', () => { expect(retrieved).toBeUndefined(); }); + it('should lookup builtin response step by bare ID alias', () => { + const registeredStepId = + 'step//workflow/internal/builtins@4.2.0-beta.71//__builtin_response_text'; + const stepFn = async () => 'ok'; + + registerStepFunction(registeredStepId, stepFn); + + const retrieved = getStepFunction('__builtin_response_text'); + expect(retrieved).toBe(stepFn); + }); + it('should deserialize step function name through reviver', async () => { const stepName = 'step//test//testStep'; const stepFn = async () => 42; diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index da77c3552a..ab0af84738 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -170,35 +170,25 @@ export async function getNextBuilderDeferred() { } private async resolveImplicitStepFiles(): Promise { + const workflowStdlibPath = this.resolveWorkflowStdlibStepFilePath(); + return workflowStdlibPath ? [workflowStdlibPath] : []; + } + + private resolveWorkflowStdlibStepFilePath(): string | null { let workflowCjsEntry: string; try { workflowCjsEntry = require.resolve('workflow', { paths: [this.config.workingDir], }); } catch { - return []; + return null; } const workflowDistDir = dirname(workflowCjsEntry); const workflowStdlibPath = this.normalizeDiscoveredFilePath( join(workflowDistDir, 'stdlib.js') ); - - const candidatePaths = [workflowStdlibPath]; - const existingFiles = await Promise.all( - candidatePaths.map(async (filePath) => { - try { - const fileStats = await stat(filePath); - return fileStats.isFile() ? filePath : null; - } catch { - return null; - } - }) - ); - - return existingFiles.filter((filePath): filePath is string => - Boolean(filePath) - ); + return existsSync(workflowStdlibPath) ? workflowStdlibPath : null; } private areFileSetsEqual(a: Set, b: Set): boolean { @@ -1642,16 +1632,60 @@ export async function getNextBuilderDeferred() { return Array.from(discoveredSerdeFiles).sort(); } - private resolveBuiltInStepFilePath(): string | null { - try { - return this.normalizeDiscoveredFilePath( - require.resolve('workflow/internal/builtins', { - paths: [this.config.workingDir], - }) - ); - } catch { - return null; + private shouldCopyDeferredSdkStepFile({ + stepFile, + workflowStdlibStepFilePath, + }: { + stepFile: string; + workflowStdlibStepFilePath: string | null; + }): boolean { + if (!workflowStdlibStepFilePath) { + return false; } + return ( + this.normalizeDiscoveredFilePath(stepFile) === + workflowStdlibStepFilePath + ); + } + + private async createResponseBuiltinsStepFile({ + stepsRouteDir, + }: { + stepsRouteDir: string; + }): Promise { + const copiedStepsDir = join(stepsRouteDir, DEFERRED_STEP_COPY_DIR_NAME); + await mkdir(copiedStepsDir, { recursive: true }); + + const responseBuiltinsFilePath = join( + copiedStepsDir, + 'workflow-response-builtins.ts' + ); + const source = [ + 'export async function __builtin_response_array_buffer(this: Request | Response) {', + " 'use step';", + ' return this.arrayBuffer();', + '}', + '', + 'export async function __builtin_response_json(this: Request | Response) {', + " 'use step';", + ' return this.json();', + '}', + '', + 'export async function __builtin_response_text(this: Request | Response) {', + " 'use step';", + ' return this.text();', + '}', + ].join('\n'); + const sourceMapComment = createDeferredStepCopyInlineSourceMapComment({ + sourcePath: responseBuiltinsFilePath, + sourceContent: source, + }); + await this.writeFileIfChanged( + responseBuiltinsFilePath, + `${source}\n${sourceMapComment}\n` + ); + + return responseBuiltinsFilePath; } private async copyDiscoveredStepFiles({ @@ -1826,23 +1860,34 @@ export async function getNextBuilderDeferred() { const serdeOnlyFiles = serdeFiles.filter( (file) => !stepFileSet.has(file) ); - const builtInStepFilePath = this.resolveBuiltInStepFilePath(); - // Keep SDK step sources (including workflow/internal/builtins) imported - // from package context so transitive SDK imports resolve correctly in - // staged/tarball workbenches. + const workflowStdlibStepFilePath = + this.resolveWorkflowStdlibStepFilePath(); + // Keep most SDK step sources imported from package context so transitive + // SDK imports resolve correctly in staged/tarball workbenches. The + // stdlib fetch step is copied so it can still be transformed in step mode. const copiedStepSourceFiles = stepFiles.filter( - (stepFile) => !isWorkflowSdkFile(stepFile) + (stepFile) => + !isWorkflowSdkFile(stepFile) || + this.shouldCopyDeferredSdkStepFile({ + stepFile, + workflowStdlibStepFilePath, + }) ); - const manifestStepFiles = builtInStepFilePath - ? [ - builtInStepFilePath, - ...stepFiles.filter((stepFile) => stepFile !== builtInStepFilePath), - ] - : stepFiles; - const copiedStepFiles = await this.copyDiscoveredStepFiles({ + const copiedDiscoveredStepFiles = await this.copyDiscoveredStepFiles({ stepFiles: copiedStepSourceFiles, stepsRouteDir, }); + const responseBuiltinsStepFilePath = + await this.createResponseBuiltinsStepFile({ + stepsRouteDir, + }); + const copiedStepFiles = [ + responseBuiltinsStepFilePath, + ...copiedDiscoveredStepFiles, + ]; + const manifestStepFiles = Array.from( + new Set([...copiedStepSourceFiles, responseBuiltinsStepFilePath]) + ).sort(); const stepRouteFile = join(stepsRouteDir, routeFileName); const copiedStepImports = copiedStepFiles @@ -1876,7 +1921,6 @@ export async function getNextBuilderDeferred() { const routeContents = [ '// biome-ignore-all lint: generated file', '/* eslint-disable */', - "import 'workflow/internal/builtins';", copiedStepImports, serdeImports ? `// Serde files for cross-context class registration\n${serdeImports}` From a25e1b2a6c4fa03945b053e6ff7aec9bb405e8a8 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Sat, 21 Mar 2026 16:10:47 -0700 Subject: [PATCH 7/7] fix(next): stabilize deferred discovery under canary watch --- packages/builders/src/base-builder.ts | 234 ++++++++++++++------------ packages/next/src/builder-deferred.ts | 148 ++++++++++------ 2 files changed, 226 insertions(+), 156 deletions(-) diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 2ef2610985..c7a8ceb18f 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -594,6 +594,7 @@ export abstract class BaseBuilder { format = 'cjs', outfile, bundleFinalOutput = true, + keepInterimBundleContext = this.config.watch, tsconfigPath, discoveredEntries, }: { @@ -602,6 +603,7 @@ export abstract class BaseBuilder { outfile: string; format?: 'cjs' | 'esm'; bundleFinalOutput?: boolean; + keepInterimBundleContext?: boolean; discoveredEntries?: DiscoveredEntries; }): Promise<{ manifest: WorkflowManifest; @@ -739,55 +741,60 @@ export abstract class BaseBuilder { // - createPseudoPackagePlugin() to handle server-only/client-only with empty modules // - createNodeModuleErrorPlugin() to catch Node.js builtin imports at build time }); - const interimBundle = await interimBundleCtx.rebuild(); - - this.logEsbuildMessages( - interimBundle, - 'intermediate workflow bundle', - true, - { - suppressWarnings: this.config.suppressCreateWorkflowsBundleWarnings, - } - ); - this.logCreateWorkflowsBundleInfo( - 'Created intermediate workflow bundle', - `${Date.now() - bundleStartTime}ms` - ); + let shouldDisposeInterimBundleCtx = !keepInterimBundleContext; + try { + const interimBundle = await interimBundleCtx.rebuild(); - if (this.config.workflowManifestPath) { - const resolvedPath = resolve( - process.cwd(), - this.config.workflowManifestPath + this.logEsbuildMessages( + interimBundle, + 'intermediate workflow bundle', + true, + { + suppressWarnings: this.config.suppressCreateWorkflowsBundleWarnings, + } + ); + this.logCreateWorkflowsBundleInfo( + 'Created intermediate workflow bundle', + `${Date.now() - bundleStartTime}ms` ); - let prefix = ''; - if (resolvedPath.endsWith('.cjs')) { - prefix = 'module.exports = '; - } else if ( - resolvedPath.endsWith('.js') || - resolvedPath.endsWith('.mjs') - ) { - prefix = 'export default '; - } + if (this.config.workflowManifestPath) { + const resolvedPath = resolve( + process.cwd(), + this.config.workflowManifestPath + ); + let prefix = ''; + + if (resolvedPath.endsWith('.cjs')) { + prefix = 'module.exports = '; + } else if ( + resolvedPath.endsWith('.js') || + resolvedPath.endsWith('.mjs') + ) { + prefix = 'export default '; + } - await mkdir(dirname(resolvedPath), { recursive: true }); - await writeFile( - resolvedPath, - prefix + JSON.stringify(workflowManifest, null, 2) - ); - } + await mkdir(dirname(resolvedPath), { recursive: true }); + await writeFile( + resolvedPath, + prefix + JSON.stringify(workflowManifest, null, 2) + ); + } - // Create .gitignore in .swc directory - await this.createSwcGitignore(); + // Create .gitignore in .swc directory + await this.createSwcGitignore(); - if (!interimBundle.outputFiles || interimBundle.outputFiles.length === 0) { - throw new Error('No output files generated from esbuild'); - } + if ( + !interimBundle.outputFiles || + interimBundle.outputFiles.length === 0 + ) { + throw new Error('No output files generated from esbuild'); + } - const bundleFinal = async (interimBundle: string) => { - const workflowBundleCode = interimBundle; + const bundleFinal = async (interimBundle: string) => { + const workflowBundleCode = interimBundle; - const workflowFunctionCode = `// biome-ignore-all lint: generated file + const workflowFunctionCode = `// biome-ignore-all lint: generated file /* eslint-disable */ import { workflowEntrypoint } from 'workflow/runtime'; @@ -795,76 +802,91 @@ const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`; export const POST = workflowEntrypoint(workflowCode);`; - // we skip the final bundling step for Next.js so it can bundle itself - if (!bundleFinalOutput) { - if (!outfile) { - throw new Error(`Invariant: missing outfile for workflow bundle`); + // we skip the final bundling step for Next.js so it can bundle itself + if (!bundleFinalOutput) { + if (!outfile) { + throw new Error(`Invariant: missing outfile for workflow bundle`); + } + // Ensure the output directory exists + const outputDir = dirname(outfile); + await mkdir(outputDir, { recursive: true }); + + // Atomic write: write to temp file then rename to prevent + // file watchers from reading partial file during write + const tempPath = `${outfile}.${randomUUID()}.tmp`; + await writeFile(tempPath, workflowFunctionCode); + await rename(tempPath, outfile); + return; } - // Ensure the output directory exists - const outputDir = dirname(outfile); - await mkdir(outputDir, { recursive: true }); - - // Atomic write: write to temp file then rename to prevent - // file watchers from reading partial file during write - const tempPath = `${outfile}.${randomUUID()}.tmp`; - await writeFile(tempPath, workflowFunctionCode); - await rename(tempPath, outfile); - return; - } - - const bundleStartTime = Date.now(); - // Now bundle this so we can resolve the @workflow/core dependency - // we could remove this if we do nft tracing or similar instead - const finalWorkflowResult = await esbuild.build({ - banner: { - js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n', - }, - stdin: { - contents: workflowFunctionCode, - resolveDir: this.config.workingDir, - sourcefile: 'virtual-entry.js', - loader: 'js', - }, - outfile, - // Source maps for the final workflow bundle wrapper (not important since this code - // doesn't run in the VM - only the intermediate bundle sourcemap is relevant) - sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, - absWorkingDir: this.config.workingDir, - bundle: true, - format, - platform: 'node', - target: 'es2022', - write: true, - keepNames: true, - minify: false, - external: ['@aws-sdk/credential-provider-web-identity'], - }); - - this.logEsbuildMessages( - finalWorkflowResult, - 'final workflow bundle', - true, - { - suppressWarnings: this.config.suppressCreateWorkflowsBundleWarnings, - } - ); - this.logCreateWorkflowsBundleInfo( - 'Created final workflow bundle', - `${Date.now() - bundleStartTime}ms` - ); - }; - await bundleFinal(interimBundle.outputFiles[0].text); + const bundleStartTime = Date.now(); + + // Now bundle this so we can resolve the @workflow/core dependency + // we could remove this if we do nft tracing or similar instead + const finalWorkflowResult = await esbuild.build({ + banner: { + js: '// biome-ignore-all lint: generated file\n/* eslint-disable */\n', + }, + stdin: { + contents: workflowFunctionCode, + resolveDir: this.config.workingDir, + sourcefile: 'virtual-entry.js', + loader: 'js', + }, + outfile, + // Source maps for the final workflow bundle wrapper (not important since this code + // doesn't run in the VM - only the intermediate bundle sourcemap is relevant) + sourcemap: EMIT_SOURCEMAPS_FOR_DEBUGGING, + absWorkingDir: this.config.workingDir, + bundle: true, + format, + platform: 'node', + target: 'es2022', + write: true, + keepNames: true, + minify: false, + external: ['@aws-sdk/credential-provider-web-identity'], + }); - if (this.config.watch) { - return { - manifest: workflowManifest, - interimBundleCtx, - bundleFinal, + this.logEsbuildMessages( + finalWorkflowResult, + 'final workflow bundle', + true, + { + suppressWarnings: this.config.suppressCreateWorkflowsBundleWarnings, + } + ); + this.logCreateWorkflowsBundleInfo( + 'Created final workflow bundle', + `${Date.now() - bundleStartTime}ms` + ); }; + await bundleFinal(interimBundle.outputFiles[0].text); + + if (keepInterimBundleContext) { + shouldDisposeInterimBundleCtx = false; + return { + manifest: workflowManifest, + interimBundleCtx, + bundleFinal, + }; + } + return { manifest: workflowManifest }; + } catch (error) { + shouldDisposeInterimBundleCtx = true; + throw error; + } finally { + if (shouldDisposeInterimBundleCtx) { + try { + await interimBundleCtx.dispose(); + } catch (disposeError) { + console.warn( + 'Warning: Failed to dispose workflow bundle context', + disposeError + ); + } + } } - await interimBundleCtx.dispose(); - return { manifest: workflowManifest }; } /** diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index ab0af84738..34f59d0e3e 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -132,28 +132,31 @@ export async function getNextBuilderDeferred() { return; } - let didBuildSucceed = false; try { await this.buildDiscoveredFiles(inputFiles, implicitStepFiles); - didBuildSucceed = true; } catch (error) { if (this.config.watch) { + await this.validateDiscoveredEntryFiles(); + const recoveredInputFiles = + this.getCurrentInputFiles(implicitStepFiles); + const recoveredSignature = + await this.createDeferredBuildSignature(recoveredInputFiles); + if (recoveredSignature !== buildSignature) { + // A file was added/removed while this build was running; retry + // immediately with the refreshed discovered-entry state. + continue; + } console.warn( '[workflow] Deferred entries build failed. Will retry only after inputs change.', error ); + this.lastDeferredBuildSignature = buildSignature; + return; } else { throw error; } - } finally { - // Record attempted signature even on failure so we don't loop on the - // same broken input graph. - this.lastDeferredBuildSignature = buildSignature; - } - - if (!didBuildSucceed) { - return; } + this.lastDeferredBuildSignature = buildSignature; const postBuildInputFiles = this.getCurrentInputFiles(implicitStepFiles); @@ -191,20 +194,6 @@ export async function getNextBuilderDeferred() { return existsSync(workflowStdlibPath) ? workflowStdlibPath : null; } - private areFileSetsEqual(a: Set, b: Set): boolean { - if (a.size !== b.size) { - return false; - } - - for (const filePath of a) { - if (!b.has(filePath)) { - return false; - } - } - - return true; - } - private async reconcileDiscoveredEntries({ workflowCandidates, stepCandidates, @@ -331,38 +320,59 @@ export async function getNextBuilderDeferred() { } private async validateDiscoveredEntryFiles(): Promise { + const workflowCandidates = new Set(this.discoveredWorkflowFiles); + const stepCandidates = new Set(this.discoveredStepFiles); + const serdeCandidates = new Set(this.discoveredSerdeFiles); const { workflowFiles, stepFiles, serdeFiles } = await this.reconcileDiscoveredEntries({ - workflowCandidates: this.discoveredWorkflowFiles, - stepCandidates: this.discoveredStepFiles, - serdeCandidates: this.discoveredSerdeFiles, + workflowCandidates, + stepCandidates, + serdeCandidates, validatePatterns: true, }); - const workflowsChanged = !this.areFileSetsEqual( - this.discoveredWorkflowFiles, - workflowFiles - ); - const stepsChanged = !this.areFileSetsEqual( - this.discoveredStepFiles, - stepFiles - ); - const serdeChanged = !this.areFileSetsEqual( - this.discoveredSerdeFiles, - serdeFiles - ); - if (workflowsChanged || stepsChanged || serdeChanged) { - this.discoveredWorkflowFiles.clear(); - this.discoveredStepFiles.clear(); - this.discoveredSerdeFiles.clear(); - for (const filePath of workflowFiles) { + // Reconcile validated entries against the snapshot we started with so + // file discoveries that arrive during validation are preserved. + let workflowsChanged = false; + let stepsChanged = false; + let serdeChanged = false; + + for (const filePath of workflowCandidates) { + if (!workflowFiles.has(filePath)) { + workflowsChanged = + this.discoveredWorkflowFiles.delete(filePath) || workflowsChanged; + } + } + for (const filePath of workflowFiles) { + if (!this.discoveredWorkflowFiles.has(filePath)) { this.discoveredWorkflowFiles.add(filePath); + workflowsChanged = true; + } + } + + for (const filePath of stepCandidates) { + if (!stepFiles.has(filePath)) { + stepsChanged = + this.discoveredStepFiles.delete(filePath) || stepsChanged; } - for (const filePath of stepFiles) { + } + for (const filePath of stepFiles) { + if (!this.discoveredStepFiles.has(filePath)) { this.discoveredStepFiles.add(filePath); + stepsChanged = true; + } + } + + for (const filePath of serdeCandidates) { + if (!serdeFiles.has(filePath)) { + serdeChanged = + this.discoveredSerdeFiles.delete(filePath) || serdeChanged; } - for (const filePath of serdeFiles) { + } + for (const filePath of serdeFiles) { + if (!this.discoveredSerdeFiles.has(filePath)) { this.discoveredSerdeFiles.add(filePath); + serdeChanged = true; } } @@ -383,14 +393,14 @@ export async function getNextBuilderDeferred() { const tempRouteFileName = 'route.js.temp'; const trackedDiscoveredEntries = await this.collectTrackedDiscoveredEntries(); - const discoveredStepFiles = Array.from( + const discoveredStepFileCandidates = Array.from( new Set([ ...this.discoveredStepFiles, ...trackedDiscoveredEntries.discoveredSteps, ...implicitStepFiles, ]) ).sort(); - const discoveredWorkflowFiles = Array.from( + const discoveredWorkflowFileCandidates = Array.from( new Set([ ...this.discoveredWorkflowFiles, ...trackedDiscoveredEntries.discoveredWorkflows, @@ -402,18 +412,28 @@ export async function getNextBuilderDeferred() { ...trackedDiscoveredEntries.discoveredSerdeFiles, ]) ).sort(); + const discoveredStepFiles = await this.filterExistingFiles( + discoveredStepFileCandidates + ); + const discoveredWorkflowFiles = await this.filterExistingFiles( + discoveredWorkflowFileCandidates + ); + const existingSerdeFileCandidates = await this.filterExistingFiles( + discoveredSerdeFileCandidates + ); const discoveredSerdeFiles = await this.collectTransitiveSerdeFiles({ entryFiles: [...discoveredStepFiles, ...discoveredWorkflowFiles], - serdeFiles: discoveredSerdeFileCandidates, + serdeFiles: existingSerdeFileCandidates, }); const discoveredEntries = { discoveredSteps: discoveredStepFiles, discoveredWorkflows: discoveredWorkflowFiles, discoveredSerdeFiles, }; + const existingInputFiles = await this.filterExistingFiles(inputFiles); const buildInputFiles = Array.from( new Set([ - ...inputFiles, + ...existingInputFiles, ...discoveredStepFiles, ...discoveredWorkflowFiles, ...discoveredSerdeFiles, @@ -683,6 +703,31 @@ export async function getNextBuilderDeferred() { : resolve(this.config.workingDir, filePath); } + private async filterExistingFiles(filePaths: string[]): Promise { + const normalizedFilePaths = Array.from( + new Set( + filePaths.map((filePath) => + this.normalizeDiscoveredFilePath(filePath) + ) + ) + ).sort(); + + const existingFiles = await Promise.all( + normalizedFilePaths.map(async (filePath) => { + try { + const fileStats = await stat(filePath); + return fileStats.isFile() ? filePath : null; + } catch { + return null; + } + }) + ); + + return existingFiles.filter((filePath): filePath is string => + Boolean(filePath) + ); + } + private async createDeferredBuildSignature( inputFiles: string[] ): Promise { @@ -1963,6 +2008,9 @@ export async function getNextBuilderDeferred() { format: 'esm', outfile: join(workflowsRouteDir, routeFileName), bundleFinalOutput: false, + // Deferred builds do not reuse the interim esbuild context. Dispose it + // after each pass to avoid leaking contexts during watch-mode rebuilds. + keepInterimBundleContext: false, inputFiles, tsconfigPath, discoveredEntries,