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/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/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..cb918cf0c2 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; @@ -122,6 +124,7 @@ export async function getNextBuilderDeferred() { private cjsSyncResolver?: ReturnType< typeof enhancedResolveOrig.create.sync >; + private manifestStepResolveBaseDirs: string[] | null = null; async build() { const outputDir = await this.findAppDirectory(); @@ -175,7 +178,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; } @@ -224,6 +232,66 @@ 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'; + } + + try { + 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'; + } + } + private resolveWorkflowStdlibStepFilePath(): string | null { let workflowCjsEntry: string; try { @@ -503,9 +571,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, @@ -692,13 +762,7 @@ export async function getNextBuilderDeferred() { this.scheduleWorkflowsCacheWrite(); } - if ( - hasWorkflow || - hasStep || - hasSerde || - hasCacheTrackingChange || - wasTrackedDependency - ) { + if (hasCacheTrackingChange || wasTrackedDependency) { this.scheduleDeferredRebuild(); } }, @@ -716,7 +780,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; } @@ -743,9 +808,42 @@ 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 getManifestStepResolveBaseDirs(): string[] { + if (this.manifestStepResolveBaseDirs) { + return this.manifestStepResolveBaseDirs; + } + + const resolveBaseDirs = new Set(); + const addResolveBaseDir = (baseDir: string) => { + resolveBaseDirs.add(this.normalizeDiscoveredFilePath(baseDir)); + }; + + if (this.config.projectRoot) { + addResolveBaseDir(this.config.projectRoot); + } + + let currentResolveDir = this.config.workingDir; + while (currentResolveDir) { + addResolveBaseDir(currentResolveDir); + const parentResolveDir = dirname(currentResolveDir); + if (parentResolveDir === currentResolveDir) { + break; + } + currentResolveDir = parentResolveDir; + } + + this.manifestStepResolveBaseDirs = Array.from(resolveBaseDirs); + return this.manifestStepResolveBaseDirs; } private async filterExistingFiles(filePaths: string[]): Promise { @@ -1025,47 +1123,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 +1179,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') { @@ -1508,11 +1545,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/') ); @@ -1860,9 +1904,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 }); @@ -1875,7 +1921,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) { @@ -2005,14 +2051,41 @@ export async function getNextBuilderDeferred() { return workflowManifest; } + private async collectManifestStepSourceFiles( + manifest: WorkflowManifest + ): Promise { + const manifestStepEntries = Object.keys(manifest.steps || {}); + if (manifestStepEntries.length === 0) { + return []; + } + + const resolveBaseDirs = this.getManifestStepResolveBaseDirs(); + const candidateFiles = manifestStepEntries + .flatMap((stepEntry) => { + if (isAbsolute(stepEntry)) { + return [this.normalizeDiscoveredFilePath(stepEntry)]; + } + return resolveBaseDirs.map((baseDir) => + this.normalizeDiscoveredFilePath(resolve(baseDir, stepEntry)) + ); + }) + .filter( + (candidateFile) => !this.isGeneratedWorkflowArtifact(candidateFile) + ); + const existingCandidates = await this.filterExistingFiles(candidateFiles); + return Array.from(new Set(existingCandidates)).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 }); @@ -2029,25 +2102,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 @@ -2092,12 +2187,6 @@ export async function getNextBuilderDeferred() { await this.writeFileIfChanged(stepRouteFile, routeContents); - const manifest = await this.createDeferredStepsManifest({ - stepFiles: manifestStepFiles, - workflowFiles, - serdeOnlyFiles, - }); - return { context: undefined, manifest, diff --git a/packages/next/src/loader.ts b/packages/next/src/loader.ts index f3561df0e6..e04a2b219e 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,32 @@ 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 cwdFallbackPath = join( + process.cwd(), + distDir, + 'cache', + 'workflow-socket.json' + ); + 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 { @@ -145,7 +213,6 @@ function getSocketCredentialsFromEnv(): SocketCredentials | null { if (Number.isNaN(port)) { return null; } - return { port, authToken }; } @@ -174,7 +241,6 @@ async function getSocketCredentialsFromFile(): Promise if (!authToken || Number.isNaN(numericPort)) { return null; } - return { port: numericPort, authToken, @@ -185,9 +251,11 @@ async function getSocketCredentialsFromFile(): Promise } async function getSocketCredentials(): Promise { - return ( - getSocketCredentialsFromEnv() ?? (await getSocketCredentialsFromFile()) - ); + const envCredentials = getSocketCredentialsFromEnv(); + if (envCredentials) { + return envCredentials; + } + return await getSocketCredentialsFromFile(); } async function getSocketClient(): Promise { @@ -259,7 +327,6 @@ async function getSocketClient(): Promise { } })(); } - return socketClientPromise; } @@ -303,6 +370,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 +661,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 +692,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) {