Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/next-base-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@workflow/builders": patch
"@workflow/core": patch
"@workflow/next": patch
"@workflow/utils": patch
"@workflow/world-local": patch
"@workflow/world-postgres": patch
---

Respect framework base paths when routing workflow traffic and expose health checks on generated Next.js workflow routes.
14 changes: 10 additions & 4 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import {
applySwcTransform,
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
import {
createWorkflowEntrypointOptionsCode,
createWorkflowRouteHandlersCode,
} from './constants.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import {
type DiscoveredEntries,
Expand Down Expand Up @@ -1564,6 +1567,7 @@ export const __steps_registered = true;

const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(
{
basePath: this.config.basePath,
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
}
);
Expand All @@ -1578,7 +1582,7 @@ import { workflowEntrypoint } from 'workflow/runtime';
const workflowRouteModuleBodyStartedAt = Date.now();
const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});`;
${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode})`)}`;

// we skip the final bundling step for Next.js so it can bundle itself
if (!bundleFinalOutput) {
Expand Down Expand Up @@ -1771,6 +1775,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
const stepsRelativePath = `./${basename(stepsOutfile).replace(/\\/g, '/')}`;
const escapedVMCode = workflowVMCode.replace(/[\\`$]/g, '\\$&');
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode({
basePath: this.config.basePath,
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
});

Expand All @@ -1786,7 +1791,7 @@ void __steps_registered;

const workflowCode = \`${escapedVMCode}\`;

export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});`;
${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode})`)}`;

if (!bundleFinalOutput) {
await this.writeGeneratedFile(flowOutfile, combinedFunctionCode);
Expand Down Expand Up @@ -1849,6 +1854,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&');
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(
{
basePath: this.config.basePath,
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
}
);
Expand All @@ -1864,7 +1870,7 @@ void __steps_registered;

const workflowCode = \`${escaped}\`;

export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});`;
${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode})`)}`;

