From 4c260397e9d70543d1c6a806612a20f0f8d11e59 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 7 Apr 2026 13:41:04 -0700 Subject: [PATCH 1/8] fix(next): skip eager discovery in deferred build mode --- .changeset/slow-bottles-pull.md | 5 +++ packages/core/e2e/local-build.test.ts | 17 +++++++ packages/next/src/builder-deferred.ts | 64 +-------------------------- 3 files changed, 24 insertions(+), 62 deletions(-) create mode 100644 .changeset/slow-bottles-pull.md diff --git a/.changeset/slow-bottles-pull.md b/.changeset/slow-bottles-pull.md new file mode 100644 index 0000000000..4938f195a8 --- /dev/null +++ b/.changeset/slow-bottles-pull.md @@ -0,0 +1,5 @@ +--- +'@workflow/next': patch +--- + +Stop eager input-graph directive discovery in deferred Next.js builds and rely on loader/socket-driven discovery with `onBeforeDeferredEntries`. diff --git a/packages/core/e2e/local-build.test.ts b/packages/core/e2e/local-build.test.ts index 8620226149..a59e839a7d 100644 --- a/packages/core/e2e/local-build.test.ts +++ b/packages/core/e2e/local-build.test.ts @@ -83,6 +83,14 @@ const ESM_STEP_BUNDLE_PROJECTS: Record = { '.vercel/output/functions/.well-known/workflow/v1/step.func/index.mjs', }; +const DEFERRED_BUILD_MODE_PROJECTS = new Set([ + 'nextjs-webpack', + 'nextjs-turbopack', +]); +const DEFERRED_BUILD_UNSUPPORTED_WARNING = + 'Enabled lazyDiscovery but Next.js version is not compatible'; +const EAGER_DISCOVERY_LOG = 'Discovering workflow directives'; + describe.each([ 'example', 'nextjs-webpack', @@ -111,6 +119,15 @@ describe.each([ expect(result.output).not.toContain('Error:'); + if (DEFERRED_BUILD_MODE_PROJECTS.has(project)) { + const deferredBuildSupported = !result.output.includes( + DEFERRED_BUILD_UNSUPPORTED_WARNING + ); + if (deferredBuildSupported) { + expect(result.output).not.toContain(EAGER_DISCOVERY_LOG); + } + } + if (usesVercelWorld()) { const diagnosticsManifestPath = path.join( getWorkbenchAppPath(project), diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 16573f712d..50c56290d9 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -716,7 +716,8 @@ export async function getNextBuilderDeferred() { } await this.loadWorkflowsCache(); - await this.loadDiscoveredEntriesFromInputGraph(); + // Deferred mode must not run eager input-graph discovery; entries are + // discovered via loader->socket notifications during Next's build. this.cacheInitialized = true; } @@ -1025,47 +1026,6 @@ 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'); @@ -1122,26 +1082,6 @@ export async function getNextBuilderDeferred() { ); } - protected async getInputFiles(): Promise { - const inputFiles = await super.getInputFiles(); - return inputFiles.filter((item) => { - // Match App Router entrypoints: route.ts, page.ts, layout.ts in app/ or src/app/ directories - // Matches: /app/page.ts, /app/dashboard/page.ts, /src/app/route.ts, etc. - if ( - item.match( - /(^|.*[/\\])(app|src[/\\]app)([/\\](route|page|layout)\.|[/\\].*[/\\](route|page|layout)\.)/ - ) - ) { - return true; - } - // Match Pages Router entrypoints: files in pages/ or src/pages/ - if (item.match(/[/\\](pages|src[/\\]pages)[/\\]/)) { - return true; - } - return false; - }); - } - private async writeFunctionsConfig(outputDir: string) { // we don't run this in development mode as it's not needed if (process.env.NODE_ENV === 'development') { From be858fcfed02028f04462e07b2ccc38a1df3692b Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 7 Apr 2026 14:17:44 -0700 Subject: [PATCH 2/8] fix(next): register deferred package steps on stable builds --- packages/next/src/builder-deferred.ts | 115 +++++++++++++++++++++----- 1 file changed, 95 insertions(+), 20 deletions(-) diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 50c56290d9..f42549bf0b 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -503,9 +503,11 @@ export async function getNextBuilderDeferred() { discoveredEntries, }; - const { manifest: stepsManifest } = - await this.buildStepsFunction(options); const workflowsBundle = await this.buildWorkflowsFunction(options); + const { manifest: stepsManifest } = await this.buildStepsFunction({ + ...options, + additionalStepSourceManifest: workflowsBundle?.manifest, + }); await this.buildWebhookRoute({ workflowGeneratedDir, routeFileName: tempRouteFileName, @@ -744,9 +746,14 @@ export async function getNextBuilderDeferred() { } private normalizeDiscoveredFilePath(filePath: string): string { - return isAbsolute(filePath) + const resolvedPath = isAbsolute(filePath) ? filePath : resolve(this.config.workingDir, filePath); + try { + return realpathSync(resolvedPath); + } catch { + return resolvedPath; + } } private async filterExistingFiles(filePaths: string[]): Promise { @@ -1448,11 +1455,18 @@ export async function getNextBuilderDeferred() { return Array.from(relativeSpecifiers); } - private shouldSkipTransitiveStepFile(filePath: string): boolean { + private isGeneratedWorkflowArtifact(filePath: string): boolean { const normalizedPath = filePath.replace(/\\/g, '/'); return ( normalizedPath.includes('/.well-known/workflow/') || - normalizedPath.includes('/.next/') || + normalizedPath.includes('/.next/') + ); + } + + private shouldSkipTransitiveStepFile(filePath: string): boolean { + const normalizedPath = filePath.replace(/\\/g, '/'); + return ( + this.isGeneratedWorkflowArtifact(normalizedPath) || normalizedPath.includes('/node_modules/') || normalizedPath.includes('/.pnpm/') ); @@ -1800,9 +1814,11 @@ export async function getNextBuilderDeferred() { private async copyDiscoveredStepFiles({ stepFiles, stepsRouteDir, + preserveFileNames = [], }: { stepFiles: string[]; stepsRouteDir: string; + preserveFileNames?: string[]; }): Promise { const copiedStepsDir = join(stepsRouteDir, DEFERRED_STEP_COPY_DIR_NAME); await mkdir(copiedStepsDir, { recursive: true }); @@ -1815,7 +1831,7 @@ export async function getNextBuilderDeferred() { ) ).sort(); const copiedStepFileBySourcePath = new Map(); - const expectedFileNames = new Set(); + const expectedFileNames = new Set(preserveFileNames); const copiedStepFiles: string[] = []; for (const normalizedStepFile of normalizedStepFiles) { @@ -1945,14 +1961,57 @@ export async function getNextBuilderDeferred() { return workflowManifest; } + private async collectManifestStepSourceFiles( + manifest: WorkflowManifest + ): Promise { + const manifestStepEntries = Object.keys(manifest.steps || {}); + if (manifestStepEntries.length === 0) { + return []; + } + + const candidateFiles = manifestStepEntries + .map((stepEntry) => + this.normalizeDiscoveredFilePath( + isAbsolute(stepEntry) + ? stepEntry + : resolve(this.config.workingDir, stepEntry) + ) + ) + .filter( + (candidateFile) => !this.isGeneratedWorkflowArtifact(candidateFile) + ); + const existingCandidates = await this.filterExistingFiles(candidateFiles); + const manifestStepFiles = await Promise.all( + existingCandidates.map(async (candidateFile) => { + try { + const source = await readFile(candidateFile, 'utf-8'); + const patterns = detectWorkflowPatterns(source); + return patterns.hasUseStep ? candidateFile : null; + } catch { + return null; + } + }) + ); + + return Array.from( + new Set( + manifestStepFiles.filter((candidate): candidate is string => + Boolean(candidate) + ) + ) + ).sort(); + } + private async buildStepsFunction({ workflowGeneratedDir, routeFileName = 'route.js', discoveredEntries, + additionalStepSourceManifest, }: { workflowGeneratedDir: string; routeFileName?: string; discoveredEntries: DeferredDiscoveredEntries; + additionalStepSourceManifest?: WorkflowManifest; }) { const stepsRouteDir = join(workflowGeneratedDir, 'step'); await mkdir(stepsRouteDir, { recursive: true }); @@ -1969,25 +2028,47 @@ export async function getNextBuilderDeferred() { const serdeOnlyFiles = serdeFiles.filter( (file) => !stepFileSet.has(file) ); + const additionalManifestStepFiles = additionalStepSourceManifest + ? await this.collectManifestStepSourceFiles( + additionalStepSourceManifest + ) + : []; + const stepFilesWithManifestSources = Array.from( + new Set([...stepFiles, ...additionalManifestStepFiles]) + ).sort(); + const responseBuiltinsStepFilePath = + await this.createResponseBuiltinsStepFile({ + stepsRouteDir, + }); + const manifestStepFiles = Array.from( + new Set([...stepFilesWithManifestSources, responseBuiltinsStepFilePath]) + ).sort(); + const manifest = await this.createDeferredStepsManifest({ + stepFiles: manifestStepFiles, + workflowFiles, + serdeOnlyFiles, + }); + + const manifestDiscoveredStepFiles = + await this.collectManifestStepSourceFiles(manifest); // Copy all discovered step sources so they are transformed in step mode. // Importing raw node_modules files directly can bypass loader transforms, // which prevents step registrars from being emitted. - const copiedStepSourceFiles = stepFiles; + const copiedStepSourceFiles = Array.from( + new Set([ + ...stepFilesWithManifestSources, + ...manifestDiscoveredStepFiles, + ]) + ).sort(); const copiedDiscoveredStepFiles = await this.copyDiscoveredStepFiles({ stepFiles: copiedStepSourceFiles, stepsRouteDir, + preserveFileNames: [basename(responseBuiltinsStepFilePath)], }); - const responseBuiltinsStepFilePath = - await this.createResponseBuiltinsStepFile({ - stepsRouteDir, - }); const copiedStepFiles = [ responseBuiltinsStepFilePath, ...copiedDiscoveredStepFiles, ]; - const manifestStepFiles = Array.from( - new Set([...stepFiles, responseBuiltinsStepFilePath]) - ).sort(); const stepRouteFile = join(stepsRouteDir, routeFileName); const copiedStepImports = copiedStepFiles @@ -2032,12 +2113,6 @@ export async function getNextBuilderDeferred() { await this.writeFileIfChanged(stepRouteFile, routeContents); - const manifest = await this.createDeferredStepsManifest({ - stepFiles: manifestStepFiles, - workflowFiles, - serdeOnlyFiles, - }); - return { context: undefined, manifest, From ab27eaddbd04863c7561cff9ef881b554490eb0c Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 7 Apr 2026 14:42:24 -0700 Subject: [PATCH 3/8] fix(next): include manifest package steps in deferred dev builds --- packages/core/e2e/dev.test.ts | 57 +++++++++++++++++++++++++++ packages/next/src/builder-deferred.ts | 28 +++++++++---- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 5efab8ce22..6d89c2d8b2 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -412,6 +412,63 @@ ${apiFileContent}` }); } ); + + test.skipIf(!supportsDeferredStepCopies)( + 'should copy package step sources discovered via manifest entries', + { timeout: 30_000 }, + async () => { + const workflowManifestPath = path.join( + appPath, + 'app/.well-known/workflow/v1/manifest.json' + ); + const copiedStepDir = path.join( + path.dirname(generatedStep), + '__workflow_step_files__' + ); + + await pollUntil({ + description: + 'copied deferred step files to include @workflow/ai package steps', + timeoutMs: 25_000, + check: async () => { + await fetchWithTimeout('/api/chat'); + const manifestJson = await fs.readFile( + workflowManifestPath, + 'utf8' + ); + const manifest = JSON.parse(manifestJson) as { + steps?: Record; + }; + const manifestStepFiles = Object.keys(manifest.steps || {}); + expect( + manifestStepFiles.some((filePath) => + filePath.includes('ai/dist/agent/durable-agent.js') + ) + ).toBe(true); + + const copiedStepFileNames = await fs.readdir(copiedStepDir); + const copiedStepContents = await Promise.all( + copiedStepFileNames.map(async (copiedStepFileName) => { + const copiedStepFilePath = path.join( + copiedStepDir, + copiedStepFileName + ); + const copiedStepStats = await fs.stat(copiedStepFilePath); + if (!copiedStepStats.isFile()) { + return ''; + } + return await fs.readFile(copiedStepFilePath, 'utf8'); + }) + ); + expect( + copiedStepContents.some((content) => + content.includes('async function closeStream') + ) + ).toBe(true); + }, + }); + } + ); }); } diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index f42549bf0b..0b836b0fd9 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -1970,13 +1970,27 @@ export async function getNextBuilderDeferred() { } const candidateFiles = manifestStepEntries - .map((stepEntry) => - this.normalizeDiscoveredFilePath( - isAbsolute(stepEntry) - ? stepEntry - : resolve(this.config.workingDir, stepEntry) - ) - ) + .flatMap((stepEntry) => { + if (isAbsolute(stepEntry)) { + return [this.normalizeDiscoveredFilePath(stepEntry)]; + } + const resolveBaseDirs = new Set(); + if (this.config.projectRoot) { + resolveBaseDirs.add(this.config.projectRoot); + } + let currentResolveDir = this.config.workingDir; + while (currentResolveDir) { + resolveBaseDirs.add(currentResolveDir); + const parentResolveDir = dirname(currentResolveDir); + if (parentResolveDir === currentResolveDir) { + break; + } + currentResolveDir = parentResolveDir; + } + return Array.from(resolveBaseDirs).map((baseDir) => + this.normalizeDiscoveredFilePath(resolve(baseDir, stepEntry)) + ); + }) .filter( (candidateFile) => !this.isGeneratedWorkflowArtifact(candidateFile) ); From 13ddeb2f023045f1512d5c342f4dd7e105173b55 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 7 Apr 2026 15:02:22 -0700 Subject: [PATCH 4/8] perf(next): reduce deferred manifest step discovery overhead --- packages/next/src/builder-deferred.ts | 74 +++++++++++++++------------ 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 0b836b0fd9..306e4785c5 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -122,6 +122,7 @@ export async function getNextBuilderDeferred() { private cjsSyncResolver?: ReturnType< typeof enhancedResolveOrig.create.sync >; + private manifestStepResolveBaseDirs: string[] | null = null; async build() { const outputDir = await this.findAppDirectory(); @@ -756,6 +757,43 @@ export async function getNextBuilderDeferred() { } } + private getManifestStepResolveBaseDirs(): string[] { + if (this.manifestStepResolveBaseDirs) { + return this.manifestStepResolveBaseDirs; + } + + const resolveBaseDirs = new Set(); + if (this.config.projectRoot) { + resolveBaseDirs.add( + this.normalizeDiscoveredFilePath(this.config.projectRoot) + ); + } + const normalizedWorkingDir = this.normalizeDiscoveredFilePath( + this.config.workingDir + ); + resolveBaseDirs.add(normalizedWorkingDir); + + let currentResolveDir = normalizedWorkingDir; + while (true) { + if ( + existsSync(join(currentResolveDir, 'pnpm-workspace.yaml')) || + existsSync(join(currentResolveDir, 'turbo.json')) || + existsSync(join(currentResolveDir, '.git')) + ) { + resolveBaseDirs.add(currentResolveDir); + break; + } + const parentResolveDir = dirname(currentResolveDir); + if (parentResolveDir === currentResolveDir) { + break; + } + currentResolveDir = parentResolveDir; + } + + this.manifestStepResolveBaseDirs = Array.from(resolveBaseDirs); + return this.manifestStepResolveBaseDirs; + } + private async filterExistingFiles(filePaths: string[]): Promise { const normalizedFilePaths = Array.from( new Set( @@ -1969,25 +2007,13 @@ export async function getNextBuilderDeferred() { return []; } + const resolveBaseDirs = this.getManifestStepResolveBaseDirs(); const candidateFiles = manifestStepEntries .flatMap((stepEntry) => { if (isAbsolute(stepEntry)) { return [this.normalizeDiscoveredFilePath(stepEntry)]; } - const resolveBaseDirs = new Set(); - if (this.config.projectRoot) { - resolveBaseDirs.add(this.config.projectRoot); - } - let currentResolveDir = this.config.workingDir; - while (currentResolveDir) { - resolveBaseDirs.add(currentResolveDir); - const parentResolveDir = dirname(currentResolveDir); - if (parentResolveDir === currentResolveDir) { - break; - } - currentResolveDir = parentResolveDir; - } - return Array.from(resolveBaseDirs).map((baseDir) => + return resolveBaseDirs.map((baseDir) => this.normalizeDiscoveredFilePath(resolve(baseDir, stepEntry)) ); }) @@ -1995,25 +2021,7 @@ export async function getNextBuilderDeferred() { (candidateFile) => !this.isGeneratedWorkflowArtifact(candidateFile) ); const existingCandidates = await this.filterExistingFiles(candidateFiles); - const manifestStepFiles = await Promise.all( - existingCandidates.map(async (candidateFile) => { - try { - const source = await readFile(candidateFile, 'utf-8'); - const patterns = detectWorkflowPatterns(source); - return patterns.hasUseStep ? candidateFile : null; - } catch { - return null; - } - }) - ); - - return Array.from( - new Set( - manifestStepFiles.filter((candidate): candidate is string => - Boolean(candidate) - ) - ) - ).sort(); + return Array.from(new Set(existingCandidates)).sort(); } private async buildStepsFunction({ From db881e08a9f79d2245cf47497ade0a59d18019b9 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 7 Apr 2026 23:22:10 -0700 Subject: [PATCH 5/8] fix(next): remove deferred debug logs in deferred builder/loader --- packages/next/src/builder-deferred.ts | 60 ++++++- packages/next/src/loader.ts | 242 ++++++++++++++++++++++++-- 2 files changed, 277 insertions(+), 25 deletions(-) diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 306e4785c5..5e8217c254 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -176,7 +176,12 @@ export async function getNextBuilderDeferred() { const inputFiles = this.getCurrentInputFiles(implicitStepFiles); const buildSignature = await this.createDeferredBuildSignature(inputFiles); - if (buildSignature === this.lastDeferredBuildSignature) { + const shouldForceBuildForGeneratedRoutes = + await this.shouldForceBuildForGeneratedRoutes(); + if ( + buildSignature === this.lastDeferredBuildSignature && + !shouldForceBuildForGeneratedRoutes + ) { return; } @@ -225,6 +230,51 @@ export async function getNextBuilderDeferred() { return workflowStdlibPath ? [workflowStdlibPath] : []; } + private async shouldForceBuildForGeneratedRoutes(): Promise { + const outputDir = await this.findAppDirectory(); + const generatedRouteFiles = [ + join(outputDir, '.well-known/workflow/v1/flow/route.js'), + join(outputDir, '.well-known/workflow/v1/step/route.js'), + join(outputDir, '.well-known/workflow/v1/webhook/[token]/route.js'), + ]; + + for (const routeFilePath of generatedRouteFiles) { + const routeState = await this.getGeneratedRouteState(routeFilePath); + if (routeState === 'missing') { + return true; + } + if (routeState === 'stub') { + return true; + } + } + + return false; + } + + private async getGeneratedRouteState( + routeFilePath: string + ): Promise<'missing' | 'stub' | 'generated'> { + let routeStats; + try { + routeStats = await stat(routeFilePath); + } catch { + return 'missing'; + } + if (!routeStats.isFile()) { + return 'missing'; + } + if (routeStats.size > 1024) { + return 'generated'; + } + + try { + const source = await readFile(routeFilePath, 'utf-8'); + return source.includes(ROUTE_STUB_FILE_MARKER) ? 'stub' : 'generated'; + } catch { + return 'missing'; + } + } + private resolveWorkflowStdlibStepFilePath(): string | null { let workflowCjsEntry: string; try { @@ -695,13 +745,7 @@ export async function getNextBuilderDeferred() { this.scheduleWorkflowsCacheWrite(); } - if ( - hasWorkflow || - hasStep || - hasSerde || - hasCacheTrackingChange || - wasTrackedDependency - ) { + if (hasCacheTrackingChange || wasTrackedDependency) { this.scheduleDeferredRebuild(); } }, diff --git a/packages/next/src/loader.ts b/packages/next/src/loader.ts index f3561df0e6..dd9f9f5738 100644 --- a/packages/next/src/loader.ts +++ b/packages/next/src/loader.ts @@ -3,7 +3,11 @@ import { readFile } from 'node:fs/promises'; import { connect, type Socket } from 'node:net'; import { dirname, join, relative } from 'node:path'; import { transform } from '@swc/core'; -import { type SocketMessage, serializeMessage } from './socket-server.js'; +import { + parseMessage, + type SocketMessage, + serializeMessage, +} from './socket-server.js'; import { DEFERRED_STEP_SOURCE_METADATA_PREFIX, isDeferredStepCopyFilePath, @@ -28,6 +32,16 @@ type LoaderStaticDependencies = { }; let cachedLoaderStaticDependencies: LoaderStaticDependencies | null = null; +type DiscoveredPatternState = { + hasWorkflow: boolean; + hasStep: boolean; + hasSerde: boolean; +}; +const discoveredPatternStateByFilePath = new Map< + string, + DiscoveredPatternState +>(); + // Cache socket connection to avoid reconnecting on every file. let socketClientPromise: Promise | null = null; let socketClient: Socket | null = null; @@ -38,6 +52,10 @@ type SocketCredentials = { authToken: string; }; +const ROUTE_STUB_FILE_MARKER = 'WORKFLOW_ROUTE_STUB_FILE'; +const ROUTE_STUB_BUILD_WAIT_TIMEOUT_MS = 120_000; +let pendingDeferredRouteStubBuildPromise: Promise | null = null; + function registerFileDependency( loaderContext: WorkflowLoaderContext, dependencyPath: string @@ -46,6 +64,35 @@ function registerFileDependency( loaderContext.addBuildDependency?.(dependencyPath); } +function updateDiscoveredPatternState( + filePath: string, + nextState: DiscoveredPatternState +): { shouldNotify: boolean; previousState?: DiscoveredPatternState } { + const previousState = discoveredPatternStateByFilePath.get(filePath); + const hasAnyPattern = + nextState.hasWorkflow || nextState.hasStep || nextState.hasSerde; + + if (!hasAnyPattern) { + if (!previousState) { + return { shouldNotify: false }; + } + discoveredPatternStateByFilePath.delete(filePath); + return { shouldNotify: true, previousState }; + } + + if ( + previousState && + previousState.hasWorkflow === nextState.hasWorkflow && + previousState.hasStep === nextState.hasStep && + previousState.hasSerde === nextState.hasSerde + ) { + return { shouldNotify: false, previousState }; + } + + discoveredPatternStateByFilePath.set(filePath, nextState); + return { shouldNotify: true, previousState }; +} + function addIfExists(files: Set, dependencyPath: string): void { if (existsSync(dependencyPath)) { files.add(dependencyPath); @@ -127,11 +174,20 @@ async function writeSocketMessage( function getSocketInfoFilePath(): string | null { const configuredPath = process.env.WORKFLOW_SOCKET_INFO_PATH; - if (!configuredPath) { - return null; + if (configuredPath) { + return configuredPath; } - return configuredPath; + // Fallback for worker processes that don't inherit dynamic env updates + // from the process that created the socket server. + const distDir = process.env.WORKFLOW_NEXT_DIST_DIR || '.next'; + const fallbackPath = join( + process.cwd(), + distDir, + 'cache', + 'workflow-socket.json' + ); + return fallbackPath; } function getSocketCredentialsFromEnv(): SocketCredentials | null { @@ -145,7 +201,6 @@ function getSocketCredentialsFromEnv(): SocketCredentials | null { if (Number.isNaN(port)) { return null; } - return { port, authToken }; } @@ -174,20 +229,22 @@ async function getSocketCredentialsFromFile(): Promise if (!authToken || Number.isNaN(numericPort)) { return null; } - return { port: numericPort, authToken, }; - } catch { + } catch (error) { return null; } } async function getSocketCredentials(): Promise { - return ( - getSocketCredentialsFromEnv() ?? (await getSocketCredentialsFromFile()) - ); + const fileCredentials = await getSocketCredentialsFromFile(); + if (fileCredentials) { + return fileCredentials; + } + const envCredentials = getSocketCredentialsFromEnv(); + return envCredentials; } async function getSocketClient(): Promise { @@ -259,7 +316,6 @@ async function getSocketClient(): Promise { } })(); } - return socketClientPromise; } @@ -303,6 +359,127 @@ async function notifySocketServer( } } +function isWorkflowRouteStubSource(source: string): boolean { + return source.includes(ROUTE_STUB_FILE_MARKER); +} + +async function createSocketConnection( + socketCredentials: SocketCredentials, + timeoutMs = 1_000 +): Promise { + return await new Promise((resolve, reject) => { + const socket = connect({ + port: socketCredentials.port, + host: '127.0.0.1', + }); + const timeout = setTimeout(() => { + cleanup(); + socket.destroy(); + reject(new Error('Socket connection timeout')); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timeout); + socket.off('connect', onConnect); + socket.off('error', onError); + }; + const onConnect = () => { + socket.setNoDelay(true); + cleanup(); + resolve(socket); + }; + const onError = (error: Error) => { + cleanup(); + socket.destroy(); + reject(error); + }; + + socket.on('connect', onConnect); + socket.on('error', onError); + }); +} + +async function waitForDeferredBuildComplete( + socket: Socket, + authToken: string, + timeoutMs = ROUTE_STUB_BUILD_WAIT_TIMEOUT_MS +): Promise { + await new Promise((resolve, reject) => { + let buffer = ''; + const timeout = setTimeout(() => { + cleanup(); + reject( + new Error('Timed out waiting for deferred route build completion') + ); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timeout); + socket.off('data', onData); + socket.off('error', onError); + socket.off('close', onClose); + socket.off('end', onClose); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onClose = () => { + cleanup(); + reject(new Error('Socket closed before deferred route build completed')); + }; + const onData = (chunk: Buffer) => { + buffer += chunk.toString(); + let newlineIndex = buffer.indexOf('\n'); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf('\n'); + + const message = parseMessage(line, authToken); + if (message?.type === 'build-complete') { + cleanup(); + resolve(); + return; + } + } + }; + + socket.on('data', onData); + socket.on('error', onError); + socket.on('close', onClose); + socket.on('end', onClose); + }); +} + +async function triggerDeferredRouteStubBuildAndWait(): Promise { + const socketCredentials = await getSocketCredentials(); + if (!socketCredentials) { + return; + } + const socket = await createSocketConnection(socketCredentials); + try { + await writeSocketMessage( + socket, + serializeMessage({ type: 'trigger-build' }, socketCredentials.authToken) + ); + await waitForDeferredBuildComplete(socket, socketCredentials.authToken); + } finally { + socket.destroy(); + } +} + +async function ensureDeferredRouteStubBuildAndWait(): Promise { + if (pendingDeferredRouteStubBuildPromise) { + return pendingDeferredRouteStubBuildPromise; + } + const pendingPromise = triggerDeferredRouteStubBuildAndWait(); + pendingDeferredRouteStubBuildPromise = pendingPromise.finally(() => { + if (pendingDeferredRouteStubBuildPromise === pendingPromise) { + pendingDeferredRouteStubBuildPromise = null; + } + }); + return pendingDeferredRouteStubBuildPromise; +} + async function getBuildersModule(): Promise< typeof import('@workflow/builders') > { @@ -473,8 +650,27 @@ export default function workflowLoader( registerFileDependency(this, deferredStepSourceMetadata.absolutePath); } - // Skip generated workflow route files to avoid re-processing them - if ((await checkGeneratedFile(filename)) && !isDeferredStepCopyFile) { + const isGeneratedWorkflowFile = await checkGeneratedFile(filename); + // Skip generated workflow route files to avoid re-processing them, except + // deferred route stubs which must wait for generated route output. + if (isGeneratedWorkflowFile && !isDeferredStepCopyFile) { + if ( + process.env.WORKFLOW_NEXT_LAZY_DISCOVERY === '1' && + isWorkflowRouteStubSource(normalizedSource) + ) { + try { + await ensureDeferredRouteStubBuildAndWait(); + const refreshedSource = await readFile(filename, 'utf8'); + if (!isWorkflowRouteStubSource(refreshedSource)) { + return { code: refreshedSource, map: sourceMap }; + } + } catch (error) { + console.warn( + `[workflow] Failed waiting for deferred route build for ${filename}, using stub output`, + error + ); + } + } return { code: normalizedSource, map: sourceMap }; } @@ -485,12 +681,24 @@ export default function workflowLoader( // Deferred step copy files must report using their original source path so // deferred rebuilds can react to source edits outside generated artifacts. if (!isDeferredStepCopyFile || deferredStepSourceMetadata?.absolutePath) { - await notifySocketServer( + const hasSerde = patterns.hasSerde; + const nextPatternState: DiscoveredPatternState = { + hasWorkflow: patterns.hasUseWorkflow, + hasStep: patterns.hasUseStep, + hasSerde, + }; + const { shouldNotify } = updateDiscoveredPatternState( discoveryFilePath, - patterns.hasUseWorkflow, - patterns.hasUseStep, - patterns.hasSerde + nextPatternState ); + if (shouldNotify) { + await notifySocketServer( + discoveryFilePath, + nextPatternState.hasWorkflow, + nextPatternState.hasStep, + nextPatternState.hasSerde + ); + } } if (!isDeferredStepCopyFile) { From eb1fa37071942a1842763b6d99a14b3030f11e53 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 9 Apr 2026 11:53:24 -0700 Subject: [PATCH 6/8] fix(next): restore socket env precedence and robust stub detection --- packages/next/src/builder-deferred.ts | 27 ++++++++++++++++++++++----- packages/next/src/loader.ts | 11 +++++------ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 5e8217c254..0b09ffd997 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -3,6 +3,7 @@ import { constants, existsSync, realpathSync } from 'node:fs'; import { access, mkdir, + open, readdir, readFile, rm, @@ -32,6 +33,7 @@ import { } from './step-copy-utils.js'; const ROUTE_STUB_FILE_MARKER = 'WORKFLOW_ROUTE_STUB_FILE'; +const ROUTE_STUB_MARKER_SCAN_BYTES = 4 * 1024; type WorkflowManifest = import('@workflow/builders').WorkflowManifest; @@ -263,13 +265,28 @@ export async function getNextBuilderDeferred() { if (!routeStats.isFile()) { return 'missing'; } - if (routeStats.size > 1024) { - return 'generated'; - } try { - const source = await readFile(routeFilePath, 'utf-8'); - return source.includes(ROUTE_STUB_FILE_MARKER) ? 'stub' : 'generated'; + const routeFileHandle = await open(routeFilePath, 'r'); + try { + const markerScanBuffer = Buffer.alloc(ROUTE_STUB_MARKER_SCAN_BYTES); + const { bytesRead } = await routeFileHandle.read( + markerScanBuffer, + 0, + ROUTE_STUB_MARKER_SCAN_BYTES, + 0 + ); + const markerScanSource = markerScanBuffer.toString( + 'utf8', + 0, + bytesRead + ); + return markerScanSource.includes(ROUTE_STUB_FILE_MARKER) + ? 'stub' + : 'generated'; + } finally { + await routeFileHandle.close(); + } } catch { return 'missing'; } diff --git a/packages/next/src/loader.ts b/packages/next/src/loader.ts index dd9f9f5738..f522e83091 100644 --- a/packages/next/src/loader.ts +++ b/packages/next/src/loader.ts @@ -233,18 +233,17 @@ async function getSocketCredentialsFromFile(): Promise port: numericPort, authToken, }; - } catch (error) { + } catch { return null; } } async function getSocketCredentials(): Promise { - const fileCredentials = await getSocketCredentialsFromFile(); - if (fileCredentials) { - return fileCredentials; - } const envCredentials = getSocketCredentialsFromEnv(); - return envCredentials; + if (envCredentials) { + return envCredentials; + } + return await getSocketCredentialsFromFile(); } async function getSocketClient(): Promise { From 59763fd6f77914e46d8856660b5cc54a036c33c1 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 9 Apr 2026 12:32:37 -0700 Subject: [PATCH 7/8] fix(next): check project-root socket fallback path --- packages/next/src/loader.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/next/src/loader.ts b/packages/next/src/loader.ts index f522e83091..e04a2b219e 100644 --- a/packages/next/src/loader.ts +++ b/packages/next/src/loader.ts @@ -181,13 +181,25 @@ function getSocketInfoFilePath(): string | null { // Fallback for worker processes that don't inherit dynamic env updates // from the process that created the socket server. const distDir = process.env.WORKFLOW_NEXT_DIST_DIR || '.next'; - const fallbackPath = join( + const cwdFallbackPath = join( process.cwd(), distDir, 'cache', 'workflow-socket.json' ); - return fallbackPath; + const projectRoot = process.env.WORKFLOW_PROJECT_ROOT; + if (projectRoot) { + const projectRootFallbackPath = join( + projectRoot, + distDir, + 'cache', + 'workflow-socket.json' + ); + if (existsSync(projectRootFallbackPath)) { + return projectRootFallbackPath; + } + } + return cwdFallbackPath; } function getSocketCredentialsFromEnv(): SocketCredentials | null { From d25f9fd44ff760911c3a0e4b624df184d68aec6a Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 9 Apr 2026 13:16:13 -0700 Subject: [PATCH 8/8] fix(next): make manifest step path resolution windows-safe --- packages/next/src/builder-deferred.ts | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 0b09ffd997..cb918cf0c2 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -824,26 +824,17 @@ export async function getNextBuilderDeferred() { } const resolveBaseDirs = new Set(); + const addResolveBaseDir = (baseDir: string) => { + resolveBaseDirs.add(this.normalizeDiscoveredFilePath(baseDir)); + }; + if (this.config.projectRoot) { - resolveBaseDirs.add( - this.normalizeDiscoveredFilePath(this.config.projectRoot) - ); + addResolveBaseDir(this.config.projectRoot); } - const normalizedWorkingDir = this.normalizeDiscoveredFilePath( - this.config.workingDir - ); - resolveBaseDirs.add(normalizedWorkingDir); - let currentResolveDir = normalizedWorkingDir; - while (true) { - if ( - existsSync(join(currentResolveDir, 'pnpm-workspace.yaml')) || - existsSync(join(currentResolveDir, 'turbo.json')) || - existsSync(join(currentResolveDir, '.git')) - ) { - resolveBaseDirs.add(currentResolveDir); - break; - } + let currentResolveDir = this.config.workingDir; + while (currentResolveDir) { + addResolveBaseDir(currentResolveDir); const parentResolveDir = dirname(currentResolveDir); if (parentResolveDir === currentResolveDir) { break;