Skip to content
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/core": patch
"@workflow/builders": patch
"@workflow/next": patch
"@workflow/utils": patch
"@workflow/world-local": patch
"@workflow/world-postgres": patch
---

Respect Next.js basePath when constructing workflow runtime URLs.
3 changes: 3 additions & 0 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1484,6 +1484,7 @@ export const __steps_registered = true;

const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(
{
basePath: this.config.basePath,
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
}
);
Expand Down Expand Up @@ -1689,6 +1690,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 Down Expand Up @@ -1764,6 +1766,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo
const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&');
const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode(
{
basePath: this.config.basePath,
routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt',
}
);
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 }'
);
});
});
5 changes: 5 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 Down
2 changes: 2 additions & 0 deletions packages/builders/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ interface BaseWorkflowConfig {
// artifact locations.
distDir?: string;

basePath?: string;

// Suppress informational logs emitted by createWorkflowsBundle()
// (e.g. intermediate/final workflow bundle timing logs).
suppressCreateWorkflowsBundleLogs?: boolean;
Expand Down
11 changes: 10 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 @@ -291,8 +292,16 @@ 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> {
if (options?.basePath !== undefined) {
setWorkflowBasePath(options.basePath);
}

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

Expand Down
16 changes: 10 additions & 6 deletions packages/core/src/runtime/step-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
TooEarlyError,
WorkflowRuntimeError,
} from '@workflow/errors';
import { pluralize, stepDisplayName } from '@workflow/utils';
import {
getWorkflowBasePath,
pluralize,
stepDisplayName,
} from '@workflow/utils';
import type { Event, SerializedData, Step, World } from '@workflow/world';
import {
SPEC_VERSION_CURRENT,
Expand All @@ -32,7 +36,6 @@ import {
normalizeUnknownError,
promoteAbortErrorToFatal,
} from '../types.js';

import {
isOptimisticInlineStartEnabled,
isOptimisticInlineStartExplicitlyDisabled,
Expand Down Expand Up @@ -563,7 +566,10 @@ export async function executeStep(

const args = hydratedInput.args;
const thisVal = hydratedInput.thisVal ?? null;
const port = isVercel ? undefined : await getPortLazy();
const basePath = getWorkflowBasePath();
const workflowBaseUrl = isVercel
? `https://${process.env.VERCEL_URL}${basePath}`
: `http://localhost:${(await getPortLazy()) ?? 3000}${basePath}`;

const executionStartTime = Date.now();
result = await trace('step.execute', {}, async () => {
Expand All @@ -579,9 +585,7 @@ export async function executeStep(
workflowName,
workflowRunId,
workflowStartedAt: new Date(+workflowStartedAt),
url: isVercel
? `https://${process.env.VERCEL_URL}`
: `http://localhost:${port ?? 3000}`,
url: workflowBaseUrl,
features: { encryption: !!encryptionKey },
},
workflowDeploymentId: params.workflowDeploymentId,
Expand Down
17 changes: 11 additions & 6 deletions packages/core/src/runtime/step-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {
WorkflowRuntimeError,
WorkflowWorldError,
} from '@workflow/errors';
import { formatStepName, pluralize, stepDisplayName } from '@workflow/utils';
import {
formatStepName,
getWorkflowBasePath,
pluralize,
stepDisplayName,
} from '@workflow/utils';
import { getPort } from '@workflow/utils/get-port';
import {
getQueueTopicPrefix,
Expand Down Expand Up @@ -221,6 +226,10 @@ function createStepHandler(namespace?: string) {
isVercel ? undefined : getPort(),
getSpanKind('CONSUMER'),
]);
const basePath = getWorkflowBasePath();
const workflowBaseUrl = isVercel
? `https://${process.env.VERCEL_URL}${basePath}`
: `http://localhost:${port ?? 3000}${basePath}`;

return trace(
`step.execute ${stepDisplayName(stepName)}`,
Expand Down Expand Up @@ -666,11 +675,7 @@ function createStepHandler(namespace?: string) {
workflowName,
workflowRunId,
workflowStartedAt: new Date(+workflowStartedAt),
// TODO: there should be a getUrl method on the world interface itself. This
// solution only works for vercel + local worlds.
url: isVercel
? `https://${process.env.VERCEL_URL}`
: `http://localhost:${port ?? 3000}`,
url: workflowBaseUrl,
features: { encryption: !!encryptionKey },
},
workflowDeploymentId: process.env.VERCEL_DEPLOYMENT_ID,
Expand Down
15 changes: 6 additions & 9 deletions packages/core/src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
WorkflowNotRegisteredError,
WorkflowRuntimeError,
} from '@workflow/errors';
import { withResolvers } from '@workflow/utils';
import { getWorkflowBasePath, withResolvers } from '@workflow/utils';
import { parseWorkflowName } from '@workflow/utils/parse-name';
import type { Event, WorkflowRun } from '@workflow/world';
import { SPEC_VERSION_SUPPORTS_COMPRESSION } from '@workflow/world';
Expand Down Expand Up @@ -194,7 +194,10 @@ export async function runWorkflow(
// fs ops (readdir, readFile) into the flow route bundle. The resolved
// port is cached per process (see get-port-lazy.ts), so this is cheap
// on replays after the first.
const port = isVercel ? undefined : await getPortLazy();
const basePath = getWorkflowBasePath();
const workflowBaseUrl = isVercel
? `https://${process.env.VERCEL_URL}${basePath}`
: `http://localhost:${(await getPortLazy()) ?? 3000}${basePath}`;

const {
context,
Expand Down Expand Up @@ -311,18 +314,12 @@ export async function runWorkflow(
vmGlobalThis[WORKFLOW_GET_STREAM_ID] = (namespace?: string) =>
getWorkflowRunStreamId(workflowRun.runId, namespace);

// TODO: there should be a getUrl method on the world interface itself. This
// solution only works for vercel + local worlds.
const url = isVercel
? `https://${process.env.VERCEL_URL}`
: `http://localhost:${port ?? 3000}`;

// For the workflow VM, we store the context in a symbol on the `globalThis` object
const ctx: WorkflowMetadata = {
workflowName: workflowRun.workflowName,
workflowRunId: workflowRun.runId,
workflowStartedAt: new vmGlobalThis.Date(+startedAt),
url,
url: workflowBaseUrl,
features: { encryption: !!encryptionKey },
};

Expand Down
78 changes: 78 additions & 0 deletions packages/next/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
Expand Down Expand Up @@ -50,6 +51,9 @@ import { withWorkflow } from './index.js';
const loaderStubPath = join(__dirname, 'loader.js');
const hadLoaderStub = existsSync(loaderStubPath);
const realTmpDir = realpathSync(tmpdir());
const BASE_PATH_SYMBOL = Symbol.for('@workflow/base-path');
const workflowGlobal = globalThis as typeof globalThis &
Record<symbol, string | undefined>;

function writeFile(path: string, contents: string): void {
mkdirSync(dirname(path), { recursive: true });
Expand Down Expand Up @@ -81,9 +85,11 @@ describe('withWorkflow builder config', () => {
delete process.env.WORKFLOW_LOCAL_DATA_DIR;
delete process.env.WORKFLOW_NEXT_PRIVATE_BUILT;
delete process.env.WORKFLOW_TARGET_WORLD;
workflowGlobal[BASE_PATH_SYMBOL] = undefined;
});

afterEach(() => {
workflowGlobal[BASE_PATH_SYMBOL] = undefined;
if (!hadLoaderStub && existsSync(loaderStubPath)) {
rmSync(loaderStubPath);
}
Expand Down Expand Up @@ -173,6 +179,78 @@ describe('withWorkflow builder config', () => {
});
});

it('configures workflow URLs from Next.js basePath', async () => {
const config = withWorkflow({
basePath: '/v2',
env: {
EXISTING_ENV: '1',
},
});

const nextConfig = await config('phase-production-build', {
defaultConfig: {},
});

expect(workflowGlobal[BASE_PATH_SYMBOL]).toBe('/v2');
expect(nextConfig.env).toEqual({
EXISTING_ENV: '1',
});
expect(builderConfigs[0]).toMatchObject({
basePath: '/v2',
});
});

it('writes a Vercel launcher entrypoint for basePath workflow routes', async () => {
const projectDir = mkdtempSync(join(realTmpDir, 'workflow-next-basepath-'));
const routeFile = join(
projectDir,
'.next/server/app/.well-known/workflow/v1/flow/route.js'
);
const traceFile = `${routeFile}.nft.json`;

try {
writeFile(routeFile, 'module.exports = {};\n');
writeFile(traceFile, JSON.stringify({ version: 1, files: [] }));

const config = withWorkflow({
basePath: '/v2',
});
const nextConfig = await config('phase-production-build', {
defaultConfig: {},
});
const runAfterProductionCompile = (
nextConfig.compiler as {
runAfterProductionCompile(metadata: {
projectDir: string;
distDir: string;
}): Promise<void>;
}
).runAfterProductionCompile;

await runAfterProductionCompile({
projectDir,
distDir: '.next',
});

expect(
readFileSync(
join(
projectDir,
'.next/server/pages/v2/.well-known/workflow/v1/flow.js'
),
'utf-8'
)
).toBe(
'module.exports = require("../../../../../app/.well-known/workflow/v1/flow/route.js");\n'
);
expect(JSON.parse(readFileSync(traceFile, 'utf-8')).files).toContain(
'../../../../../pages/v2/.well-known/workflow/v1/flow.js'
);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});

it('externalizes the built-in Vercel world while preserving user externals', async () => {
const config = withWorkflow({
serverExternalPackages: ['@node-rs/xxhash'],
Expand Down
Loading