From 94f08e8afa2c2f705d13c695e4528ef574da6997 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:48:05 -0700 Subject: [PATCH 1/9] fix(next): respect basePath for workflow URLs --- .changeset/next-base-path.md | 8 ++++ packages/core/src/runtime/base-url.test.ts | 44 ++++++++++++++++++ packages/core/src/runtime/base-url.ts | 15 ++++++ packages/core/src/runtime/step-executor.ts | 6 +-- packages/core/src/runtime/step-handler.ts | 7 +-- packages/core/src/workflow.ts | 7 +-- packages/next/src/index.test.ts | 35 ++++++++++++++ packages/next/src/index.ts | 20 ++++++++ packages/utils/src/base-path.test.ts | 18 ++++++++ packages/utils/src/base-path.ts | 13 ++++++ packages/utils/src/index.ts | 3 +- packages/world-local/src/config.test.ts | 53 +++++++++++++++++++++- packages/world-local/src/config.ts | 47 ++++++++++++++++--- packages/world-local/src/index.ts | 1 + packages/world-local/src/queue.test.ts | 42 +++++++++++++++++ packages/world-local/src/queue.ts | 39 ++++++++-------- 16 files changed, 317 insertions(+), 41 deletions(-) create mode 100644 .changeset/next-base-path.md create mode 100644 packages/core/src/runtime/base-url.test.ts create mode 100644 packages/core/src/runtime/base-url.ts create mode 100644 packages/utils/src/base-path.test.ts create mode 100644 packages/utils/src/base-path.ts diff --git a/.changeset/next-base-path.md b/.changeset/next-base-path.md new file mode 100644 index 0000000000..3e4ef32f3a --- /dev/null +++ b/.changeset/next-base-path.md @@ -0,0 +1,8 @@ +--- +"@workflow/core": patch +"@workflow/next": patch +"@workflow/utils": patch +"@workflow/world-local": patch +--- + +Respect Next.js basePath when constructing workflow runtime URLs. diff --git a/packages/core/src/runtime/base-url.test.ts b/packages/core/src/runtime/base-url.test.ts new file mode 100644 index 0000000000..fac49df8f8 --- /dev/null +++ b/packages/core/src/runtime/base-url.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getWorkflowBaseUrl } from './base-url.js'; + +describe('getWorkflowBaseUrl', () => { + const originalEnv = { + VERCEL_URL: process.env.VERCEL_URL, + WORKFLOW_BASE_PATH: process.env.WORKFLOW_BASE_PATH, + }; + + afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('uses localhost without a base path by default', () => { + delete process.env.WORKFLOW_BASE_PATH; + + expect(getWorkflowBaseUrl({ isVercel: false, port: 3000 })).toBe( + 'http://localhost:3000' + ); + }); + + it('includes WORKFLOW_BASE_PATH for local metadata URLs', () => { + process.env.WORKFLOW_BASE_PATH = '/v2'; + + expect(getWorkflowBaseUrl({ isVercel: false, port: 3000 })).toBe( + 'http://localhost:3000/v2' + ); + }); + + it('includes WORKFLOW_BASE_PATH for Vercel metadata URLs', () => { + process.env.VERCEL_URL = 'example.vercel.app'; + process.env.WORKFLOW_BASE_PATH = '/v2/'; + + expect(getWorkflowBaseUrl({ isVercel: true })).toBe( + 'https://example.vercel.app/v2' + ); + }); +}); diff --git a/packages/core/src/runtime/base-url.ts b/packages/core/src/runtime/base-url.ts new file mode 100644 index 0000000000..60d2020eb6 --- /dev/null +++ b/packages/core/src/runtime/base-url.ts @@ -0,0 +1,15 @@ +import { normalizeBasePath } from '@workflow/utils'; + +export function getWorkflowBaseUrl({ + isVercel, + port, +}: { + isVercel: boolean; + port?: number; +}): string { + const origin = isVercel + ? `https://${process.env.VERCEL_URL}` + : `http://localhost:${port ?? 3000}`; + + return `${origin}${normalizeBasePath(process.env.WORKFLOW_BASE_PATH)}`; +} diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 7f21bba0fc..902eb5370f 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -32,7 +32,7 @@ import { normalizeUnknownError, promoteAbortErrorToFatal, } from '../types.js'; - +import { getWorkflowBaseUrl } from './base-url.js'; import { isOptimisticInlineStartEnabled, isOptimisticInlineStartExplicitlyDisabled, @@ -579,9 +579,7 @@ export async function executeStep( workflowName, workflowRunId, workflowStartedAt: new Date(+workflowStartedAt), - url: isVercel - ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${port ?? 3000}`, + url: getWorkflowBaseUrl({ isVercel, port }), features: { encryption: !!encryptionKey }, }, workflowDeploymentId: params.workflowDeploymentId, diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index 2a8ba2631f..ee41906805 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -47,6 +47,7 @@ import { promoteAbortErrorToFatal, } from '../types.js'; +import { getWorkflowBaseUrl } from './base-url.js'; import { MAX_QUEUE_DELIVERIES } from './constants.js'; import { getQueueOverhead, @@ -666,11 +667,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: getWorkflowBaseUrl({ isVercel, port }), features: { encryption: !!encryptionKey }, }, workflowDeploymentId: process.env.VERCEL_DEPLOYMENT_ID, diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index af78708ceb..3e8460ddc9 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -16,6 +16,7 @@ import type { QueueItem } from './global.js'; import { ENOTSUP, WorkflowSuspension } from './global.js'; import { runtimeLogger } from './logger.js'; import type { WorkflowOrchestratorContext } from './private.js'; +import { getWorkflowBaseUrl } from './runtime/base-url.js'; import { getPortLazy } from './runtime/get-port-lazy.js'; import { runIdCreatedAt } from './runtime/run-id-time.js'; import { handleSuspension } from './runtime/suspension-handler.js'; @@ -311,11 +312,7 @@ 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}`; + const url = getWorkflowBaseUrl({ isVercel, port }); // For the workflow VM, we store the context in a symbol on the `globalThis` object const ctx: WorkflowMetadata = { diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 6996e331b3..8df08a81e4 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -62,6 +62,7 @@ describe('withWorkflow builder config', () => { PORT: process.env.PORT, VERCEL_DEPLOYMENT_ID: process.env.VERCEL_DEPLOYMENT_ID, WORKFLOW_LOCAL_DATA_DIR: process.env.WORKFLOW_LOCAL_DATA_DIR, + WORKFLOW_BASE_PATH: process.env.WORKFLOW_BASE_PATH, WORKFLOW_NEXT_PRIVATE_BUILT: process.env.WORKFLOW_NEXT_PRIVATE_BUILT, WORKFLOW_TARGET_WORLD: process.env.WORKFLOW_TARGET_WORLD, }; @@ -79,6 +80,7 @@ describe('withWorkflow builder config', () => { delete process.env.PORT; delete process.env.VERCEL_DEPLOYMENT_ID; delete process.env.WORKFLOW_LOCAL_DATA_DIR; + delete process.env.WORKFLOW_BASE_PATH; delete process.env.WORKFLOW_NEXT_PRIVATE_BUILT; delete process.env.WORKFLOW_TARGET_WORLD; }); @@ -173,6 +175,39 @@ describe('withWorkflow builder config', () => { }); }); + it('propagates Next.js basePath to workflow runtime URLs', async () => { + const config = withWorkflow({ + basePath: '/v2', + env: { + EXISTING_ENV: '1', + }, + }); + + const nextConfig = await config('phase-production-build', { + defaultConfig: {}, + }); + + expect(process.env.WORKFLOW_BASE_PATH).toBe('/v2'); + expect(nextConfig.env).toEqual({ + EXISTING_ENV: '1', + WORKFLOW_BASE_PATH: '/v2', + }); + }); + + it('clears workflow basePath when Next.js basePath is not configured', async () => { + process.env.WORKFLOW_BASE_PATH = '/previous'; + const config = withWorkflow({}); + + const nextConfig = await config('phase-production-build', { + defaultConfig: {}, + }); + + expect(process.env.WORKFLOW_BASE_PATH).toBe(''); + expect(nextConfig.env).toEqual({ + WORKFLOW_BASE_PATH: '', + }); + }); + it('externalizes the built-in Vercel world while preserving user externals', async () => { const config = withWorkflow({ serverExternalPackages: ['@node-rs/xxhash'], diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 18262b2c51..2c92f23d10 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -28,6 +28,20 @@ const workflowSerdeComputedPropertyPattern = const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']); const warnedAutoRemovedServerExternalPackages = new Set(); +function normalizeBasePath(basePath: string | undefined): string { + if (!basePath) { + return ''; + } + + const trimmed = basePath.trim(); + if (!trimmed || trimmed === '/') { + return ''; + } + + const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return withLeadingSlash.replace(/\/+$/, ''); +} + interface WorkflowPatternMatch { hasUseWorkflow: boolean; hasUseStep: boolean; @@ -375,6 +389,12 @@ export function withWorkflow( } // shallow clone to avoid read-only on top-level nextConfig = Object.assign({}, nextConfig); + const workflowBasePath = normalizeBasePath(nextConfig.basePath); + process.env.WORKFLOW_BASE_PATH = workflowBasePath; + nextConfig.env = { + ...nextConfig.env, + WORKFLOW_BASE_PATH: workflowBasePath, + }; nextConfig.serverExternalPackages = [ ...new Set([ ...(nextConfig.serverExternalPackages || []), diff --git a/packages/utils/src/base-path.test.ts b/packages/utils/src/base-path.test.ts new file mode 100644 index 0000000000..3a311470f7 --- /dev/null +++ b/packages/utils/src/base-path.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeBasePath } from './base-path.js'; + +describe('normalizeBasePath', () => { + it.each([ + [undefined, ''], + ['', ''], + ['/', ''], + [' ', ''], + ['v2', '/v2'], + ['/v2', '/v2'], + ['/v2/', '/v2'], + [' /v2/ ', '/v2'], + ['/nested/base/', '/nested/base'], + ])('normalizes %p to %p', (input, expected) => { + expect(normalizeBasePath(input)).toBe(expected); + }); +}); diff --git a/packages/utils/src/base-path.ts b/packages/utils/src/base-path.ts new file mode 100644 index 0000000000..122a15933c --- /dev/null +++ b/packages/utils/src/base-path.ts @@ -0,0 +1,13 @@ +export function normalizeBasePath(basePath: string | undefined): string { + if (!basePath) { + return ''; + } + + const trimmed = basePath.trim(); + if (!trimmed || trimmed === '/') { + return ''; + } + + const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return withLeadingSlash.replace(/\/+$/, ''); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5d485cf047..420b22d3ed 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,4 +1,4 @@ -export { pluralize } from './pluralize.js'; +export { normalizeBasePath } from './base-path.js'; export { formatStepName, formatWorkflowName, @@ -8,6 +8,7 @@ export { stepDisplayName, workflowDisplayName, } from './parse-name.js'; +export { pluralize } from './pluralize.js'; export { once, type PromiseWithResolvers, withResolvers } from './promise.js'; export { parseDurationToDate } from './time.js'; export { diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts index 3bc9a5b779..d71dbba2f9 100644 --- a/packages/world-local/src/config.test.ts +++ b/packages/world-local/src/config.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { resolveBaseUrl } from './config'; +import { createWorkflowRouteUrl, resolveBaseUrl } from './config'; // Mock the getWorkflowPort function from @workflow/utils/get-port vi.mock('@workflow/utils/get-port', () => ({ @@ -227,6 +227,57 @@ describe('resolveBaseUrl', () => { expect(result).toBe('https://example.com'); expect(getWorkflowPort).not.toHaveBeenCalled(); }); + + it('should append config.basePath to port-derived URLs', async () => { + const { getWorkflowPort } = await import('@workflow/utils/get-port'); + process.env.PORT = '4173'; + + const result = await resolveBaseUrl({ + basePath: '/v2', + port: 5000, + }); + + expect(result).toBe('http://localhost:5000/v2'); + expect(getWorkflowPort).not.toHaveBeenCalled(); + }); + + it('should append WORKFLOW_BASE_PATH to PORT-derived URLs', async () => { + process.env.PORT = '4173'; + process.env.WORKFLOW_BASE_PATH = '/v2/'; + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:4173/v2'); + }); + + it('should not append WORKFLOW_BASE_PATH to explicit WORKFLOW_LOCAL_BASE_URL', async () => { + process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:3000/custom'; + process.env.WORKFLOW_BASE_PATH = '/v2'; + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:3000/custom'); + }); + }); + + describe('createWorkflowRouteUrl', () => { + it('builds workflow routes without a base path', () => { + expect(createWorkflowRouteUrl('http://localhost:3000', 'flow')).toBe( + 'http://localhost:3000/.well-known/workflow/v1/flow' + ); + }); + + it('preserves the base path from baseUrl', () => { + expect(createWorkflowRouteUrl('http://localhost:3000/v2', 'flow')).toBe( + 'http://localhost:3000/v2/.well-known/workflow/v1/flow' + ); + }); + + it('normalizes trailing slashes and drops search params', () => { + expect( + createWorkflowRouteUrl('http://localhost:3000/v2/?debug=1', 'step') + ).toBe('http://localhost:3000/v2/.well-known/workflow/v1/step'); + }); }); describe('edge cases', () => { diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index 923bda8e01..8bd5c0c8c9 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -1,3 +1,4 @@ +import { normalizeBasePath } from '@workflow/utils'; import { getWorkflowPort } from '@workflow/utils/get-port'; import { once } from './util.js'; @@ -11,10 +12,15 @@ const getBaseUrlFromEnv = () => { return process.env.WORKFLOW_LOCAL_BASE_URL; }; +const getBasePathFromEnv = () => { + return process.env.WORKFLOW_BASE_PATH; +}; + export type Config = { dataDir: string; port?: number; baseUrl?: string; + basePath?: string; /** * Whether start() should re-enqueue pending/running runs from storage. * Defaults to true. Test harnesses that always start from a clean slate can @@ -38,17 +44,42 @@ export type Config = { export const config = once(() => { const dataDir = getDataDirFromEnv(); const baseUrl = getBaseUrlFromEnv(); + const basePath = getBasePathFromEnv(); - return { dataDir, baseUrl }; + return { dataDir, baseUrl, basePath }; }); +function resolveBasePath(config: Partial): string { + return normalizeBasePath(config.basePath ?? process.env.WORKFLOW_BASE_PATH); +} + +export function resolveDirectBaseUrl(config: Partial): string { + return ( + config.baseUrl ?? + process.env.WORKFLOW_LOCAL_BASE_URL ?? + `http://localhost${resolveBasePath(config)}` + ); +} + +export function createWorkflowRouteUrl( + baseUrl: string, + pathname: 'flow' | 'step' +): string { + const url = new URL(baseUrl); + const basePath = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, ''); + url.pathname = `${basePath}/.well-known/workflow/v1/${pathname}`; + url.search = ''; + url.hash = ''; + return url.toString(); +} + /** * Resolves the base URL for queue requests following the priority order: * 1. config.baseUrl (highest priority - full override from args) * 2. WORKFLOW_LOCAL_BASE_URL env var (checked directly to handle late env var setting) - * 3. config.port (explicit port override from args) - * 4. PORT env var (explicit configuration) - * 5. Auto-detected port via getPort (detect actual listening port) + * 3. config.port (explicit port override from args), plus WORKFLOW_BASE_PATH + * 4. PORT env var (explicit configuration), plus WORKFLOW_BASE_PATH + * 5. Auto-detected port via getPort (detect actual listening port), plus WORKFLOW_BASE_PATH */ export async function resolveBaseUrl(config: Partial): Promise { if (config.baseUrl) { @@ -61,17 +92,19 @@ export async function resolveBaseUrl(config: Partial): Promise { return process.env.WORKFLOW_LOCAL_BASE_URL; } + const basePath = resolveBasePath(config); + if (typeof config.port === 'number') { - return `http://localhost:${config.port}`; + return `http://localhost:${config.port}${basePath}`; } if (process.env.PORT) { - return `http://localhost:${process.env.PORT}`; + return `http://localhost:${process.env.PORT}${basePath}`; } const detectedPort = await getWorkflowPort(); if (detectedPort) { - return `http://localhost:${detectedPort}`; + return `http://localhost:${detectedPort}${basePath}`; } throw new Error('Unable to resolve base URL for workflow queue.'); diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index 4b57fbea28..dbacfa26a6 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -47,6 +47,7 @@ export type LocalWorld = World & { * @param args.dataDir - Directory for storing workflow data (default: `.workflow-data/`) * @param args.port - Port override for queue transport (default: auto-detected) * @param args.baseUrl - Full base URL override for queue transport (default: `http://localhost:{port}`) + * @param args.basePath - Path prefix for queue transport when baseUrl is not set (default: `WORKFLOW_BASE_PATH`) * @param args.recoverActiveRuns - Whether `start()` should re-enqueue pending/running runs from storage (default: `true`) * @param args.tag - Optional tag to scope files (e.g., `vitest-0`). When set, files are written * as `{id}.{tag}.json` and `clear()` only deletes files matching this tag. diff --git a/packages/world-local/src/queue.test.ts b/packages/world-local/src/queue.test.ts index 9e190e1ac7..5b532500af 100644 --- a/packages/world-local/src/queue.test.ts +++ b/packages/world-local/src/queue.test.ts @@ -345,6 +345,48 @@ describe('queue delaySeconds', () => { }); }); +describe('queue basePath routing', () => { + let localQueue: ReturnType; + + afterEach(async () => { + await localQueue?.close(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('posts HTTP deliveries under the baseUrl path', async () => { + localQueue = createQueue({ baseUrl: 'http://localhost:3000/v2' }); + const fetchMock = vi.fn(async () => + Response.json({ ok: true }, { status: 200 }) + ); + vi.stubGlobal('fetch', fetchMock); + + await localQueue.queue('__wkf_workflow_test' as any, { + runId: 'run_01ABC', + }); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'http://localhost:3000/v2/.well-known/workflow/v1/flow' + ); + }); + + it('uses basePath when delivering through a direct handler', async () => { + localQueue = createQueue({ basePath: '/v2' }); + const handler = vi.fn(async (req: Request) => { + expect(req.url).toBe('http://localhost/v2/.well-known/workflow/v1/flow'); + return Response.json({ ok: true }, { status: 200 }); + }); + localQueue.registerHandler('__wkf_workflow_', handler); + + await localQueue.queue('__wkf_workflow_test' as any, { + runId: 'run_01ABC', + }); + + await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce()); + }); +}); + /** undici's shape for a saturated-local-server connect timeout. */ function fetchFailedTimeout(): TypeError { const err = new TypeError('fetch failed'); diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index b803fd43a3..d8a72ca92b 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -12,7 +12,11 @@ import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; import { z } from 'zod/v4'; import type { Config } from './config.js'; -import { resolveBaseUrl } from './config.js'; +import { + createWorkflowRouteUrl, + resolveBaseUrl, + resolveDirectBaseUrl, +} from './config.js'; import { jsonReplacer, jsonReviver } from './fs.js'; import { getPackageInfo } from './init.js'; @@ -212,28 +216,27 @@ export function createQueue(config: Partial): LocalQueue { try { if (directHandler) { - const req = new Request( - `http://localhost/.well-known/workflow/v1/${pathname}`, - { - method: 'POST', - headers, - body, - } + const url = createWorkflowRouteUrl( + resolveDirectBaseUrl(config), + pathname ); + const req = new Request(url, { + method: 'POST', + headers, + body, + }); response = await directHandler(req); } else { const baseUrl = await resolveBaseUrl(config); + const url = createWorkflowRouteUrl(baseUrl, pathname); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- undici v7 dispatcher types don't match @types/node's RequestInit - response = await fetch( - `${baseUrl}/.well-known/workflow/v1/${pathname}`, - { - method: 'POST', - duplex: 'half', - dispatcher: httpAgent, - headers, - body, - } as any - ); + response = await fetch(url, { + method: 'POST', + duplex: 'half', + dispatcher: httpAgent, + headers, + body, + } as any); } } catch (err) { // The delivery never reached the handler: undici threw before a From 18dd2c5e66eb64fd2e3e70a89c8641b042e3f628 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:59:06 -0700 Subject: [PATCH 2/9] refactor: simplify basePath URL handling --- packages/core/src/runtime/base-url.test.ts | 6 +++--- packages/core/src/runtime/base-url.ts | 19 +++++++++---------- packages/core/src/runtime/step-executor.ts | 9 +++++++-- packages/core/src/runtime/step-handler.ts | 5 ++++- packages/core/src/workflow.ts | 11 +++++++---- packages/next/src/index.ts | 16 +--------------- packages/world-local/src/config.test.ts | 13 ------------- packages/world-local/src/config.ts | 18 ++++++------------ packages/world-local/src/index.ts | 1 - packages/world-local/src/queue.test.ts | 6 ++++-- 10 files changed, 41 insertions(+), 63 deletions(-) diff --git a/packages/core/src/runtime/base-url.test.ts b/packages/core/src/runtime/base-url.test.ts index fac49df8f8..693ac0738c 100644 --- a/packages/core/src/runtime/base-url.test.ts +++ b/packages/core/src/runtime/base-url.test.ts @@ -20,7 +20,7 @@ describe('getWorkflowBaseUrl', () => { it('uses localhost without a base path by default', () => { delete process.env.WORKFLOW_BASE_PATH; - expect(getWorkflowBaseUrl({ isVercel: false, port: 3000 })).toBe( + expect(getWorkflowBaseUrl({ type: 'local', port: 3000 })).toBe( 'http://localhost:3000' ); }); @@ -28,7 +28,7 @@ describe('getWorkflowBaseUrl', () => { it('includes WORKFLOW_BASE_PATH for local metadata URLs', () => { process.env.WORKFLOW_BASE_PATH = '/v2'; - expect(getWorkflowBaseUrl({ isVercel: false, port: 3000 })).toBe( + expect(getWorkflowBaseUrl({ type: 'local', port: 3000 })).toBe( 'http://localhost:3000/v2' ); }); @@ -37,7 +37,7 @@ describe('getWorkflowBaseUrl', () => { process.env.VERCEL_URL = 'example.vercel.app'; process.env.WORKFLOW_BASE_PATH = '/v2/'; - expect(getWorkflowBaseUrl({ isVercel: true })).toBe( + expect(getWorkflowBaseUrl({ type: 'vercel' })).toBe( 'https://example.vercel.app/v2' ); }); diff --git a/packages/core/src/runtime/base-url.ts b/packages/core/src/runtime/base-url.ts index 60d2020eb6..bdb545c383 100644 --- a/packages/core/src/runtime/base-url.ts +++ b/packages/core/src/runtime/base-url.ts @@ -1,15 +1,14 @@ import { normalizeBasePath } from '@workflow/utils'; -export function getWorkflowBaseUrl({ - isVercel, - port, -}: { - isVercel: boolean; - port?: number; -}): string { - const origin = isVercel - ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${port ?? 3000}`; +export type WorkflowBaseUrlTarget = + | { type: 'vercel' } + | { type: 'local'; port: number }; + +export function getWorkflowBaseUrl(target: WorkflowBaseUrlTarget): string { + const origin = + target.type === 'vercel' + ? `https://${process.env.VERCEL_URL}` + : `http://localhost:${target.port}`; return `${origin}${normalizeBasePath(process.env.WORKFLOW_BASE_PATH)}`; } diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 902eb5370f..4aba0a58f6 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -563,7 +563,12 @@ export async function executeStep( const args = hydratedInput.args; const thisVal = hydratedInput.thisVal ?? null; - const port = isVercel ? undefined : await getPortLazy(); + const workflowBaseUrl = isVercel + ? getWorkflowBaseUrl({ type: 'vercel' }) + : getWorkflowBaseUrl({ + type: 'local', + port: (await getPortLazy()) ?? 3000, + }); const executionStartTime = Date.now(); result = await trace('step.execute', {}, async () => { @@ -579,7 +584,7 @@ export async function executeStep( workflowName, workflowRunId, workflowStartedAt: new Date(+workflowStartedAt), - url: getWorkflowBaseUrl({ isVercel, port }), + url: workflowBaseUrl, features: { encryption: !!encryptionKey }, }, workflowDeploymentId: params.workflowDeploymentId, diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index ee41906805..40602bd165 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -222,6 +222,9 @@ function createStepHandler(namespace?: string) { isVercel ? undefined : getPort(), getSpanKind('CONSUMER'), ]); + const workflowBaseUrl = isVercel + ? getWorkflowBaseUrl({ type: 'vercel' }) + : getWorkflowBaseUrl({ type: 'local', port: port ?? 3000 }); return trace( `step.execute ${stepDisplayName(stepName)}`, @@ -667,7 +670,7 @@ function createStepHandler(namespace?: string) { workflowName, workflowRunId, workflowStartedAt: new Date(+workflowStartedAt), - url: getWorkflowBaseUrl({ isVercel, port }), + url: workflowBaseUrl, features: { encryption: !!encryptionKey }, }, workflowDeploymentId: process.env.VERCEL_DEPLOYMENT_ID, diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 3e8460ddc9..5de6217f61 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -195,7 +195,12 @@ 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 workflowBaseUrl = isVercel + ? getWorkflowBaseUrl({ type: 'vercel' }) + : getWorkflowBaseUrl({ + type: 'local', + port: (await getPortLazy()) ?? 3000, + }); const { context, @@ -312,14 +317,12 @@ export async function runWorkflow( vmGlobalThis[WORKFLOW_GET_STREAM_ID] = (namespace?: string) => getWorkflowRunStreamId(workflowRun.runId, namespace); - const url = getWorkflowBaseUrl({ isVercel, port }); - // 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 }, }; diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 2c92f23d10..fcc5e9f49e 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -28,20 +28,6 @@ const workflowSerdeComputedPropertyPattern = const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']); const warnedAutoRemovedServerExternalPackages = new Set(); -function normalizeBasePath(basePath: string | undefined): string { - if (!basePath) { - return ''; - } - - const trimmed = basePath.trim(); - if (!trimmed || trimmed === '/') { - return ''; - } - - const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - return withLeadingSlash.replace(/\/+$/, ''); -} - interface WorkflowPatternMatch { hasUseWorkflow: boolean; hasUseStep: boolean; @@ -389,7 +375,7 @@ export function withWorkflow( } // shallow clone to avoid read-only on top-level nextConfig = Object.assign({}, nextConfig); - const workflowBasePath = normalizeBasePath(nextConfig.basePath); + const workflowBasePath = nextConfig.basePath ?? ''; process.env.WORKFLOW_BASE_PATH = workflowBasePath; nextConfig.env = { ...nextConfig.env, diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts index d71dbba2f9..7de3221af2 100644 --- a/packages/world-local/src/config.test.ts +++ b/packages/world-local/src/config.test.ts @@ -228,19 +228,6 @@ describe('resolveBaseUrl', () => { expect(getWorkflowPort).not.toHaveBeenCalled(); }); - it('should append config.basePath to port-derived URLs', async () => { - const { getWorkflowPort } = await import('@workflow/utils/get-port'); - process.env.PORT = '4173'; - - const result = await resolveBaseUrl({ - basePath: '/v2', - port: 5000, - }); - - expect(result).toBe('http://localhost:5000/v2'); - expect(getWorkflowPort).not.toHaveBeenCalled(); - }); - it('should append WORKFLOW_BASE_PATH to PORT-derived URLs', async () => { process.env.PORT = '4173'; process.env.WORKFLOW_BASE_PATH = '/v2/'; diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index 8bd5c0c8c9..19934ca6e5 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -12,15 +12,10 @@ const getBaseUrlFromEnv = () => { return process.env.WORKFLOW_LOCAL_BASE_URL; }; -const getBasePathFromEnv = () => { - return process.env.WORKFLOW_BASE_PATH; -}; - export type Config = { dataDir: string; port?: number; baseUrl?: string; - basePath?: string; /** * Whether start() should re-enqueue pending/running runs from storage. * Defaults to true. Test harnesses that always start from a clean slate can @@ -44,20 +39,19 @@ export type Config = { export const config = once(() => { const dataDir = getDataDirFromEnv(); const baseUrl = getBaseUrlFromEnv(); - const basePath = getBasePathFromEnv(); - return { dataDir, baseUrl, basePath }; + return { dataDir, baseUrl }; }); -function resolveBasePath(config: Partial): string { - return normalizeBasePath(config.basePath ?? process.env.WORKFLOW_BASE_PATH); +function resolveBasePath(): string { + return normalizeBasePath(process.env.WORKFLOW_BASE_PATH); } export function resolveDirectBaseUrl(config: Partial): string { return ( config.baseUrl ?? process.env.WORKFLOW_LOCAL_BASE_URL ?? - `http://localhost${resolveBasePath(config)}` + `http://localhost${resolveBasePath()}` ); } @@ -66,7 +60,7 @@ export function createWorkflowRouteUrl( pathname: 'flow' | 'step' ): string { const url = new URL(baseUrl); - const basePath = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, ''); + const basePath = normalizeBasePath(url.pathname); url.pathname = `${basePath}/.well-known/workflow/v1/${pathname}`; url.search = ''; url.hash = ''; @@ -92,7 +86,7 @@ export async function resolveBaseUrl(config: Partial): Promise { return process.env.WORKFLOW_LOCAL_BASE_URL; } - const basePath = resolveBasePath(config); + const basePath = resolveBasePath(); if (typeof config.port === 'number') { return `http://localhost:${config.port}${basePath}`; diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index dbacfa26a6..4b57fbea28 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -47,7 +47,6 @@ export type LocalWorld = World & { * @param args.dataDir - Directory for storing workflow data (default: `.workflow-data/`) * @param args.port - Port override for queue transport (default: auto-detected) * @param args.baseUrl - Full base URL override for queue transport (default: `http://localhost:{port}`) - * @param args.basePath - Path prefix for queue transport when baseUrl is not set (default: `WORKFLOW_BASE_PATH`) * @param args.recoverActiveRuns - Whether `start()` should re-enqueue pending/running runs from storage (default: `true`) * @param args.tag - Optional tag to scope files (e.g., `vitest-0`). When set, files are written * as `{id}.{tag}.json` and `clear()` only deletes files matching this tag. diff --git a/packages/world-local/src/queue.test.ts b/packages/world-local/src/queue.test.ts index 5b532500af..2f666075a6 100644 --- a/packages/world-local/src/queue.test.ts +++ b/packages/world-local/src/queue.test.ts @@ -262,7 +262,7 @@ describe('queue delaySeconds', () => { // Real-ish sleep: never resolves, rejects with AbortError on signal // abort — mirrors node:timers/promises semantics for long delays. vi.mocked(mockSetTimeout).mockImplementationOnce( - (_delay?: number, value?: unknown, opts?: { signal?: AbortSignal }) => + (_delay?: number, _value?: unknown, opts?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { opts?.signal?.addEventListener('abort', () => { const err = new Error('The operation was aborted'); @@ -351,6 +351,7 @@ describe('queue basePath routing', () => { afterEach(async () => { await localQueue?.close(); vi.restoreAllMocks(); + vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); @@ -372,7 +373,8 @@ describe('queue basePath routing', () => { }); it('uses basePath when delivering through a direct handler', async () => { - localQueue = createQueue({ basePath: '/v2' }); + vi.stubEnv('WORKFLOW_BASE_PATH', '/v2'); + localQueue = createQueue({}); const handler = vi.fn(async (req: Request) => { expect(req.url).toBe('http://localhost/v2/.well-known/workflow/v1/flow'); return Response.json({ ok: true }, { status: 200 }); From c4e31b7217b01809982dcc69f2e35159f70a3ef4 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:50:18 -0700 Subject: [PATCH 3/9] fix: probe basePath route when detecting workflow port --- packages/world-local/src/config.test.ts | 14 ++++++++++++++ packages/world-local/src/config.ts | 9 ++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts index 7de3221af2..4aca07fe89 100644 --- a/packages/world-local/src/config.test.ts +++ b/packages/world-local/src/config.test.ts @@ -237,6 +237,20 @@ describe('resolveBaseUrl', () => { expect(result).toBe('http://localhost:4173/v2'); }); + it('should probe auto-detected ports under WORKFLOW_BASE_PATH', async () => { + const { getWorkflowPort } = await import('@workflow/utils/get-port'); + vi.mocked(getWorkflowPort).mockResolvedValue(5173); + delete process.env.PORT; + process.env.WORKFLOW_BASE_PATH = '/v2/'; + + const result = await resolveBaseUrl({}); + + expect(result).toBe('http://localhost:5173/v2'); + expect(getWorkflowPort).toHaveBeenCalledWith({ + endpoint: '/v2/.well-known/workflow/v1/flow?__health', + }); + }); + it('should not append WORKFLOW_BASE_PATH to explicit WORKFLOW_LOCAL_BASE_URL', async () => { process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:3000/custom'; process.env.WORKFLOW_BASE_PATH = '/v2'; diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index 19934ca6e5..6657c8ef70 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -7,6 +7,7 @@ const getDataDirFromEnv = () => { }; export const DEFAULT_RESOLVE_DATA_OPTION = 'all'; +const WORKFLOW_FLOW_HEALTH_PATH = '/.well-known/workflow/v1/flow?__health'; const getBaseUrlFromEnv = () => { return process.env.WORKFLOW_LOCAL_BASE_URL; @@ -47,6 +48,10 @@ function resolveBasePath(): string { return normalizeBasePath(process.env.WORKFLOW_BASE_PATH); } +function getWorkflowProbeEndpoint(): string { + return `${resolveBasePath()}${WORKFLOW_FLOW_HEALTH_PATH}`; +} + export function resolveDirectBaseUrl(config: Partial): string { return ( config.baseUrl ?? @@ -96,7 +101,9 @@ export async function resolveBaseUrl(config: Partial): Promise { return `http://localhost:${process.env.PORT}${basePath}`; } - const detectedPort = await getWorkflowPort(); + const detectedPort = await getWorkflowPort({ + endpoint: getWorkflowProbeEndpoint(), + }); if (detectedPort) { return `http://localhost:${detectedPort}${basePath}`; } From d32401ebe3b97011ea79d1faa48d4b70d03f8f3d Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:04:43 -0700 Subject: [PATCH 4/9] fix: apply basePath routing to postgres world --- .changeset/next-base-path.md | 1 + packages/utils/src/index.ts | 5 +++ packages/utils/src/workflow-route.test.ts | 33 +++++++++++++++++ packages/utils/src/workflow-route.ts | 20 +++++++++++ packages/world-local/src/config.test.ts | 22 +----------- packages/world-local/src/config.ts | 24 +++---------- packages/world-local/src/queue.ts | 7 ++-- packages/world-postgres/src/queue.test.ts | 44 ++++++++++++++++++++--- packages/world-postgres/src/queue.ts | 32 ++++++++++------- 9 files changed, 125 insertions(+), 63 deletions(-) create mode 100644 packages/utils/src/workflow-route.test.ts create mode 100644 packages/utils/src/workflow-route.ts diff --git a/.changeset/next-base-path.md b/.changeset/next-base-path.md index 3e4ef32f3a..b803988677 100644 --- a/.changeset/next-base-path.md +++ b/.changeset/next-base-path.md @@ -3,6 +3,7 @@ "@workflow/next": patch "@workflow/utils": patch "@workflow/world-local": patch +"@workflow/world-postgres": patch --- Respect Next.js basePath when constructing workflow runtime URLs. diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 420b22d3ed..ec12560cb7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -11,6 +11,11 @@ export { export { pluralize } from './pluralize.js'; export { once, type PromiseWithResolvers, withResolvers } from './promise.js'; export { parseDurationToDate } from './time.js'; +export { + createWorkflowHealthEndpoint, + createWorkflowRouteUrl, + type WorkflowRoutePathname, +} from './workflow-route.js'; export { isVercelWorldTarget, resolveWorkflowTargetWorld, diff --git a/packages/utils/src/workflow-route.test.ts b/packages/utils/src/workflow-route.test.ts new file mode 100644 index 0000000000..ed59bc1d74 --- /dev/null +++ b/packages/utils/src/workflow-route.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + createWorkflowHealthEndpoint, + createWorkflowRouteUrl, +} from './workflow-route.js'; + +describe('createWorkflowRouteUrl', () => { + it('builds workflow routes without a base path', () => { + expect(createWorkflowRouteUrl('http://localhost:3000', 'flow')).toBe( + 'http://localhost:3000/.well-known/workflow/v1/flow' + ); + }); + + it('preserves the base path from baseUrl', () => { + expect(createWorkflowRouteUrl('http://localhost:3000/v2', 'flow')).toBe( + 'http://localhost:3000/v2/.well-known/workflow/v1/flow' + ); + }); + + it('normalizes trailing slashes and drops search params', () => { + expect( + createWorkflowRouteUrl('http://localhost:3000/v2/?debug=1', 'step') + ).toBe('http://localhost:3000/v2/.well-known/workflow/v1/step'); + }); +}); + +describe('createWorkflowHealthEndpoint', () => { + it('builds the workflow health endpoint under the base path', () => { + expect(createWorkflowHealthEndpoint('/v2/')).toBe( + '/v2/.well-known/workflow/v1/flow?__health' + ); + }); +}); diff --git a/packages/utils/src/workflow-route.ts b/packages/utils/src/workflow-route.ts new file mode 100644 index 0000000000..8e9fd38ce5 --- /dev/null +++ b/packages/utils/src/workflow-route.ts @@ -0,0 +1,20 @@ +import { normalizeBasePath } from './base-path.js'; + +const WORKFLOW_ROUTE_BASE = '/.well-known/workflow/v1'; + +export type WorkflowRoutePathname = 'flow' | 'step'; + +export function createWorkflowRouteUrl( + baseUrl: string, + pathname: WorkflowRoutePathname +): string { + const url = new URL(baseUrl); + url.pathname = `${normalizeBasePath(url.pathname)}${WORKFLOW_ROUTE_BASE}/${pathname}`; + url.search = ''; + url.hash = ''; + return url.toString(); +} + +export function createWorkflowHealthEndpoint(basePath: string | undefined) { + return `${normalizeBasePath(basePath)}${WORKFLOW_ROUTE_BASE}/flow?__health`; +} diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts index 4aca07fe89..91abe11a5f 100644 --- a/packages/world-local/src/config.test.ts +++ b/packages/world-local/src/config.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createWorkflowRouteUrl, resolveBaseUrl } from './config'; +import { resolveBaseUrl } from './config'; // Mock the getWorkflowPort function from @workflow/utils/get-port vi.mock('@workflow/utils/get-port', () => ({ @@ -261,26 +261,6 @@ describe('resolveBaseUrl', () => { }); }); - describe('createWorkflowRouteUrl', () => { - it('builds workflow routes without a base path', () => { - expect(createWorkflowRouteUrl('http://localhost:3000', 'flow')).toBe( - 'http://localhost:3000/.well-known/workflow/v1/flow' - ); - }); - - it('preserves the base path from baseUrl', () => { - expect(createWorkflowRouteUrl('http://localhost:3000/v2', 'flow')).toBe( - 'http://localhost:3000/v2/.well-known/workflow/v1/flow' - ); - }); - - it('normalizes trailing slashes and drops search params', () => { - expect( - createWorkflowRouteUrl('http://localhost:3000/v2/?debug=1', 'step') - ).toBe('http://localhost:3000/v2/.well-known/workflow/v1/step'); - }); - }); - describe('edge cases', () => { it('should throw error with empty config object when no port is detected', async () => { const { getWorkflowPort } = await import('@workflow/utils/get-port'); diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index 6657c8ef70..03d1091292 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -1,4 +1,7 @@ -import { normalizeBasePath } from '@workflow/utils'; +import { + createWorkflowHealthEndpoint, + normalizeBasePath, +} from '@workflow/utils'; import { getWorkflowPort } from '@workflow/utils/get-port'; import { once } from './util.js'; @@ -7,7 +10,6 @@ const getDataDirFromEnv = () => { }; export const DEFAULT_RESOLVE_DATA_OPTION = 'all'; -const WORKFLOW_FLOW_HEALTH_PATH = '/.well-known/workflow/v1/flow?__health'; const getBaseUrlFromEnv = () => { return process.env.WORKFLOW_LOCAL_BASE_URL; @@ -48,10 +50,6 @@ function resolveBasePath(): string { return normalizeBasePath(process.env.WORKFLOW_BASE_PATH); } -function getWorkflowProbeEndpoint(): string { - return `${resolveBasePath()}${WORKFLOW_FLOW_HEALTH_PATH}`; -} - export function resolveDirectBaseUrl(config: Partial): string { return ( config.baseUrl ?? @@ -60,18 +58,6 @@ export function resolveDirectBaseUrl(config: Partial): string { ); } -export function createWorkflowRouteUrl( - baseUrl: string, - pathname: 'flow' | 'step' -): string { - const url = new URL(baseUrl); - const basePath = normalizeBasePath(url.pathname); - url.pathname = `${basePath}/.well-known/workflow/v1/${pathname}`; - url.search = ''; - url.hash = ''; - return url.toString(); -} - /** * Resolves the base URL for queue requests following the priority order: * 1. config.baseUrl (highest priority - full override from args) @@ -102,7 +88,7 @@ export async function resolveBaseUrl(config: Partial): Promise { } const detectedPort = await getWorkflowPort({ - endpoint: getWorkflowProbeEndpoint(), + endpoint: createWorkflowHealthEndpoint(process.env.WORKFLOW_BASE_PATH), }); if (detectedPort) { return `http://localhost:${detectedPort}${basePath}`; diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index d8a72ca92b..0846513115 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -1,5 +1,6 @@ import { setTimeout } from 'node:timers/promises'; import type { Transport } from '@vercel/queue'; +import { createWorkflowRouteUrl } from '@workflow/utils'; import { MessageId, parseQueueName, @@ -12,11 +13,7 @@ import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; import { z } from 'zod/v4'; import type { Config } from './config.js'; -import { - createWorkflowRouteUrl, - resolveBaseUrl, - resolveDirectBaseUrl, -} from './config.js'; +import { resolveBaseUrl, resolveDirectBaseUrl } from './config.js'; import { jsonReplacer, jsonReviver } from './fs.js'; import { getPackageInfo } from './init.js'; diff --git a/packages/world-postgres/src/queue.test.ts b/packages/world-postgres/src/queue.test.ts index f7d975d799..ceda9fc9d4 100644 --- a/packages/world-postgres/src/queue.test.ts +++ b/packages/world-postgres/src/queue.test.ts @@ -79,6 +79,7 @@ describe('postgres queue http execution', () => { ); vi.useRealTimers(); delete process.env.WORKFLOW_LOCAL_BASE_URL; + delete process.env.WORKFLOW_BASE_PATH; delete process.env.PORT; }); @@ -145,13 +146,14 @@ describe('postgres queue http execution', () => { }> = []; const port = await getUnusedLoopbackPort(); vi.mocked(getWorkflowPort).mockResolvedValue(port); + process.env.WORKFLOW_BASE_PATH = '/v2/'; const queue = buildQueue({ connectionString: 'postgres://test' }, pool); await queue.start(); expect(run).not.toHaveBeenCalled(); - await startWorkflowHttpServer(requests, port); + await startWorkflowHttpServer(requests, port, '/v2'); await vi.waitFor(() => { expect(run).toHaveBeenCalledTimes(1); }); @@ -170,11 +172,13 @@ describe('postgres queue http execution', () => { await expect(task(payload, {} as any)).resolves.toBeUndefined(); - expect(getWorkflowPort).toHaveBeenCalled(); + expect(getWorkflowPort).toHaveBeenCalledWith({ + endpoint: '/v2/.well-known/workflow/v1/flow?__health', + }); expect(requests).toEqual([ expect.objectContaining({ method: 'POST', - url: '/.well-known/workflow/v1/step', + url: '/v2/.well-known/workflow/v1/step', }), ]); }); @@ -365,6 +369,32 @@ describe('postgres queue http execution', () => { } }); + it('posts workflow requests under the explicit base URL path', async () => { + const fetchMock = vi.fn(async () => Response.json({ ok: true })); + vi.stubGlobal('fetch', fetchMock); + process.env.WORKFLOW_LOCAL_BASE_URL = 'https://workflow.example.test/v2/'; + + const queue = buildQueue({ connectionString: 'postgres://test' }, pool); + try { + await queue.start(); + + const task = getTaskHandler('workflow_flows'); + await task( + buildMessageData('__wkf_workflow_test-workflow', { + runId: 'wrun_01ABC', + }), + {} as any + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://workflow.example.test/v2/.well-known/workflow/v1/flow', + expect.objectContaining({ method: 'POST' }) + ); + } finally { + vi.unstubAllGlobals(); + } + }); + it('queues producer delays and headers in graphile job metadata', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); @@ -487,7 +517,8 @@ async function startWorkflowHttpServer( headers: Record; body: string; }>, - port = 0 + port = 0, + basePath = '' ) { const server = createServer(async (req, res) => { const body = await new Promise((resolve, reject) => { @@ -508,7 +539,10 @@ async function startWorkflowHttpServer( }; requests.push(request); - if (req.method === 'POST' && req.url === '/.well-known/workflow/v1/step') { + if ( + req.method === 'POST' && + req.url === `${basePath}/.well-known/workflow/v1/step` + ) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: true })); return; diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index 962f74f6d4..fdb31a9aa3 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -2,6 +2,11 @@ import { connect } from 'node:net'; import * as Stream from 'node:stream'; import { setTimeout as sleep } from 'node:timers/promises'; import type { Transport } from '@vercel/queue'; +import { + createWorkflowHealthEndpoint, + createWorkflowRouteUrl, + normalizeBasePath, +} from '@workflow/utils'; import { getWorkflowPort } from '@workflow/utils/get-port'; import { getQueuePrefixKind, @@ -216,17 +221,21 @@ export function createQueue( return process.env.WORKFLOW_LOCAL_BASE_URL; } + const basePath = normalizeBasePath(process.env.WORKFLOW_BASE_PATH); + if (typeof port === 'number') { - return `http://localhost:${port}`; + return `http://localhost:${port}${basePath}`; } if (process.env.PORT) { - return `http://localhost:${process.env.PORT}`; + return `http://localhost:${process.env.PORT}${basePath}`; } - const detectedPort = await getWorkflowPort(); + const detectedPort = await getWorkflowPort({ + endpoint: createWorkflowHealthEndpoint(process.env.WORKFLOW_BASE_PATH), + }); if (typeof detectedPort === 'number') { - return `http://localhost:${detectedPort}`; + return `http://localhost:${detectedPort}${basePath}`; } return undefined; @@ -359,15 +368,12 @@ export function createQueue( } const pathname = getQueueRoute(queueName); - const response = await fetch( - `${baseUrl}/.well-known/workflow/v1/${pathname}`, - { - method: 'POST', - duplex: 'half', - headers, - body, - } as any - ); + const response = await fetch(createWorkflowRouteUrl(baseUrl, pathname), { + method: 'POST', + duplex: 'half', + headers, + body, + } as any); const text = await response.text(); if (!response.ok) { From 661c556a21e812855365678c1b674c894f2c3159 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:35:02 -0700 Subject: [PATCH 5/9] fix: derive workflow basePath from Next config --- .changeset/next-base-path.md | 1 + packages/builders/src/base-builder.ts | 3 ++ packages/builders/src/constants.test.ts | 9 +++++- packages/builders/src/constants.ts | 5 ++++ packages/builders/src/types.ts | 3 ++ packages/core/src/runtime.ts | 11 ++++++- packages/core/src/runtime/base-url.test.ts | 13 +++++---- packages/core/src/runtime/base-url.ts | 4 +-- packages/next/src/index.test.ts | 34 +++++++++++++++------- packages/next/src/index.ts | 14 +++++---- packages/utils/src/index.ts | 4 +++ packages/utils/src/workflow-config.test.ts | 20 +++++++++++++ packages/utils/src/workflow-config.ts | 15 ++++++++++ packages/utils/src/workflow-route.test.ts | 15 +++++++++- packages/utils/src/workflow-route.ts | 5 +++- packages/world-local/src/config.test.ts | 14 +++++---- packages/world-local/src/config.ts | 12 ++++---- packages/world-local/src/queue.test.ts | 4 ++- packages/world-postgres/src/queue.test.ts | 5 ++-- packages/world-postgres/src/queue.ts | 6 ++-- 20 files changed, 152 insertions(+), 45 deletions(-) create mode 100644 packages/utils/src/workflow-config.test.ts create mode 100644 packages/utils/src/workflow-config.ts diff --git a/.changeset/next-base-path.md b/.changeset/next-base-path.md index b803988677..a3db430b64 100644 --- a/.changeset/next-base-path.md +++ b/.changeset/next-base-path.md @@ -1,5 +1,6 @@ --- "@workflow/core": patch +"@workflow/builders": patch "@workflow/next": patch "@workflow/utils": patch "@workflow/world-local": patch diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index c0e1278dab..066914f760 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -1484,6 +1484,7 @@ export const __steps_registered = true; const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( { + basePath: this.config.basePath, routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', } ); @@ -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', }); @@ -1764,6 +1766,7 @@ export const POST = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCo const escaped = interimBundleText.replace(/[\\`$]/g, '\\$&'); const workflowEntrypointOptionsCode = createWorkflowEntrypointOptionsCode( { + basePath: this.config.basePath, routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', } ); diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts index 3e086100f9..cbc2e6f4fe 100644 --- a/packages/builders/src/constants.test.ts +++ b/packages/builders/src/constants.test.ts @@ -41,6 +41,12 @@ describe('createWorkflowEntrypointOptionsCode', () => { ); }); + it('inlines a framework base path', () => { + expect(createWorkflowEntrypointOptionsCode({ basePath: '/v2' })).toBe( + ', { basePath: "/v2" }' + ); + }); + it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => { process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom'; @@ -53,10 +59,11 @@ describe('createWorkflowEntrypointOptionsCode', () => { expect( createWorkflowEntrypointOptionsCode({ namespace: 'custom', + basePath: '/v2', routeModuleBodyStartedAt: 'workflowRouteModuleBodyStartedAt', }) ).toBe( - ', { namespace: "custom", routeModuleBodyStartedAt: workflowRouteModuleBodyStartedAt }' + ', { namespace: "custom", basePath: "/v2", routeModuleBodyStartedAt: workflowRouteModuleBodyStartedAt }' ); }); }); diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index 7d063890e8..24e52a1d55 100644 --- a/packages/builders/src/constants.ts +++ b/packages/builders/src/constants.ts @@ -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; }) { @@ -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}` diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts index 74fcd3b885..dfd6f3dab3 100644 --- a/packages/builders/src/types.ts +++ b/packages/builders/src/types.ts @@ -60,6 +60,9 @@ interface BaseWorkflowConfig { // artifact locations. distDir?: string; + // Optional URL path prefix for frameworks that serve routes under one. + basePath?: string; + // Suppress informational logs emitted by createWorkflowsBundle() // (e.g. intermediate/final workflow bundle timing logs). suppressCreateWorkflowsBundleLogs?: boolean; diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 7c533c242c..27a08efdcc 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -9,6 +9,7 @@ import { RunExpiredError, WorkflowRuntimeError, } from '@workflow/errors'; +import { setWorkflowBasePath } from '@workflow/utils'; import { parseWorkflowName, workflowDisplayName, @@ -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 { + if (options?.basePath !== undefined) { + setWorkflowBasePath(options.basePath); + } + const NO_INLINE_REPLAY_AFTER_MS = Number(process.env.WORKFLOW_V2_TIMEOUT_MS) || 120_000; diff --git a/packages/core/src/runtime/base-url.test.ts b/packages/core/src/runtime/base-url.test.ts index 693ac0738c..f570b11c19 100644 --- a/packages/core/src/runtime/base-url.test.ts +++ b/packages/core/src/runtime/base-url.test.ts @@ -1,13 +1,14 @@ +import { setWorkflowBasePath } from '@workflow/utils'; import { afterEach, describe, expect, it } from 'vitest'; import { getWorkflowBaseUrl } from './base-url.js'; describe('getWorkflowBaseUrl', () => { const originalEnv = { VERCEL_URL: process.env.VERCEL_URL, - WORKFLOW_BASE_PATH: process.env.WORKFLOW_BASE_PATH, }; afterEach(() => { + setWorkflowBasePath(undefined); for (const [key, value] of Object.entries(originalEnv)) { if (value === undefined) { delete process.env[key]; @@ -18,24 +19,24 @@ describe('getWorkflowBaseUrl', () => { }); it('uses localhost without a base path by default', () => { - delete process.env.WORKFLOW_BASE_PATH; + setWorkflowBasePath(undefined); expect(getWorkflowBaseUrl({ type: 'local', port: 3000 })).toBe( 'http://localhost:3000' ); }); - it('includes WORKFLOW_BASE_PATH for local metadata URLs', () => { - process.env.WORKFLOW_BASE_PATH = '/v2'; + it('includes the configured base path for local metadata URLs', () => { + setWorkflowBasePath('/v2'); expect(getWorkflowBaseUrl({ type: 'local', port: 3000 })).toBe( 'http://localhost:3000/v2' ); }); - it('includes WORKFLOW_BASE_PATH for Vercel metadata URLs', () => { + it('includes the configured base path for Vercel metadata URLs', () => { process.env.VERCEL_URL = 'example.vercel.app'; - process.env.WORKFLOW_BASE_PATH = '/v2/'; + setWorkflowBasePath('/v2/'); expect(getWorkflowBaseUrl({ type: 'vercel' })).toBe( 'https://example.vercel.app/v2' diff --git a/packages/core/src/runtime/base-url.ts b/packages/core/src/runtime/base-url.ts index bdb545c383..532704e3fa 100644 --- a/packages/core/src/runtime/base-url.ts +++ b/packages/core/src/runtime/base-url.ts @@ -1,4 +1,4 @@ -import { normalizeBasePath } from '@workflow/utils'; +import { getWorkflowBasePath } from '@workflow/utils'; export type WorkflowBaseUrlTarget = | { type: 'vercel' } @@ -10,5 +10,5 @@ export function getWorkflowBaseUrl(target: WorkflowBaseUrlTarget): string { ? `https://${process.env.VERCEL_URL}` : `http://localhost:${target.port}`; - return `${origin}${normalizeBasePath(process.env.WORKFLOW_BASE_PATH)}`; + return `${origin}${getWorkflowBasePath()}`; } diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 8df08a81e4..c298853afa 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -50,6 +50,19 @@ 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'); + +function workflowBasePathGlobal() { + return globalThis as typeof globalThis & Record; +} + +function setWorkflowBasePath(basePath: string | undefined): void { + workflowBasePathGlobal()[BASE_PATH_SYMBOL] = basePath; +} + +function getWorkflowBasePath(): string { + return workflowBasePathGlobal()[BASE_PATH_SYMBOL] ?? ''; +} function writeFile(path: string, contents: string): void { mkdirSync(dirname(path), { recursive: true }); @@ -62,7 +75,6 @@ describe('withWorkflow builder config', () => { PORT: process.env.PORT, VERCEL_DEPLOYMENT_ID: process.env.VERCEL_DEPLOYMENT_ID, WORKFLOW_LOCAL_DATA_DIR: process.env.WORKFLOW_LOCAL_DATA_DIR, - WORKFLOW_BASE_PATH: process.env.WORKFLOW_BASE_PATH, WORKFLOW_NEXT_PRIVATE_BUILT: process.env.WORKFLOW_NEXT_PRIVATE_BUILT, WORKFLOW_TARGET_WORLD: process.env.WORKFLOW_TARGET_WORLD, }; @@ -80,12 +92,13 @@ describe('withWorkflow builder config', () => { delete process.env.PORT; delete process.env.VERCEL_DEPLOYMENT_ID; delete process.env.WORKFLOW_LOCAL_DATA_DIR; - delete process.env.WORKFLOW_BASE_PATH; delete process.env.WORKFLOW_NEXT_PRIVATE_BUILT; delete process.env.WORKFLOW_TARGET_WORLD; + setWorkflowBasePath(undefined); }); afterEach(() => { + setWorkflowBasePath(undefined); if (!hadLoaderStub && existsSync(loaderStubPath)) { rmSync(loaderStubPath); } @@ -175,7 +188,7 @@ describe('withWorkflow builder config', () => { }); }); - it('propagates Next.js basePath to workflow runtime URLs', async () => { + it('configures workflow URLs from Next.js basePath', async () => { const config = withWorkflow({ basePath: '/v2', env: { @@ -187,25 +200,26 @@ describe('withWorkflow builder config', () => { defaultConfig: {}, }); - expect(process.env.WORKFLOW_BASE_PATH).toBe('/v2'); + expect(getWorkflowBasePath()).toBe('/v2'); expect(nextConfig.env).toEqual({ EXISTING_ENV: '1', - WORKFLOW_BASE_PATH: '/v2', + }); + expect(builderConfigs[0]).toMatchObject({ + basePath: '/v2', }); }); it('clears workflow basePath when Next.js basePath is not configured', async () => { - process.env.WORKFLOW_BASE_PATH = '/previous'; + setWorkflowBasePath('/previous'); const config = withWorkflow({}); const nextConfig = await config('phase-production-build', { defaultConfig: {}, }); - expect(process.env.WORKFLOW_BASE_PATH).toBe(''); - expect(nextConfig.env).toEqual({ - WORKFLOW_BASE_PATH: '', - }); + expect(getWorkflowBasePath()).toBe(''); + expect(nextConfig.env).toBeUndefined(); + expect(builderConfigs[0]).toMatchObject({ basePath: '' }); }); it('externalizes the built-in Vercel world while preserving user externals', async () => { diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index fcc5e9f49e..855fb362ea 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -27,6 +27,13 @@ const workflowSerdeComputedPropertyPattern = const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']); const warnedAutoRemovedServerExternalPackages = new Set(); +const BASE_PATH_SYMBOL = Symbol.for('@workflow/base-path'); + +function setWorkflowBasePath(basePath: string): void { + (globalThis as typeof globalThis & Record)[ + BASE_PATH_SYMBOL + ] = basePath; +} interface WorkflowPatternMatch { hasUseWorkflow: boolean; @@ -376,11 +383,7 @@ export function withWorkflow( // shallow clone to avoid read-only on top-level nextConfig = Object.assign({}, nextConfig); const workflowBasePath = nextConfig.basePath ?? ''; - process.env.WORKFLOW_BASE_PATH = workflowBasePath; - nextConfig.env = { - ...nextConfig.env, - WORKFLOW_BASE_PATH: workflowBasePath, - }; + setWorkflowBasePath(workflowBasePath); nextConfig.serverExternalPackages = [ ...new Set([ ...(nextConfig.serverExternalPackages || []), @@ -484,6 +487,7 @@ export function withWorkflow( moduleSpecifierRoot: process.cwd(), workingDir: process.cwd(), distDir, + basePath: workflowBasePath, diagnosticsDir: `${distDir}/diagnostics`, buildTarget: 'next', workflowsBundlePath: '', // not used in base diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index ec12560cb7..60bd0a1499 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -11,6 +11,10 @@ export { export { pluralize } from './pluralize.js'; export { once, type PromiseWithResolvers, withResolvers } from './promise.js'; export { parseDurationToDate } from './time.js'; +export { + getWorkflowBasePath, + setWorkflowBasePath, +} from './workflow-config.js'; export { createWorkflowHealthEndpoint, createWorkflowRouteUrl, diff --git a/packages/utils/src/workflow-config.test.ts b/packages/utils/src/workflow-config.test.ts new file mode 100644 index 0000000000..ce80981272 --- /dev/null +++ b/packages/utils/src/workflow-config.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getWorkflowBasePath, setWorkflowBasePath } from './workflow-config.js'; + +describe('workflow basePath config', () => { + afterEach(() => { + setWorkflowBasePath(undefined); + }); + + it('defaults to an empty base path', () => { + setWorkflowBasePath(undefined); + + expect(getWorkflowBasePath()).toBe(''); + }); + + it('normalizes the configured base path', () => { + setWorkflowBasePath('v2/'); + + expect(getWorkflowBasePath()).toBe('/v2'); + }); +}); diff --git a/packages/utils/src/workflow-config.ts b/packages/utils/src/workflow-config.ts new file mode 100644 index 0000000000..4b61e75fac --- /dev/null +++ b/packages/utils/src/workflow-config.ts @@ -0,0 +1,15 @@ +import { normalizeBasePath } from './base-path.js'; + +const BASE_PATH_SYMBOL = Symbol.for('@workflow/base-path'); + +function workflowConfigGlobal() { + return globalThis as typeof globalThis & Record; +} + +export function setWorkflowBasePath(basePath: string | undefined): void { + workflowConfigGlobal()[BASE_PATH_SYMBOL] = normalizeBasePath(basePath); +} + +export function getWorkflowBasePath(): string { + return normalizeBasePath(workflowConfigGlobal()[BASE_PATH_SYMBOL]); +} diff --git a/packages/utils/src/workflow-route.test.ts b/packages/utils/src/workflow-route.test.ts index ed59bc1d74..63912e28b2 100644 --- a/packages/utils/src/workflow-route.test.ts +++ b/packages/utils/src/workflow-route.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import { setWorkflowBasePath } from './workflow-config.js'; import { createWorkflowHealthEndpoint, createWorkflowRouteUrl, @@ -25,9 +26,21 @@ describe('createWorkflowRouteUrl', () => { }); describe('createWorkflowHealthEndpoint', () => { + afterEach(() => { + setWorkflowBasePath(undefined); + }); + it('builds the workflow health endpoint under the base path', () => { expect(createWorkflowHealthEndpoint('/v2/')).toBe( '/v2/.well-known/workflow/v1/flow?__health' ); }); + + it('defaults to the configured base path', () => { + setWorkflowBasePath('/v2/'); + + expect(createWorkflowHealthEndpoint()).toBe( + '/v2/.well-known/workflow/v1/flow?__health' + ); + }); }); diff --git a/packages/utils/src/workflow-route.ts b/packages/utils/src/workflow-route.ts index 8e9fd38ce5..621c27494b 100644 --- a/packages/utils/src/workflow-route.ts +++ b/packages/utils/src/workflow-route.ts @@ -1,4 +1,5 @@ import { normalizeBasePath } from './base-path.js'; +import { getWorkflowBasePath } from './workflow-config.js'; const WORKFLOW_ROUTE_BASE = '/.well-known/workflow/v1'; @@ -15,6 +16,8 @@ export function createWorkflowRouteUrl( return url.toString(); } -export function createWorkflowHealthEndpoint(basePath: string | undefined) { +export function createWorkflowHealthEndpoint( + basePath: string | undefined = getWorkflowBasePath() +) { return `${normalizeBasePath(basePath)}${WORKFLOW_ROUTE_BASE}/flow?__health`; } diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts index 91abe11a5f..d22c387584 100644 --- a/packages/world-local/src/config.test.ts +++ b/packages/world-local/src/config.test.ts @@ -1,3 +1,4 @@ +import { setWorkflowBasePath } from '@workflow/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { resolveBaseUrl } from './config'; @@ -15,6 +16,7 @@ describe('resolveBaseUrl', () => { afterEach(() => { process.env = originalEnv; + setWorkflowBasePath(undefined); vi.clearAllMocks(); }); @@ -228,20 +230,20 @@ describe('resolveBaseUrl', () => { expect(getWorkflowPort).not.toHaveBeenCalled(); }); - it('should append WORKFLOW_BASE_PATH to PORT-derived URLs', async () => { + it('should append the configured basePath to PORT-derived URLs', async () => { process.env.PORT = '4173'; - process.env.WORKFLOW_BASE_PATH = '/v2/'; + setWorkflowBasePath('/v2/'); const result = await resolveBaseUrl({}); expect(result).toBe('http://localhost:4173/v2'); }); - it('should probe auto-detected ports under WORKFLOW_BASE_PATH', async () => { + it('should probe auto-detected ports under the configured basePath', async () => { const { getWorkflowPort } = await import('@workflow/utils/get-port'); vi.mocked(getWorkflowPort).mockResolvedValue(5173); delete process.env.PORT; - process.env.WORKFLOW_BASE_PATH = '/v2/'; + setWorkflowBasePath('/v2/'); const result = await resolveBaseUrl({}); @@ -251,9 +253,9 @@ describe('resolveBaseUrl', () => { }); }); - it('should not append WORKFLOW_BASE_PATH to explicit WORKFLOW_LOCAL_BASE_URL', async () => { + it('should not append the configured basePath to explicit WORKFLOW_LOCAL_BASE_URL', async () => { process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:3000/custom'; - process.env.WORKFLOW_BASE_PATH = '/v2'; + setWorkflowBasePath('/v2'); const result = await resolveBaseUrl({}); diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index 03d1091292..8602cbfc4e 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -1,6 +1,6 @@ import { createWorkflowHealthEndpoint, - normalizeBasePath, + getWorkflowBasePath, } from '@workflow/utils'; import { getWorkflowPort } from '@workflow/utils/get-port'; import { once } from './util.js'; @@ -47,7 +47,7 @@ export const config = once(() => { }); function resolveBasePath(): string { - return normalizeBasePath(process.env.WORKFLOW_BASE_PATH); + return getWorkflowBasePath(); } export function resolveDirectBaseUrl(config: Partial): string { @@ -62,9 +62,9 @@ export function resolveDirectBaseUrl(config: Partial): string { * Resolves the base URL for queue requests following the priority order: * 1. config.baseUrl (highest priority - full override from args) * 2. WORKFLOW_LOCAL_BASE_URL env var (checked directly to handle late env var setting) - * 3. config.port (explicit port override from args), plus WORKFLOW_BASE_PATH - * 4. PORT env var (explicit configuration), plus WORKFLOW_BASE_PATH - * 5. Auto-detected port via getPort (detect actual listening port), plus WORKFLOW_BASE_PATH + * 3. config.port (explicit port override from args), plus Next.js basePath + * 4. PORT env var (explicit configuration), plus Next.js basePath + * 5. Auto-detected port via getPort (detect actual listening port), plus Next.js basePath */ export async function resolveBaseUrl(config: Partial): Promise { if (config.baseUrl) { @@ -88,7 +88,7 @@ export async function resolveBaseUrl(config: Partial): Promise { } const detectedPort = await getWorkflowPort({ - endpoint: createWorkflowHealthEndpoint(process.env.WORKFLOW_BASE_PATH), + endpoint: createWorkflowHealthEndpoint(), }); if (detectedPort) { return `http://localhost:${detectedPort}${basePath}`; diff --git a/packages/world-local/src/queue.test.ts b/packages/world-local/src/queue.test.ts index 2f666075a6..0dc184d14e 100644 --- a/packages/world-local/src/queue.test.ts +++ b/packages/world-local/src/queue.test.ts @@ -1,3 +1,4 @@ +import { setWorkflowBasePath } from '@workflow/utils'; import type { StepInvokePayload } from '@workflow/world'; import { MessageId, ValidQueueName } from '@workflow/world'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -350,6 +351,7 @@ describe('queue basePath routing', () => { afterEach(async () => { await localQueue?.close(); + setWorkflowBasePath(undefined); vi.restoreAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); @@ -373,7 +375,7 @@ describe('queue basePath routing', () => { }); it('uses basePath when delivering through a direct handler', async () => { - vi.stubEnv('WORKFLOW_BASE_PATH', '/v2'); + setWorkflowBasePath('/v2'); localQueue = createQueue({}); const handler = vi.fn(async (req: Request) => { expect(req.url).toBe('http://localhost/v2/.well-known/workflow/v1/flow'); diff --git a/packages/world-postgres/src/queue.test.ts b/packages/world-postgres/src/queue.test.ts index ceda9fc9d4..d8f89ce06d 100644 --- a/packages/world-postgres/src/queue.test.ts +++ b/packages/world-postgres/src/queue.test.ts @@ -1,5 +1,6 @@ import { createServer, type Server } from 'node:http'; import { JsonTransport } from '@vercel/queue'; +import { setWorkflowBasePath } from '@workflow/utils'; import { getWorkflowPort } from '@workflow/utils/get-port'; import { MessageId, parseQueueName, type QueuePayload } from '@workflow/world'; import { createLocalWorld } from '@workflow/world-local'; @@ -78,8 +79,8 @@ describe('postgres queue http execution', () => { ) ); vi.useRealTimers(); + setWorkflowBasePath(undefined); delete process.env.WORKFLOW_LOCAL_BASE_URL; - delete process.env.WORKFLOW_BASE_PATH; delete process.env.PORT; }); @@ -146,7 +147,7 @@ describe('postgres queue http execution', () => { }> = []; const port = await getUnusedLoopbackPort(); vi.mocked(getWorkflowPort).mockResolvedValue(port); - process.env.WORKFLOW_BASE_PATH = '/v2/'; + setWorkflowBasePath('/v2/'); const queue = buildQueue({ connectionString: 'postgres://test' }, pool); await queue.start(); diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index fdb31a9aa3..dadd34a675 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -5,7 +5,7 @@ import type { Transport } from '@vercel/queue'; import { createWorkflowHealthEndpoint, createWorkflowRouteUrl, - normalizeBasePath, + getWorkflowBasePath, } from '@workflow/utils'; import { getWorkflowPort } from '@workflow/utils/get-port'; import { @@ -221,7 +221,7 @@ export function createQueue( return process.env.WORKFLOW_LOCAL_BASE_URL; } - const basePath = normalizeBasePath(process.env.WORKFLOW_BASE_PATH); + const basePath = getWorkflowBasePath(); if (typeof port === 'number') { return `http://localhost:${port}${basePath}`; @@ -232,7 +232,7 @@ export function createQueue( } const detectedPort = await getWorkflowPort({ - endpoint: createWorkflowHealthEndpoint(process.env.WORKFLOW_BASE_PATH), + endpoint: createWorkflowHealthEndpoint(), }); if (typeof detectedPort === 'number') { return `http://localhost:${detectedPort}${basePath}`; From d3eecabe65a40c9d3373519c1f34821e1f74b39a Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:14:46 -0700 Subject: [PATCH 6/9] refactor: trim basePath implementation --- packages/builders/src/constants.test.ts | 6 --- packages/builders/src/types.ts | 1 - packages/core/src/runtime/base-url.test.ts | 45 --------------------- packages/core/src/runtime/base-url.ts | 14 ------- packages/core/src/runtime/step-executor.ts | 15 +++---- packages/core/src/runtime/step-handler.ts | 13 ++++-- packages/core/src/workflow.ts | 11 ++---- packages/next/src/index.test.ts | 33 +++------------- packages/next/src/index.ts | 6 +-- packages/utils/src/base-path.test.ts | 18 --------- packages/utils/src/base-path.ts | 13 ------ packages/utils/src/index.ts | 8 +--- packages/utils/src/workflow-config.test.ts | 24 +++++++---- packages/utils/src/workflow-config.ts | 38 +++++++++++++++--- packages/utils/src/workflow-route.test.ts | 46 ---------------------- packages/utils/src/workflow-route.ts | 23 ----------- packages/world-local/src/config.test.ts | 30 +++----------- packages/world-local/src/config.ts | 14 +++---- packages/world-local/src/queue.test.ts | 20 ---------- packages/world-postgres/src/queue.test.ts | 26 ------------ 20 files changed, 89 insertions(+), 315 deletions(-) delete mode 100644 packages/core/src/runtime/base-url.test.ts delete mode 100644 packages/core/src/runtime/base-url.ts delete mode 100644 packages/utils/src/base-path.test.ts delete mode 100644 packages/utils/src/base-path.ts delete mode 100644 packages/utils/src/workflow-route.test.ts delete mode 100644 packages/utils/src/workflow-route.ts diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts index cbc2e6f4fe..d257434428 100644 --- a/packages/builders/src/constants.test.ts +++ b/packages/builders/src/constants.test.ts @@ -41,12 +41,6 @@ describe('createWorkflowEntrypointOptionsCode', () => { ); }); - it('inlines a framework base path', () => { - expect(createWorkflowEntrypointOptionsCode({ basePath: '/v2' })).toBe( - ', { basePath: "/v2" }' - ); - }); - it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => { process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom'; diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts index dfd6f3dab3..f7ddc05d9e 100644 --- a/packages/builders/src/types.ts +++ b/packages/builders/src/types.ts @@ -60,7 +60,6 @@ interface BaseWorkflowConfig { // artifact locations. distDir?: string; - // Optional URL path prefix for frameworks that serve routes under one. basePath?: string; // Suppress informational logs emitted by createWorkflowsBundle() diff --git a/packages/core/src/runtime/base-url.test.ts b/packages/core/src/runtime/base-url.test.ts deleted file mode 100644 index f570b11c19..0000000000 --- a/packages/core/src/runtime/base-url.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { setWorkflowBasePath } from '@workflow/utils'; -import { afterEach, describe, expect, it } from 'vitest'; -import { getWorkflowBaseUrl } from './base-url.js'; - -describe('getWorkflowBaseUrl', () => { - const originalEnv = { - VERCEL_URL: process.env.VERCEL_URL, - }; - - afterEach(() => { - setWorkflowBasePath(undefined); - for (const [key, value] of Object.entries(originalEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }); - - it('uses localhost without a base path by default', () => { - setWorkflowBasePath(undefined); - - expect(getWorkflowBaseUrl({ type: 'local', port: 3000 })).toBe( - 'http://localhost:3000' - ); - }); - - it('includes the configured base path for local metadata URLs', () => { - setWorkflowBasePath('/v2'); - - expect(getWorkflowBaseUrl({ type: 'local', port: 3000 })).toBe( - 'http://localhost:3000/v2' - ); - }); - - it('includes the configured base path for Vercel metadata URLs', () => { - process.env.VERCEL_URL = 'example.vercel.app'; - setWorkflowBasePath('/v2/'); - - expect(getWorkflowBaseUrl({ type: 'vercel' })).toBe( - 'https://example.vercel.app/v2' - ); - }); -}); diff --git a/packages/core/src/runtime/base-url.ts b/packages/core/src/runtime/base-url.ts deleted file mode 100644 index 532704e3fa..0000000000 --- a/packages/core/src/runtime/base-url.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getWorkflowBasePath } from '@workflow/utils'; - -export type WorkflowBaseUrlTarget = - | { type: 'vercel' } - | { type: 'local'; port: number }; - -export function getWorkflowBaseUrl(target: WorkflowBaseUrlTarget): string { - const origin = - target.type === 'vercel' - ? `https://${process.env.VERCEL_URL}` - : `http://localhost:${target.port}`; - - return `${origin}${getWorkflowBasePath()}`; -} diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 4aba0a58f6..c4d10fb618 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -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, @@ -32,7 +36,6 @@ import { normalizeUnknownError, promoteAbortErrorToFatal, } from '../types.js'; -import { getWorkflowBaseUrl } from './base-url.js'; import { isOptimisticInlineStartEnabled, isOptimisticInlineStartExplicitlyDisabled, @@ -563,12 +566,10 @@ export async function executeStep( const args = hydratedInput.args; const thisVal = hydratedInput.thisVal ?? null; + const basePath = getWorkflowBasePath(); const workflowBaseUrl = isVercel - ? getWorkflowBaseUrl({ type: 'vercel' }) - : getWorkflowBaseUrl({ - type: 'local', - port: (await getPortLazy()) ?? 3000, - }); + ? `https://${process.env.VERCEL_URL}${basePath}` + : `http://localhost:${(await getPortLazy()) ?? 3000}${basePath}`; const executionStartTime = Date.now(); result = await trace('step.execute', {}, async () => { diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index 40602bd165..3dd78cc534 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -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, @@ -47,7 +52,6 @@ import { promoteAbortErrorToFatal, } from '../types.js'; -import { getWorkflowBaseUrl } from './base-url.js'; import { MAX_QUEUE_DELIVERIES } from './constants.js'; import { getQueueOverhead, @@ -222,9 +226,10 @@ function createStepHandler(namespace?: string) { isVercel ? undefined : getPort(), getSpanKind('CONSUMER'), ]); + const basePath = getWorkflowBasePath(); const workflowBaseUrl = isVercel - ? getWorkflowBaseUrl({ type: 'vercel' }) - : getWorkflowBaseUrl({ type: 'local', port: port ?? 3000 }); + ? `https://${process.env.VERCEL_URL}${basePath}` + : `http://localhost:${port ?? 3000}${basePath}`; return trace( `step.execute ${stepDisplayName(stepName)}`, diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 5de6217f61..f6778a9ea6 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -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'; @@ -16,7 +16,6 @@ import type { QueueItem } from './global.js'; import { ENOTSUP, WorkflowSuspension } from './global.js'; import { runtimeLogger } from './logger.js'; import type { WorkflowOrchestratorContext } from './private.js'; -import { getWorkflowBaseUrl } from './runtime/base-url.js'; import { getPortLazy } from './runtime/get-port-lazy.js'; import { runIdCreatedAt } from './runtime/run-id-time.js'; import { handleSuspension } from './runtime/suspension-handler.js'; @@ -195,12 +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 basePath = getWorkflowBasePath(); const workflowBaseUrl = isVercel - ? getWorkflowBaseUrl({ type: 'vercel' }) - : getWorkflowBaseUrl({ - type: 'local', - port: (await getPortLazy()) ?? 3000, - }); + ? `https://${process.env.VERCEL_URL}${basePath}` + : `http://localhost:${(await getPortLazy()) ?? 3000}${basePath}`; const { context, diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index c298853afa..3d0e5292e9 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -51,18 +51,8 @@ const loaderStubPath = join(__dirname, 'loader.js'); const hadLoaderStub = existsSync(loaderStubPath); const realTmpDir = realpathSync(tmpdir()); const BASE_PATH_SYMBOL = Symbol.for('@workflow/base-path'); - -function workflowBasePathGlobal() { - return globalThis as typeof globalThis & Record; -} - -function setWorkflowBasePath(basePath: string | undefined): void { - workflowBasePathGlobal()[BASE_PATH_SYMBOL] = basePath; -} - -function getWorkflowBasePath(): string { - return workflowBasePathGlobal()[BASE_PATH_SYMBOL] ?? ''; -} +const workflowGlobal = globalThis as typeof globalThis & + Record; function writeFile(path: string, contents: string): void { mkdirSync(dirname(path), { recursive: true }); @@ -94,11 +84,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; - setWorkflowBasePath(undefined); + workflowGlobal[BASE_PATH_SYMBOL] = undefined; }); afterEach(() => { - setWorkflowBasePath(undefined); + workflowGlobal[BASE_PATH_SYMBOL] = undefined; if (!hadLoaderStub && existsSync(loaderStubPath)) { rmSync(loaderStubPath); } @@ -200,7 +190,7 @@ describe('withWorkflow builder config', () => { defaultConfig: {}, }); - expect(getWorkflowBasePath()).toBe('/v2'); + expect(workflowGlobal[BASE_PATH_SYMBOL]).toBe('/v2'); expect(nextConfig.env).toEqual({ EXISTING_ENV: '1', }); @@ -209,19 +199,6 @@ describe('withWorkflow builder config', () => { }); }); - it('clears workflow basePath when Next.js basePath is not configured', async () => { - setWorkflowBasePath('/previous'); - const config = withWorkflow({}); - - const nextConfig = await config('phase-production-build', { - defaultConfig: {}, - }); - - expect(getWorkflowBasePath()).toBe(''); - expect(nextConfig.env).toBeUndefined(); - expect(builderConfigs[0]).toMatchObject({ basePath: '' }); - }); - it('externalizes the built-in Vercel world while preserving user externals', async () => { const config = withWorkflow({ serverExternalPackages: ['@node-rs/xxhash'], diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 855fb362ea..5d4fade3c6 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -29,10 +29,10 @@ const PSEUDO_EXTERNAL_PACKAGES = new Set(['server-only', 'client-only']); const warnedAutoRemovedServerExternalPackages = new Set(); const BASE_PATH_SYMBOL = Symbol.for('@workflow/base-path'); -function setWorkflowBasePath(basePath: string): void { +function setWorkflowBasePath(basePath: string | undefined): void { (globalThis as typeof globalThis & Record)[ BASE_PATH_SYMBOL - ] = basePath; + ] = basePath ?? ''; } interface WorkflowPatternMatch { @@ -382,7 +382,7 @@ export function withWorkflow( } // shallow clone to avoid read-only on top-level nextConfig = Object.assign({}, nextConfig); - const workflowBasePath = nextConfig.basePath ?? ''; + const workflowBasePath = nextConfig.basePath; setWorkflowBasePath(workflowBasePath); nextConfig.serverExternalPackages = [ ...new Set([ diff --git a/packages/utils/src/base-path.test.ts b/packages/utils/src/base-path.test.ts deleted file mode 100644 index 3a311470f7..0000000000 --- a/packages/utils/src/base-path.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { normalizeBasePath } from './base-path.js'; - -describe('normalizeBasePath', () => { - it.each([ - [undefined, ''], - ['', ''], - ['/', ''], - [' ', ''], - ['v2', '/v2'], - ['/v2', '/v2'], - ['/v2/', '/v2'], - [' /v2/ ', '/v2'], - ['/nested/base/', '/nested/base'], - ])('normalizes %p to %p', (input, expected) => { - expect(normalizeBasePath(input)).toBe(expected); - }); -}); diff --git a/packages/utils/src/base-path.ts b/packages/utils/src/base-path.ts deleted file mode 100644 index 122a15933c..0000000000 --- a/packages/utils/src/base-path.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function normalizeBasePath(basePath: string | undefined): string { - if (!basePath) { - return ''; - } - - const trimmed = basePath.trim(); - if (!trimmed || trimmed === '/') { - return ''; - } - - const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - return withLeadingSlash.replace(/\/+$/, ''); -} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 60bd0a1499..d22076dc55 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,4 +1,3 @@ -export { normalizeBasePath } from './base-path.js'; export { formatStepName, formatWorkflowName, @@ -12,14 +11,11 @@ export { pluralize } from './pluralize.js'; export { once, type PromiseWithResolvers, withResolvers } from './promise.js'; export { parseDurationToDate } from './time.js'; export { + createWorkflowHealthEndpoint, + createWorkflowRouteUrl, getWorkflowBasePath, setWorkflowBasePath, } from './workflow-config.js'; -export { - createWorkflowHealthEndpoint, - createWorkflowRouteUrl, - type WorkflowRoutePathname, -} from './workflow-route.js'; export { isVercelWorldTarget, resolveWorkflowTargetWorld, diff --git a/packages/utils/src/workflow-config.test.ts b/packages/utils/src/workflow-config.test.ts index ce80981272..8bff4ccec4 100644 --- a/packages/utils/src/workflow-config.test.ts +++ b/packages/utils/src/workflow-config.test.ts @@ -1,20 +1,28 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { getWorkflowBasePath, setWorkflowBasePath } from './workflow-config.js'; +import { + createWorkflowHealthEndpoint, + createWorkflowRouteUrl, + getWorkflowBasePath, + setWorkflowBasePath, +} from './workflow-config.js'; describe('workflow basePath config', () => { afterEach(() => { setWorkflowBasePath(undefined); }); - it('defaults to an empty base path', () => { - setWorkflowBasePath(undefined); - - expect(getWorkflowBasePath()).toBe(''); - }); - - it('normalizes the configured base path', () => { + it('normalizes the configured base path for metadata and health probes', () => { setWorkflowBasePath('v2/'); expect(getWorkflowBasePath()).toBe('/v2'); + expect(createWorkflowHealthEndpoint()).toBe( + '/v2/.well-known/workflow/v1/flow?__health' + ); + }); + + it('preserves a base URL path when building workflow route URLs', () => { + expect( + createWorkflowRouteUrl('http://localhost:3000/v2/?debug=1', 'step') + ).toBe('http://localhost:3000/v2/.well-known/workflow/v1/step'); }); }); diff --git a/packages/utils/src/workflow-config.ts b/packages/utils/src/workflow-config.ts index 4b61e75fac..b7fda7d10b 100644 --- a/packages/utils/src/workflow-config.ts +++ b/packages/utils/src/workflow-config.ts @@ -1,15 +1,41 @@ -import { normalizeBasePath } from './base-path.js'; - const BASE_PATH_SYMBOL = Symbol.for('@workflow/base-path'); +const WORKFLOW_ROUTE_BASE = '/.well-known/workflow/v1'; +const workflowConfig = globalThis as typeof globalThis & + Record; + +function normalizeBasePath(basePath: string | undefined): string { + if (!basePath) { + return ''; + } -function workflowConfigGlobal() { - return globalThis as typeof globalThis & Record; + const trimmed = basePath.trim(); + if (!trimmed || trimmed === '/') { + return ''; + } + + const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return withLeadingSlash.replace(/\/+$/, ''); } export function setWorkflowBasePath(basePath: string | undefined): void { - workflowConfigGlobal()[BASE_PATH_SYMBOL] = normalizeBasePath(basePath); + workflowConfig[BASE_PATH_SYMBOL] = normalizeBasePath(basePath); } export function getWorkflowBasePath(): string { - return normalizeBasePath(workflowConfigGlobal()[BASE_PATH_SYMBOL]); + return workflowConfig[BASE_PATH_SYMBOL] ?? ''; +} + +export function createWorkflowRouteUrl( + baseUrl: string, + pathname: 'flow' | 'step' +): string { + const url = new URL(baseUrl); + url.pathname = `${normalizeBasePath(url.pathname)}${WORKFLOW_ROUTE_BASE}/${pathname}`; + url.search = ''; + url.hash = ''; + return url.toString(); +} + +export function createWorkflowHealthEndpoint() { + return `${getWorkflowBasePath()}${WORKFLOW_ROUTE_BASE}/flow?__health`; } diff --git a/packages/utils/src/workflow-route.test.ts b/packages/utils/src/workflow-route.test.ts deleted file mode 100644 index 63912e28b2..0000000000 --- a/packages/utils/src/workflow-route.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { setWorkflowBasePath } from './workflow-config.js'; -import { - createWorkflowHealthEndpoint, - createWorkflowRouteUrl, -} from './workflow-route.js'; - -describe('createWorkflowRouteUrl', () => { - it('builds workflow routes without a base path', () => { - expect(createWorkflowRouteUrl('http://localhost:3000', 'flow')).toBe( - 'http://localhost:3000/.well-known/workflow/v1/flow' - ); - }); - - it('preserves the base path from baseUrl', () => { - expect(createWorkflowRouteUrl('http://localhost:3000/v2', 'flow')).toBe( - 'http://localhost:3000/v2/.well-known/workflow/v1/flow' - ); - }); - - it('normalizes trailing slashes and drops search params', () => { - expect( - createWorkflowRouteUrl('http://localhost:3000/v2/?debug=1', 'step') - ).toBe('http://localhost:3000/v2/.well-known/workflow/v1/step'); - }); -}); - -describe('createWorkflowHealthEndpoint', () => { - afterEach(() => { - setWorkflowBasePath(undefined); - }); - - it('builds the workflow health endpoint under the base path', () => { - expect(createWorkflowHealthEndpoint('/v2/')).toBe( - '/v2/.well-known/workflow/v1/flow?__health' - ); - }); - - it('defaults to the configured base path', () => { - setWorkflowBasePath('/v2/'); - - expect(createWorkflowHealthEndpoint()).toBe( - '/v2/.well-known/workflow/v1/flow?__health' - ); - }); -}); diff --git a/packages/utils/src/workflow-route.ts b/packages/utils/src/workflow-route.ts deleted file mode 100644 index 621c27494b..0000000000 --- a/packages/utils/src/workflow-route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { normalizeBasePath } from './base-path.js'; -import { getWorkflowBasePath } from './workflow-config.js'; - -const WORKFLOW_ROUTE_BASE = '/.well-known/workflow/v1'; - -export type WorkflowRoutePathname = 'flow' | 'step'; - -export function createWorkflowRouteUrl( - baseUrl: string, - pathname: WorkflowRoutePathname -): string { - const url = new URL(baseUrl); - url.pathname = `${normalizeBasePath(url.pathname)}${WORKFLOW_ROUTE_BASE}/${pathname}`; - url.search = ''; - url.hash = ''; - return url.toString(); -} - -export function createWorkflowHealthEndpoint( - basePath: string | undefined = getWorkflowBasePath() -) { - return `${normalizeBasePath(basePath)}${WORKFLOW_ROUTE_BASE}/flow?__health`; -} diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts index d22c387584..4f4ada9822 100644 --- a/packages/world-local/src/config.test.ts +++ b/packages/world-local/src/config.test.ts @@ -63,11 +63,14 @@ describe('resolveBaseUrl', () => { const { getWorkflowPort } = await import('@workflow/utils/get-port'); vi.mocked(getWorkflowPort).mockResolvedValue(5173); delete process.env.PORT; + setWorkflowBasePath('/v2/'); const result = await resolveBaseUrl({}); - expect(result).toBe('http://localhost:5173'); - expect(getWorkflowPort).toHaveBeenCalled(); + expect(result).toBe('http://localhost:5173/v2'); + expect(getWorkflowPort).toHaveBeenCalledWith({ + endpoint: '/v2/.well-known/workflow/v1/flow?__health', + }); }); it('should throw error when all detection methods fail', async () => { @@ -230,29 +233,6 @@ describe('resolveBaseUrl', () => { expect(getWorkflowPort).not.toHaveBeenCalled(); }); - it('should append the configured basePath to PORT-derived URLs', async () => { - process.env.PORT = '4173'; - setWorkflowBasePath('/v2/'); - - const result = await resolveBaseUrl({}); - - expect(result).toBe('http://localhost:4173/v2'); - }); - - it('should probe auto-detected ports under the configured basePath', async () => { - const { getWorkflowPort } = await import('@workflow/utils/get-port'); - vi.mocked(getWorkflowPort).mockResolvedValue(5173); - delete process.env.PORT; - setWorkflowBasePath('/v2/'); - - const result = await resolveBaseUrl({}); - - expect(result).toBe('http://localhost:5173/v2'); - expect(getWorkflowPort).toHaveBeenCalledWith({ - endpoint: '/v2/.well-known/workflow/v1/flow?__health', - }); - }); - it('should not append the configured basePath to explicit WORKFLOW_LOCAL_BASE_URL', async () => { process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:3000/custom'; setWorkflowBasePath('/v2'); diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index 8602cbfc4e..363a1765c0 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -46,15 +46,11 @@ export const config = once(() => { return { dataDir, baseUrl }; }); -function resolveBasePath(): string { - return getWorkflowBasePath(); -} - export function resolveDirectBaseUrl(config: Partial): string { return ( config.baseUrl ?? process.env.WORKFLOW_LOCAL_BASE_URL ?? - `http://localhost${resolveBasePath()}` + `http://localhost${getWorkflowBasePath()}` ); } @@ -62,9 +58,9 @@ export function resolveDirectBaseUrl(config: Partial): string { * Resolves the base URL for queue requests following the priority order: * 1. config.baseUrl (highest priority - full override from args) * 2. WORKFLOW_LOCAL_BASE_URL env var (checked directly to handle late env var setting) - * 3. config.port (explicit port override from args), plus Next.js basePath - * 4. PORT env var (explicit configuration), plus Next.js basePath - * 5. Auto-detected port via getPort (detect actual listening port), plus Next.js basePath + * 3. config.port (explicit port override from args) + * 4. PORT env var (explicit configuration) + * 5. Auto-detected port via getPort (detect actual listening port) */ export async function resolveBaseUrl(config: Partial): Promise { if (config.baseUrl) { @@ -77,7 +73,7 @@ export async function resolveBaseUrl(config: Partial): Promise { return process.env.WORKFLOW_LOCAL_BASE_URL; } - const basePath = resolveBasePath(); + const basePath = getWorkflowBasePath(); if (typeof config.port === 'number') { return `http://localhost:${config.port}${basePath}`; diff --git a/packages/world-local/src/queue.test.ts b/packages/world-local/src/queue.test.ts index 0dc184d14e..b78fec8c4c 100644 --- a/packages/world-local/src/queue.test.ts +++ b/packages/world-local/src/queue.test.ts @@ -352,26 +352,6 @@ describe('queue basePath routing', () => { afterEach(async () => { await localQueue?.close(); setWorkflowBasePath(undefined); - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - }); - - it('posts HTTP deliveries under the baseUrl path', async () => { - localQueue = createQueue({ baseUrl: 'http://localhost:3000/v2' }); - const fetchMock = vi.fn(async () => - Response.json({ ok: true }, { status: 200 }) - ); - vi.stubGlobal('fetch', fetchMock); - - await localQueue.queue('__wkf_workflow_test' as any, { - runId: 'run_01ABC', - }); - - await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); - expect(fetchMock.mock.calls[0]?.[0]).toBe( - 'http://localhost:3000/v2/.well-known/workflow/v1/flow' - ); }); it('uses basePath when delivering through a direct handler', async () => { diff --git a/packages/world-postgres/src/queue.test.ts b/packages/world-postgres/src/queue.test.ts index d8f89ce06d..ac236210af 100644 --- a/packages/world-postgres/src/queue.test.ts +++ b/packages/world-postgres/src/queue.test.ts @@ -370,32 +370,6 @@ describe('postgres queue http execution', () => { } }); - it('posts workflow requests under the explicit base URL path', async () => { - const fetchMock = vi.fn(async () => Response.json({ ok: true })); - vi.stubGlobal('fetch', fetchMock); - process.env.WORKFLOW_LOCAL_BASE_URL = 'https://workflow.example.test/v2/'; - - const queue = buildQueue({ connectionString: 'postgres://test' }, pool); - try { - await queue.start(); - - const task = getTaskHandler('workflow_flows'); - await task( - buildMessageData('__wkf_workflow_test-workflow', { - runId: 'wrun_01ABC', - }), - {} as any - ); - - expect(fetchMock).toHaveBeenCalledWith( - 'https://workflow.example.test/v2/.well-known/workflow/v1/flow', - expect.objectContaining({ method: 'POST' }) - ); - } finally { - vi.unstubAllGlobals(); - } - }); - it('queues producer delays and headers in graphile job metadata', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2024-01-01T00:00:00.000Z')); From 4554f4f0b8d6c8758019a65b7a4ac46a0c386192 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:50:16 -0700 Subject: [PATCH 7/9] refactor: trust canonical workflow basePath --- packages/utils/src/workflow-config.test.ts | 6 +++--- packages/utils/src/workflow-config.ts | 20 +++----------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/packages/utils/src/workflow-config.test.ts b/packages/utils/src/workflow-config.test.ts index 8bff4ccec4..1fa339f831 100644 --- a/packages/utils/src/workflow-config.test.ts +++ b/packages/utils/src/workflow-config.test.ts @@ -11,8 +11,8 @@ describe('workflow basePath config', () => { setWorkflowBasePath(undefined); }); - it('normalizes the configured base path for metadata and health probes', () => { - setWorkflowBasePath('v2/'); + it('uses the configured base path for health probes', () => { + setWorkflowBasePath('/v2'); expect(getWorkflowBasePath()).toBe('/v2'); expect(createWorkflowHealthEndpoint()).toBe( @@ -22,7 +22,7 @@ describe('workflow basePath config', () => { it('preserves a base URL path when building workflow route URLs', () => { expect( - createWorkflowRouteUrl('http://localhost:3000/v2/?debug=1', 'step') + createWorkflowRouteUrl('http://localhost:3000/v2///?debug=1', 'step') ).toBe('http://localhost:3000/v2/.well-known/workflow/v1/step'); }); }); diff --git a/packages/utils/src/workflow-config.ts b/packages/utils/src/workflow-config.ts index b7fda7d10b..f1237e811c 100644 --- a/packages/utils/src/workflow-config.ts +++ b/packages/utils/src/workflow-config.ts @@ -1,24 +1,10 @@ -const BASE_PATH_SYMBOL = Symbol.for('@workflow/base-path'); const WORKFLOW_ROUTE_BASE = '/.well-known/workflow/v1'; +const BASE_PATH_SYMBOL = Symbol.for('@workflow/base-path'); const workflowConfig = globalThis as typeof globalThis & Record; -function normalizeBasePath(basePath: string | undefined): string { - if (!basePath) { - return ''; - } - - const trimmed = basePath.trim(); - if (!trimmed || trimmed === '/') { - return ''; - } - - const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - return withLeadingSlash.replace(/\/+$/, ''); -} - export function setWorkflowBasePath(basePath: string | undefined): void { - workflowConfig[BASE_PATH_SYMBOL] = normalizeBasePath(basePath); + workflowConfig[BASE_PATH_SYMBOL] = basePath ?? ''; } export function getWorkflowBasePath(): string { @@ -30,7 +16,7 @@ export function createWorkflowRouteUrl( pathname: 'flow' | 'step' ): string { const url = new URL(baseUrl); - url.pathname = `${normalizeBasePath(url.pathname)}${WORKFLOW_ROUTE_BASE}/${pathname}`; + url.pathname = `${url.pathname.replace(/\/+$/, '')}${WORKFLOW_ROUTE_BASE}/${pathname}`; url.search = ''; url.hash = ''; return url.toString(); From 7a9a96cc50fc4e38c0e2ceea3254e31bd4569104 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:53:01 -0700 Subject: [PATCH 8/9] test: use canonical workflow basePath --- packages/world-local/src/config.test.ts | 2 +- packages/world-postgres/src/queue.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/world-local/src/config.test.ts b/packages/world-local/src/config.test.ts index 4f4ada9822..ba35fccedd 100644 --- a/packages/world-local/src/config.test.ts +++ b/packages/world-local/src/config.test.ts @@ -63,7 +63,7 @@ describe('resolveBaseUrl', () => { const { getWorkflowPort } = await import('@workflow/utils/get-port'); vi.mocked(getWorkflowPort).mockResolvedValue(5173); delete process.env.PORT; - setWorkflowBasePath('/v2/'); + setWorkflowBasePath('/v2'); const result = await resolveBaseUrl({}); diff --git a/packages/world-postgres/src/queue.test.ts b/packages/world-postgres/src/queue.test.ts index ac236210af..600c1d8b93 100644 --- a/packages/world-postgres/src/queue.test.ts +++ b/packages/world-postgres/src/queue.test.ts @@ -147,7 +147,7 @@ describe('postgres queue http execution', () => { }> = []; const port = await getUnusedLoopbackPort(); vi.mocked(getWorkflowPort).mockResolvedValue(port); - setWorkflowBasePath('/v2/'); + setWorkflowBasePath('/v2'); const queue = buildQueue({ connectionString: 'postgres://test' }, pool); await queue.start(); From 45677019d06e2f9492131e304ea58d0ec2ebe873 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:23:28 -0700 Subject: [PATCH 9/9] Fix Next basePath workflow launcher --- packages/next/src/index.test.ts | 52 +++++++++++++++++++++++++ packages/next/src/index.ts | 68 ++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 3d0e5292e9..5872361bee 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readFileSync, realpathSync, rmSync, writeFileSync, @@ -199,6 +200,57 @@ describe('withWorkflow builder config', () => { }); }); + 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; + } + ).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'], diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 5d4fade3c6..bc6054f933 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -1,6 +1,6 @@ import { copyFileSync, mkdirSync, statSync } from 'node:fs'; -import { copyFile, mkdir, readFile } from 'node:fs/promises'; -import { dirname, isAbsolute, join } from 'node:path'; +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative } from 'node:path'; import type { NextConfig } from 'next'; import semver from 'semver'; import { getNextBuilder } from './builder.js'; @@ -282,6 +282,69 @@ async function copyWorkflowDiagnosticsManifest(metadata: { await copyFile(manifestPath, diagnosticsManifestPath); } +function resolveDistDir({ + projectDir, + distDir, +}: { + projectDir: string; + distDir: string; +}): string { + return isAbsolute(distDir) ? distDir : join(projectDir, distDir); +} + +function toRequirePath(path: string): string { + const normalizedPath = path.replaceAll('\\', '/'); + return normalizedPath.startsWith('.') + ? normalizedPath + : `./${normalizedPath}`; +} + +async function writeWorkflowBasePathEntrypoint( + metadata: { projectDir: string; distDir: string }, + basePath: string | undefined +): Promise { + if (!basePath) { + return; + } + + const distDir = resolveDistDir(metadata); + const appRouteFile = join( + distDir, + 'server/app/.well-known/workflow/v1/flow/route.js' + ); + const pagesRouteFile = join( + distDir, + 'server/pages', + basePath.slice(1), + '.well-known/workflow/v1/flow.js' + ); + const routeTraceFile = `${appRouteFile}.nft.json`; + + if (!fileExists(appRouteFile) || !fileExists(routeTraceFile)) { + return; + } + + await mkdir(dirname(pagesRouteFile), { recursive: true }); + await writeFile( + pagesRouteFile, + `module.exports = require(${JSON.stringify( + toRequirePath(relative(dirname(pagesRouteFile), appRouteFile)) + )});\n` + ); + + const routeTrace = JSON.parse(await readFile(routeTraceFile, 'utf-8')) as { + files: string[]; + }; + const tracedEntrypoint = relative( + dirname(appRouteFile), + pagesRouteFile + ).replaceAll('\\', '/'); + if (!routeTrace.files.includes(tracedEntrypoint)) { + routeTrace.files.push(tracedEntrypoint); + await writeFile(routeTraceFile, JSON.stringify(routeTrace)); + } +} + function copyWorkflowDiagnosticsManifestSync(metadata: { projectDir: string; distDir: string; @@ -465,6 +528,7 @@ export function withWorkflow( await existingRunAfterProductionCompile(metadata); } await copyWorkflowDiagnosticsManifest(metadata); + await writeWorkflowBasePathEntrypoint(metadata, workflowBasePath); registerWorkflowDiagnosticsManifestCopy(metadata); }, };