diff --git a/.changeset/fail-closed-deferred-workflow-stubs.md b/.changeset/fail-closed-deferred-workflow-stubs.md new file mode 100644 index 0000000000..3b1dc1258d --- /dev/null +++ b/.changeset/fail-closed-deferred-workflow-stubs.md @@ -0,0 +1,5 @@ +--- +'@workflow/next': patch +--- + +Fail builds instead of compiling deferred workflow route stubs when lazy discovery does not generate the real route. diff --git a/packages/next/src/loader.test.ts b/packages/next/src/loader.test.ts index b13ee68561..20c605a655 100644 --- a/packages/next/src/loader.test.ts +++ b/packages/next/src/loader.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { shouldNotifySocketForDiscoveredPattern } from './loader.js'; +import { + resolveDeferredWorkflowRouteStubSource, + shouldNotifySocketForDiscoveredPattern, +} from './loader.js'; describe('workflow loader discovery notifications', () => { it('notifies for unchanged files that still contain workflow patterns', () => { @@ -41,3 +44,60 @@ describe('workflow loader discovery notifications', () => { ).toBe(true); }); }); + +describe('deferred workflow route stubs', () => { + it('returns the generated route after the deferred build completes', async () => { + await expect( + resolveDeferredWorkflowRouteStubSource({ + filename: '/app/.well-known/workflow/v1/flow/route.js', + sourceMap: 'source-map', + waitForDeferredBuild: async () => {}, + readGeneratedRoute: async () => 'export async function POST() {}', + }) + ).resolves.toEqual({ + code: 'export async function POST() {}', + map: 'source-map', + }); + }); + + it('throws instead of returning stub output when the deferred build fails', async () => { + const cause = new Error('Timed out waiting for deferred route build'); + + await expect( + resolveDeferredWorkflowRouteStubSource({ + filename: '/app/.well-known/workflow/v1/flow/route.js', + sourceMap: undefined, + waitForDeferredBuild: async () => { + throw cause; + }, + readGeneratedRoute: async () => + '// WORKFLOW_ROUTE_STUB_FILE\nexport const __workflowRouteStub = true;', + }) + ).rejects.toThrow('Refusing to compile the route stub'); + }); + + it('throws when the deferred build leaves the generated route as a stub', async () => { + let thrownError: unknown; + + try { + await resolveDeferredWorkflowRouteStubSource({ + filename: '/app/.well-known/workflow/v1/flow/route.js', + sourceMap: undefined, + waitForDeferredBuild: async () => {}, + readGeneratedRoute: async () => + '// WORKFLOW_ROUTE_STUB_FILE\nexport const __workflowRouteStub = true;', + }); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toContain( + 'Refusing to compile the route stub' + ); + expect((thrownError as { cause?: unknown }).cause).toBeInstanceOf(Error); + expect(((thrownError as { cause?: Error }).cause as Error).message).toBe( + 'Deferred route build completed, but the generated route file is still the workflow route stub.' + ); + }); +}); diff --git a/packages/next/src/loader.ts b/packages/next/src/loader.ts index 60d38c79a7..0f927f827d 100644 --- a/packages/next/src/loader.ts +++ b/packages/next/src/loader.ts @@ -412,6 +412,48 @@ function isWorkflowRouteStubSource(source: string): boolean { return source.includes(ROUTE_STUB_FILE_MARKER); } +function createDeferredRouteStubBuildError( + filename: string, + cause: unknown +): Error { + const error = new Error( + [ + `[workflow] Failed to generate deferred workflow route for ${filename}.`, + 'Refusing to compile the route stub because it does not export POST and would return 405 to workflow callbacks.', + 'Retry the build, or disable lazy discovery with WORKFLOW_NEXT_LAZY_DISCOVERY=0 or withWorkflow(..., { workflows: { lazyDiscovery: false } }).', + ].join('\n') + ); + if (cause !== undefined) { + (error as { cause?: unknown }).cause = cause; + } + return error; +} + +export async function resolveDeferredWorkflowRouteStubSource({ + filename, + sourceMap, + waitForDeferredBuild, + readGeneratedRoute, +}: { + filename: string; + sourceMap: any; + waitForDeferredBuild: () => Promise; + readGeneratedRoute: () => Promise; +}): Promise<{ code: string; map: any }> { + try { + await waitForDeferredBuild(); + const refreshedSource = await readGeneratedRoute(); + if (!isWorkflowRouteStubSource(refreshedSource)) { + return { code: refreshedSource, map: sourceMap }; + } + throw new Error( + 'Deferred route build completed, but the generated route file is still the workflow route stub.' + ); + } catch (error) { + throw createDeferredRouteStubBuildError(filename, error); + } +} + async function createSocketConnection( socketCredentials: SocketCredentials, timeoutMs = 1_000 @@ -687,18 +729,12 @@ export default function workflowLoader( 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 resolveDeferredWorkflowRouteStubSource({ + filename, + sourceMap, + waitForDeferredBuild: ensureDeferredRouteStubBuildAndWait, + readGeneratedRoute: () => readFile(filename, 'utf8'), + }); } return { code: normalizedSource, map: sourceMap }; }