Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fail-closed-deferred-workflow-stubs.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 61 additions & 1 deletion packages/next/src/loader.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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.'
);
});
});
60 changes: 48 additions & 12 deletions packages/next/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
readGeneratedRoute: () => Promise<string>;
}): 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
Expand Down Expand Up @@ -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 };
}
Expand Down
Loading