const outputDir = dirname(flowOutfile);
await mkdir(outputDir, { recursive: true });
Expand Down
3 changes: 2 additions & 1 deletion packages/builders/src/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ describe('createWorkflowEntrypointOptionsCode', () => {
expect(
createWorkflowEntrypointOptionsCode({
namespace: 'custom',
basePath: '/v2',
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
})
).toBe(
', { namespace: "custom", routeModuleBodyStartedAt: workflowRouteModuleBodyStartedAt }'
', { namespace: "custom", basePath: "/v2", routeModuleBodyStartedAt: workflowRouteModuleBodyStartedAt }'
);
});
});
14 changes: 14 additions & 0 deletions packages/builders/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
basePath?: string;
/** Raw code identifier/expression emitted into generated route files, not data. */
routeModuleBodyStartedAt?: string;
}) {
Expand All @@ -66,6 +67,10 @@ export function createWorkflowEntrypointOptionsCode(options?: {
fields.push(`namespace: ${JSON.stringify(namespace)}`);
}

if (options?.basePath !== undefined) {
fields.push(`basePath: ${JSON.stringify(options.basePath)}`);
}

if (options?.routeModuleBodyStartedAt) {
fields.push(
`routeModuleBodyStartedAt: ${options.routeModuleBodyStartedAt}`
Expand All @@ -79,6 +84,15 @@ export function createWorkflowEntrypointOptionsCode(options?: {
return `, { ${fields.join(', ')} }`;
}

export function createWorkflowRouteHandlersCode(
workflowEntrypointCall: string
) {
return `export const POST = ${workflowEntrypointCall};
export const GET = POST;
export const HEAD = POST;
export const OPTIONS = POST;`;
}

/**
* Default queue trigger (no namespace). Backward compatible.
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/builders/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ interface BaseWorkflowConfig {
// artifact locations.
distDir?: string;

// Optional route prefix for apps deployed below the origin root.
basePath?: string;

// Suppress informational logs emitted by createWorkflowsBundle()
// (e.g. intermediate/final workflow bundle timing logs).
suppressCreateWorkflowsBundleLogs?: boolean;
Expand Down
124 changes: 39 additions & 85 deletions packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
WorkflowRunFailedError,
WorkflowWorldError,
} from '@workflow/errors';
import { createWorkflowUrl } from '@workflow/utils';
import { SPEC_VERSION_CURRENT, type World } from '@workflow/world';
import {
afterAll,
Expand Down Expand Up @@ -111,6 +112,9 @@ function writeE2EMetadata() {
const e2e = (fn: string) =>
getWorkflowMetadata(deploymentUrl, 'workflows/99_e2e.ts', fn);

const workflowWebhookUrl = (token: string) =>
createWorkflowUrl(deploymentUrl, { type: 'webhook', token });

type WorkflowEvent = Awaited<
ReturnType<World['events']['list']>
>['data'][number];
Expand Down Expand Up @@ -532,17 +536,11 @@ describe('e2e', () => {
expect(hook.runId).toBe(run.runId);

// Attempt to resume via the public webhook endpoint — should get 404
const res = await fetch(
new URL(
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token)}`,
deploymentUrl
),
{
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: JSON.stringify({ message: 'should-be-rejected' }),
}
);
const res = await fetch(workflowWebhookUrl(token), {
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: JSON.stringify({ message: 'should-be-rejected' }),
});
expect(res.status).toBe(404);

// Now resume via server-side resumeHook() — should work
Expand Down Expand Up @@ -587,79 +585,46 @@ describe('e2e', () => {
const [token, token2, token3] = hooks.map((h) => h.token);

// Webhook with default response
const res = await fetch(
new URL(
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token)}`,
deploymentUrl
),
{
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: JSON.stringify({ message: 'one' }),
}
);
const res = await fetch(workflowWebhookUrl(token), {
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: JSON.stringify({ message: 'one' }),
});
expect(res.status).toBe(202);
const body = await res.text();
expect(body).toBe('');

// Webhook with static response
const res2 = await fetch(
new URL(
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token2)}`,
deploymentUrl
),
{
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: JSON.stringify({ message: 'two' }),
}
);
const res2 = await fetch(workflowWebhookUrl(token2), {
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: JSON.stringify({ message: 'two' }),
});
expect(res2.status).toBe(402);
const body2 = await res2.text();
expect(body2).toBe('Hello from static response!');

// Webhook with manual response
const res3 = await fetch(
new URL(
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token3)}`,
deploymentUrl
),
{
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: JSON.stringify({ message: 'three' }),
}
);
const res3 = await fetch(workflowWebhookUrl(token3), {
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: JSON.stringify({ message: 'three' }),
});
expect(res3.status).toBe(200);
const body3 = await res3.text();
expect(body3).toBe('Hello from webhook!');

const returnValue = await run.returnValue;
expect(returnValue).toHaveLength(3);
expect(returnValue[0].url).toBe(
new URL(
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token)}`,
deploymentUrl
).href
);
expect(returnValue[0].url).toBe(workflowWebhookUrl(token));
expect(returnValue[0].method).toBe('POST');
expect(returnValue[0].body).toBe('{"message":"one"}');

expect(returnValue[1].url).toBe(
new URL(
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token2)}`,
deploymentUrl
).href
);
expect(returnValue[1].url).toBe(workflowWebhookUrl(token2));
expect(returnValue[1].method).toBe('POST');
expect(returnValue[1].body).toBe('{"message":"two"}');

expect(returnValue[2].url).toBe(
new URL(
`/.well-known/workflow/v1/webhook/${encodeURIComponent(token3)}`,
deploymentUrl
).href
);
expect(returnValue[2].url).toBe(workflowWebhookUrl(token3));
expect(returnValue[2].method).toBe('POST');
expect(returnValue[2].body).toBe('{"message":"three"}');
});
Expand Down Expand Up @@ -730,17 +695,11 @@ describe('e2e', () => {
continue;
}

const res = await fetch(
new URL(
`/.well-known/workflow/v1/webhook/${encodeURIComponent(unservedHook.token)}`,
deploymentUrl
),
{
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: `body-${unservedHook.token}`,
}
);
const res = await fetch(workflowWebhookUrl(unservedHook.token), {
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: `body-${unservedHook.token}`,
});
expect(res.status).toBe(202);
servedTokens.add(unservedHook.token);
}
Expand Down Expand Up @@ -786,11 +745,7 @@ describe('e2e', () => {
);

test('webhook route with invalid token', { timeout: 60_000 }, async () => {
const invalidWebhookUrl = new URL(
`/.well-known/workflow/v1/webhook/${encodeURIComponent('invalid')}`,
deploymentUrl
);
const res = await fetch(invalidWebhookUrl, {
const res = await fetch(workflowWebhookUrl('invalid'), {
method: 'POST',
headers: await getTrustedSourcesHeaders(),
body: JSON.stringify({}),
Expand Down Expand Up @@ -2555,14 +2510,13 @@ describe('e2e', () => {
// bypasses protection by sending messages through the Queue infrastructure.

// Test the flow endpoint health check (V2: combined handler for both workflow + step)
const flowHealthUrl = new URL(
'/.well-known/workflow/v1/flow?__health',
deploymentUrl
const flowRes = await fetch(
createWorkflowUrl(deploymentUrl, { type: 'health' }),
{
method: 'POST',
headers: await getTrustedSourcesHeaders(),
}
);
const flowRes = await fetch(flowHealthUrl, {
method: 'POST',
headers: await getTrustedSourcesHeaders(),
});
expect(flowRes.status).toBe(200);
expect(flowRes.headers.get('Content-Type')).toBe('application/json');
const flowBody = await flowRes.json();
Expand Down
3 changes: 2 additions & 1 deletion packages/core/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'node:fs';
import path, { dirname } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { createWorkflowUrl } from '@workflow/utils';
import { createVercelWorld } from '@workflow/world-vercel';
import { onTestFailed } from 'vitest';
import { getTrustedSourcesHeaders } from '../../../scripts/trusted-sources-headers.mjs';
Expand Down Expand Up @@ -373,7 +374,7 @@ export async function fetchManifest(
const forceRefresh = options?.forceRefresh ?? false;
if (cachedManifest && !forceRefresh) return cachedManifest;

const url = new URL('/.well-known/workflow/v1/manifest.json', deploymentUrl);
const url = createWorkflowUrl(deploymentUrl, { type: 'manifest' });
const res = await fetch(url, {
headers: await getTrustedSourcesHeaders(),
});
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/create-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,8 @@ export interface HookOptions {
* Whether this hook can be resumed via the public webhook endpoint.
*
* When `true`, the hook can be triggered by sending an HTTP request to the
* public `/.well-known/workflow/v1/webhook/{token}` URL. This is automatically
* set when using `createWebhook()`.
* public workflow webhook URL. This is automatically set when using
* `createWebhook()`.
*
* When `false` (the default), the hook can only be resumed server-side
* via `resumeHook()`.
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
RunExpiredError,
WorkflowRuntimeError,
} from '@workflow/errors';
import { setWorkflowBasePath } from '@workflow/utils';
import {
parseWorkflowName,
workflowDisplayName,
Expand Down Expand Up @@ -293,8 +294,14 @@ function hasOpenHookOrWait(events: Event[]): boolean {
*/
export function workflowEntrypoint(
workflowCode: string,
options?: { namespace?: string; routeModuleBodyStartedAt?: number }
options?: {
namespace?: string;
routeModuleBodyStartedAt?: number;
basePath?: string;
}
): (req: Request) => Promise<Response> {
setWorkflowBasePath(options?.basePath);

const NO_INLINE_REPLAY_AFTER_MS =
Number(process.env.WORKFLOW_V2_TIMEOUT_MS) || 120_000;

Expand Down
Loading
Loading