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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/queue-namespace-primitive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@workflow/world": minor
"@workflow/builders": minor
"@workflow/core": minor
"@workflow/world-local": minor
"@workflow/world-postgres": minor
---

Add an optional `namespace` parameter that scopes queue topic prefixes to `__{namespace}_wkf_workflow_*`. This allows configuring multiple frameworks in the same deployment without queue topic collision.
6 changes: 3 additions & 3 deletions packages/astro/src/builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,11 +164,11 @@ export const prerender = false;`,
// Normalize request, needed for preserving request through astro
workflowsRouteContent = replaceGeneratedRouteExport(
workflowsRouteContent,
/const handler = workflowEntrypoint\(workflowCode\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
`${NORMALIZE_REQUEST_CODE}
/const handler = workflowEntrypoint\(workflowCode(?<options>[^)]*)\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
(_match, options = '') => `${NORMALIZE_REQUEST_CODE}
const handleWorkflowRequest = async ({request}) => {
const normalRequest = await normalizeRequest(request);
return workflowEntrypoint(workflowCode)(normalRequest);
return workflowEntrypoint(workflowCode${options})(normalRequest);
};

export const HEAD = handleWorkflowRequest;
Expand Down
6 changes: 5 additions & 1 deletion packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
applySwcTransform,
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import { getImportPath } from './module-specifier.js';
Expand DownExpand Up@@ -994,6 +995,9 @@ export abstract class BaseBuilder {
}
}

const workflowEntrypointOptionsCode =
createWorkflowEntrypointOptionsCode();

const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;

Expand All@@ -1003,7 +1007,7 @@ import { workflowEntrypoint } from 'workflow/runtime';

const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

const handler = workflowEntrypoint(workflowCode);
const handler = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});

export const HEAD = handler;
export const POST = handler;`;
Expand Down
51 changes: 51 additions & 0 deletions packages/builders/src/constants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
} from './constants.js';

describe('createWorkflowQueueTrigger', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('uses the default workflow topic without a namespace', () => {
expect(createWorkflowQueueTrigger().topic).toBe('__wkf_workflow_*');
});

it('uses an explicit namespace when provided', () => {
expect(createWorkflowQueueTrigger({ namespace: 'custom' }).topic).toBe(
'__custom_wkf_workflow_*'
);
});

it('uses WORKFLOW_QUEUE_NAMESPACE when no explicit namespace is provided', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowQueueTrigger().topic).toBe('__custom_wkf_workflow_*');
});
});

describe('createWorkflowEntrypointOptionsCode', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('omits runtime options without a namespace', () => {
expect(createWorkflowEntrypointOptionsCode()).toBe('');
});

it('inlines an explicit namespace', () => {
expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe(
', { namespace: "custom" }'
);
});

it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowEntrypointOptionsCode()).toBe(
', { namespace: "custom" }'
);
});
});
118 changes: 100 additions & 18 deletions packages/builders/src/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,105 @@
const QUEUE_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/;

function resolveQueueNamespace(namespace?: string): string | undefined {
return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined;
}

function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) {
if (namespace !== undefined) {
if (!QUEUE_NAMESPACE_PATTERN.test(namespace)) {
throw new Error(
`Invalid queue namespace "${namespace}": must be lowercase alphanumeric, starting with a letter`
);
}

return `__${namespace}_wkf_${kind}_`;
}

return `__wkf_${kind}_`;
}

/**
* Queue trigger configuration for workflow step execution.
* Steps are queued to the __wkf_step_* topic.
* Creates a queue trigger configuration for workflow step execution.
* Steps are queued to the step topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_step_*'
* createStepQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_step_*'
* createStepQueueTrigger({ namespace: 'custom' })
*/
export const STEP_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_step_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export function createStepQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('step', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Default step queue trigger (no namespace). Backward compatible.
*/
export const STEP_QUEUE_TRIGGER = createStepQueueTrigger();

/**
* Creates a queue trigger configuration for workflow orchestration.
* Workflows are queued to the workflow topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_workflow_*'
* createWorkflowQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_workflow_*'
* createWorkflowQueueTrigger({ namespace: 'custom' })
*/
export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('workflow', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Creates the optional second argument for generated `workflowEntrypoint()`
* calls. The namespace is resolved while building so generated route files do
* not need `WORKFLOW_QUEUE_NAMESPACE` at runtime.
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
}) {
const namespace = resolveQueueNamespace(options?.namespace);

if (!namespace) {
return '';
}

// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);

return `, { namespace: ${JSON.stringify(namespace)} }`;
}

/**
* Queue trigger configuration for workflow orchestration.
* Workflows are queued to the __wkf_workflow_* topic.
* Default queue trigger (no namespace). Backward compatible.
*/
export const WORKFLOW_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_workflow_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger();
8 changes: 7 additions & 1 deletion packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,13 @@ export {
getDecoratorOptionsForDirectory,
getDecoratorOptionsForDirectoryWithConfigPath,
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export {
createStepQueueTrigger,
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
STEP_QUEUE_TRIGGER,
WORKFLOW_QUEUE_TRIGGER,
} from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
Expand Down
6 changes: 3 additions & 3 deletions packages/builders/src/request-converter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,10 @@ async function normalizeRequest(request) {
function replaceGeneratedRouteExport(
content: string,
pattern: RegExp,
replacement: string,
replacement: string | ((substring: string, ...args: any[]) => string),
errorMessage: string
) {
const replacedContent = content.replace(pattern, replacement);
const replacedContent = content.replace(pattern, replacement as any);
if (replacedContent !== content) {
return replacedContent;
}
Expand All@@ -30,7 +30,7 @@ function replaceGeneratedRouteExport(

const routeCode = content.slice(0, sourceMapIndex);
const sourceMap = content.slice(sourceMapIndex);
const wrappedRouteCode = routeCode.replace(pattern, replacement);
const wrappedRouteCode = routeCode.replace(pattern, replacement as any);
if (wrappedRouteCode === routeCode) {
throw new Error(errorMessage);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/runtime-import.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
import { describe, expect, test, vi } from 'vitest';

vi.mock('@vercel/functions', () => {
throw new Error('@vercel/functions should not load during runtime import');
});

describe('runtime entrypoint', () => {
test('does not load @vercel/functions during module evaluation', async () => {
await expect(import('./runtime')).resolves.toBeDefined();
});
});
14 changes: 10 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
import { parseWorkflowName } from '@workflow/utils/parse-name';
import {
type Event,
getQueueTopicPrefix,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
WorkflowInvokePayloadSchema,
Expand DownExpand Up@@ -126,12 +128,16 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
* @returns A function that can be used as a Vercel API route.
*/
export function workflowEntrypoint(
workflowCode: string
workflowCode: string,
options?: { namespace?: string }
): (req: Request) => Promise<Response> {
const namespace = resolveQueueNamespace(options?.namespace);
const workflowPrefix = getQueueTopicPrefix('workflow', namespace);

const { createQueueHandler, specVersion: worldSpecVersion } =
getWorldHandlers();
const handler = createQueueHandler(
'__wkf_workflow_',
workflowPrefix,
async (message_, metadata) => {
// Check if this is a health check message
// NOTE: Health check messages are intentionally unauthenticated for monitoring purposes.
Expand All@@ -156,7 +162,7 @@ export function workflowEntrypoint(
} = WorkflowInvokePayloadSchema.parse(message_);
const { requestId } = metadata;
// Extract the workflow name from the topic name
const workflowName = metadata.queueName.slice('__wkf_workflow_'.length);
const workflowName = metadata.queueName.slice(workflowPrefix.length);

// --- Max delivery check ---
// Enforce max delivery limit before any infrastructure calls.
Expand DownExpand Up@@ -744,7 +750,7 @@ export function workflowEntrypoint(
);
await queueMessage(
world,
getWorkflowQueueName(workflowName),
getWorkflowQueueName(workflowName, namespace),
{
runId,
traceCarrier: traceContext,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime/helpers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,23 @@ describe('getWorkflowQueueName', () => {
it('should throw for empty string', () => {
expect(() => getWorkflowQueueName('')).toThrow('Invalid workflow name');
});

it('should use default prefix when no namespace is provided', () => {
expect(getWorkflowQueueName('myFlow')).toBe('__wkf_workflow_myFlow');
expect(getWorkflowQueueName('myFlow', undefined)).toBe(
'__wkf_workflow_myFlow'
);
});

it('should use namespaced prefix when namespace is provided', () => {
expect(getWorkflowQueueName('myFlow', 'custom')).toBe(
'__custom_wkf_workflow_myFlow'
);
});

it('should reject invalid namespace in queue name construction', () => {
expect(() => getWorkflowQueueName('myFlow', '123bad')).toThrow();
});
});

describe('healthCheck', () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/core/src/runtime/helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,9 @@ import type {
World,
} from '@workflow/world';
import {
getQueueTopicPrefix,
HealthCheckPayloadSchema,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
} from '@workflow/world';
Expand All@@ -33,13 +35,20 @@ const SAFE_WORKFLOW_NAME_PATTERN = /^[a-zA-Z0-9_\-./@]+$/;
* Ensures the workflow name only contains safe characters before
* interpolating it into the queue name string.
*/
export function getWorkflowQueueName(workflowName: string): ValidQueueName {
export function getWorkflowQueueName(
workflowName: string,
namespace?: string
): ValidQueueName {
if (!SAFE_WORKFLOW_NAME_PATTERN.test(workflowName)) {
throw new Error(
`Invalid workflow name "${workflowName}": must only contain alphanumeric characters, underscores, hyphens, dots, forward slashes, or at signs`
);
}
return `__wkf_workflow_${workflowName}` as ValidQueueName;
const prefix = getQueueTopicPrefix(
'workflow',
resolveQueueNamespace(namespace)
);
return `${prefix}${workflowName}` as ValidQueueName;
}

const generateId = monotonicFactory();
Expand DownExpand Up@@ -324,16 +333,14 @@ async function readHealthCheckResponse(
export async function healthCheck(
world: World,
endpoint: HealthCheckEndpoint,
options?: HealthCheckOptions
options?: HealthCheckOptions & { namespace?: string }
): Promise<HealthCheckResult> {
const timeout = options?.timeout ?? DEFAULT_HEALTH_CHECK_TIMEOUT;
const correlationId = generateId();
const streamName = getHealthCheckStreamName(correlationId);

const queueName: ValidQueueName =
endpoint === 'workflow'
? '__wkf_workflow_health_check'
: '__wkf_step_health_check';
const queueName =
`${getQueueTopicPrefix(endpoint, resolveQueueNamespace(options?.namespace))}health_check` as ValidQueueName;

const startTime = Date.now();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Backport #2305: feat(core): add optional namespace for queue topic prefix by github-actions[bot] · Pull Request #2341 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/queue-namespace-primitive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@workflow/world": minor
"@workflow/builders": minor
"@workflow/core": minor
"@workflow/world-local": minor
"@workflow/world-postgres": minor
---

Add an optional `namespace` parameter that scopes queue topic prefixes to `__{namespace}_wkf_workflow_*`. This allows configuring multiple frameworks in the same deployment without queue topic collision.
6 changes: 3 additions & 3 deletions packages/astro/src/builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,11 +164,11 @@ export const prerender = false;`,
// Normalize request, needed for preserving request through astro
workflowsRouteContent = replaceGeneratedRouteExport(
workflowsRouteContent,
/const handler = workflowEntrypoint\(workflowCode\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
`${NORMALIZE_REQUEST_CODE}
/const handler = workflowEntrypoint\(workflowCode(?<options>[^)]*)\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
(_match, options = '') => `${NORMALIZE_REQUEST_CODE}
const handleWorkflowRequest = async ({request}) => {
const normalRequest = await normalizeRequest(request);
return workflowEntrypoint(workflowCode)(normalRequest);
return workflowEntrypoint(workflowCode${options})(normalRequest);
};

export const HEAD = handleWorkflowRequest;
Expand Down
6 changes: 5 additions & 1 deletion packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
applySwcTransform,
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import { getImportPath } from './module-specifier.js';
Expand DownExpand Up@@ -994,6 +995,9 @@ export abstract class BaseBuilder {
}
}

const workflowEntrypointOptionsCode =
createWorkflowEntrypointOptionsCode();

const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;

Expand All@@ -1003,7 +1007,7 @@ import { workflowEntrypoint } from 'workflow/runtime';

const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

const handler = workflowEntrypoint(workflowCode);
const handler = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});

export const HEAD = handler;
export const POST = handler;`;
Expand Down
51 changes: 51 additions & 0 deletions packages/builders/src/constants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
} from './constants.js';

describe('createWorkflowQueueTrigger', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('uses the default workflow topic without a namespace', () => {
expect(createWorkflowQueueTrigger().topic).toBe('__wkf_workflow_*');
});

it('uses an explicit namespace when provided', () => {
expect(createWorkflowQueueTrigger({ namespace: 'custom' }).topic).toBe(
'__custom_wkf_workflow_*'
);
});

it('uses WORKFLOW_QUEUE_NAMESPACE when no explicit namespace is provided', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowQueueTrigger().topic).toBe('__custom_wkf_workflow_*');
});
});

describe('createWorkflowEntrypointOptionsCode', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('omits runtime options without a namespace', () => {
expect(createWorkflowEntrypointOptionsCode()).toBe('');
});

it('inlines an explicit namespace', () => {
expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe(
', { namespace: "custom" }'
);
});

it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowEntrypointOptionsCode()).toBe(
', { namespace: "custom" }'
);
});
});
118 changes: 100 additions & 18 deletions packages/builders/src/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,105 @@
const QUEUE_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/;

function resolveQueueNamespace(namespace?: string): string | undefined {
return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined;
}

function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) {
if (namespace !== undefined) {
if (!QUEUE_NAMESPACE_PATTERN.test(namespace)) {
throw new Error(
`Invalid queue namespace "${namespace}": must be lowercase alphanumeric, starting with a letter`
);
}

return `__${namespace}_wkf_${kind}_`;
}

return `__wkf_${kind}_`;
}

/**
* Queue trigger configuration for workflow step execution.
* Steps are queued to the __wkf_step_* topic.
* Creates a queue trigger configuration for workflow step execution.
* Steps are queued to the step topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_step_*'
* createStepQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_step_*'
* createStepQueueTrigger({ namespace: 'custom' })
*/
export const STEP_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_step_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export function createStepQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('step', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Default step queue trigger (no namespace). Backward compatible.
*/
export const STEP_QUEUE_TRIGGER = createStepQueueTrigger();

/**
* Creates a queue trigger configuration for workflow orchestration.
* Workflows are queued to the workflow topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_workflow_*'
* createWorkflowQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_workflow_*'
* createWorkflowQueueTrigger({ namespace: 'custom' })
*/
export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('workflow', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Creates the optional second argument for generated `workflowEntrypoint()`
* calls. The namespace is resolved while building so generated route files do
* not need `WORKFLOW_QUEUE_NAMESPACE` at runtime.
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
}) {
const namespace = resolveQueueNamespace(options?.namespace);

if (!namespace) {
return '';
}

// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);

return `, { namespace: ${JSON.stringify(namespace)} }`;
}

/**
* Queue trigger configuration for workflow orchestration.
* Workflows are queued to the __wkf_workflow_* topic.
* Default queue trigger (no namespace). Backward compatible.
*/
export const WORKFLOW_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_workflow_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger();
8 changes: 7 additions & 1 deletion packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,13 @@ export {
getDecoratorOptionsForDirectory,
getDecoratorOptionsForDirectoryWithConfigPath,
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export {
createStepQueueTrigger,
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
STEP_QUEUE_TRIGGER,
WORKFLOW_QUEUE_TRIGGER,
} from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
Expand Down
6 changes: 3 additions & 3 deletions packages/builders/src/request-converter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,10 @@ async function normalizeRequest(request) {
function replaceGeneratedRouteExport(
content: string,
pattern: RegExp,
replacement: string,
replacement: string | ((substring: string, ...args: any[]) => string),
errorMessage: string
) {
const replacedContent = content.replace(pattern, replacement);
const replacedContent = content.replace(pattern, replacement as any);
if (replacedContent !== content) {
return replacedContent;
}
Expand All@@ -30,7 +30,7 @@ function replaceGeneratedRouteExport(

const routeCode = content.slice(0, sourceMapIndex);
const sourceMap = content.slice(sourceMapIndex);
const wrappedRouteCode = routeCode.replace(pattern, replacement);
const wrappedRouteCode = routeCode.replace(pattern, replacement as any);
if (wrappedRouteCode === routeCode) {
throw new Error(errorMessage);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/runtime-import.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
import { describe, expect, test, vi } from 'vitest';

vi.mock('@vercel/functions', () => {
throw new Error('@vercel/functions should not load during runtime import');
});

describe('runtime entrypoint', () => {
test('does not load @vercel/functions during module evaluation', async () => {
await expect(import('./runtime')).resolves.toBeDefined();
});
});
14 changes: 10 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
import { parseWorkflowName } from '@workflow/utils/parse-name';
import {
type Event,
getQueueTopicPrefix,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
WorkflowInvokePayloadSchema,
Expand DownExpand Up@@ -126,12 +128,16 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
* @returns A function that can be used as a Vercel API route.
*/
export function workflowEntrypoint(
workflowCode: string
workflowCode: string,
options?: { namespace?: string }
): (req: Request) => Promise<Response> {
const namespace = resolveQueueNamespace(options?.namespace);
const workflowPrefix = getQueueTopicPrefix('workflow', namespace);

const { createQueueHandler, specVersion: worldSpecVersion } =
getWorldHandlers();
const handler = createQueueHandler(
'__wkf_workflow_',
workflowPrefix,
async (message_, metadata) => {
// Check if this is a health check message
// NOTE: Health check messages are intentionally unauthenticated for monitoring purposes.
Expand All@@ -156,7 +162,7 @@ export function workflowEntrypoint(
} = WorkflowInvokePayloadSchema.parse(message_);
const { requestId } = metadata;
// Extract the workflow name from the topic name
const workflowName = metadata.queueName.slice('__wkf_workflow_'.length);
const workflowName = metadata.queueName.slice(workflowPrefix.length);

// --- Max delivery check ---
// Enforce max delivery limit before any infrastructure calls.
Expand DownExpand Up@@ -744,7 +750,7 @@ export function workflowEntrypoint(
);
await queueMessage(
world,
getWorkflowQueueName(workflowName),
getWorkflowQueueName(workflowName, namespace),
{
runId,
traceCarrier: traceContext,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime/helpers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,23 @@ describe('getWorkflowQueueName', () => {
it('should throw for empty string', () => {
expect(() => getWorkflowQueueName('')).toThrow('Invalid workflow name');
});

it('should use default prefix when no namespace is provided', () => {
expect(getWorkflowQueueName('myFlow')).toBe('__wkf_workflow_myFlow');
expect(getWorkflowQueueName('myFlow', undefined)).toBe(
'__wkf_workflow_myFlow'
);
});

it('should use namespaced prefix when namespace is provided', () => {
expect(getWorkflowQueueName('myFlow', 'custom')).toBe(
'__custom_wkf_workflow_myFlow'
);
});

it('should reject invalid namespace in queue name construction', () => {
expect(() => getWorkflowQueueName('myFlow', '123bad')).toThrow();
});
});

describe('healthCheck', () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/core/src/runtime/helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,9 @@ import type {
World,
} from '@workflow/world';
import {
getQueueTopicPrefix,
HealthCheckPayloadSchema,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
} from '@workflow/world';
Expand All@@ -33,13 +35,20 @@ const SAFE_WORKFLOW_NAME_PATTERN = /^[a-zA-Z0-9_\-./@]+$/;
* Ensures the workflow name only contains safe characters before
* interpolating it into the queue name string.
*/
export function getWorkflowQueueName(workflowName: string): ValidQueueName {
export function getWorkflowQueueName(
workflowName: string,
namespace?: string
): ValidQueueName {
if (!SAFE_WORKFLOW_NAME_PATTERN.test(workflowName)) {
throw new Error(
`Invalid workflow name "${workflowName}": must only contain alphanumeric characters, underscores, hyphens, dots, forward slashes, or at signs`
);
}
return `__wkf_workflow_${workflowName}` as ValidQueueName;
const prefix = getQueueTopicPrefix(
'workflow',
resolveQueueNamespace(namespace)
);
return `${prefix}${workflowName}` as ValidQueueName;
}

const generateId = monotonicFactory();
Expand DownExpand Up@@ -324,16 +333,14 @@ async function readHealthCheckResponse(
export async function healthCheck(
world: World,
endpoint: HealthCheckEndpoint,
options?: HealthCheckOptions
options?: HealthCheckOptions & { namespace?: string }
): Promise<HealthCheckResult> {
const timeout = options?.timeout ?? DEFAULT_HEALTH_CHECK_TIMEOUT;
const correlationId = generateId();
const streamName = getHealthCheckStreamName(correlationId);

const queueName: ValidQueueName =
endpoint === 'workflow'
? '__wkf_workflow_health_check'
: '__wkf_step_health_check';
const queueName =
`${getQueueTopicPrefix(endpoint, resolveQueueNamespace(options?.namespace))}health_check` as ValidQueueName;

const startTime = Date.now();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Backport #2305: feat(core): add optional namespace for queue topic prefix by github-actions[bot] · Pull Request #2341 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/queue-namespace-primitive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@workflow/world": minor
"@workflow/builders": minor
"@workflow/core": minor
"@workflow/world-local": minor
"@workflow/world-postgres": minor
---

Add an optional `namespace` parameter that scopes queue topic prefixes to `__{namespace}_wkf_workflow_*`. This allows configuring multiple frameworks in the same deployment without queue topic collision.
6 changes: 3 additions & 3 deletions packages/astro/src/builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,11 +164,11 @@ export const prerender = false;`,
// Normalize request, needed for preserving request through astro
workflowsRouteContent = replaceGeneratedRouteExport(
workflowsRouteContent,
/const handler = workflowEntrypoint\(workflowCode\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
`${NORMALIZE_REQUEST_CODE}
/const handler = workflowEntrypoint\(workflowCode(?<options>[^)]*)\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
(_match, options = '') => `${NORMALIZE_REQUEST_CODE}
const handleWorkflowRequest = async ({request}) => {
const normalRequest = await normalizeRequest(request);
return workflowEntrypoint(workflowCode)(normalRequest);
return workflowEntrypoint(workflowCode${options})(normalRequest);
};

export const HEAD = handleWorkflowRequest;
Expand Down
6 changes: 5 additions & 1 deletion packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
applySwcTransform,
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import { getImportPath } from './module-specifier.js';
Expand DownExpand Up@@ -994,6 +995,9 @@ export abstract class BaseBuilder {
}
}

const workflowEntrypointOptionsCode =
createWorkflowEntrypointOptionsCode();

const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;

Expand All@@ -1003,7 +1007,7 @@ import { workflowEntrypoint } from 'workflow/runtime';

const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

const handler = workflowEntrypoint(workflowCode);
const handler = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});

export const HEAD = handler;
export const POST = handler;`;
Expand Down
51 changes: 51 additions & 0 deletions packages/builders/src/constants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
} from './constants.js';

describe('createWorkflowQueueTrigger', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('uses the default workflow topic without a namespace', () => {
expect(createWorkflowQueueTrigger().topic).toBe('__wkf_workflow_*');
});

it('uses an explicit namespace when provided', () => {
expect(createWorkflowQueueTrigger({ namespace: 'custom' }).topic).toBe(
'__custom_wkf_workflow_*'
);
});

it('uses WORKFLOW_QUEUE_NAMESPACE when no explicit namespace is provided', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowQueueTrigger().topic).toBe('__custom_wkf_workflow_*');
});
});

describe('createWorkflowEntrypointOptionsCode', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('omits runtime options without a namespace', () => {
expect(createWorkflowEntrypointOptionsCode()).toBe('');
});

it('inlines an explicit namespace', () => {
expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe(
', { namespace: "custom" }'
);
});

it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowEntrypointOptionsCode()).toBe(
', { namespace: "custom" }'
);
});
});
118 changes: 100 additions & 18 deletions packages/builders/src/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,105 @@
const QUEUE_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/;

function resolveQueueNamespace(namespace?: string): string | undefined {
return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined;
}

function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) {
if (namespace !== undefined) {
if (!QUEUE_NAMESPACE_PATTERN.test(namespace)) {
throw new Error(
`Invalid queue namespace "${namespace}": must be lowercase alphanumeric, starting with a letter`
);
}

return `__${namespace}_wkf_${kind}_`;
}

return `__wkf_${kind}_`;
}

/**
* Queue trigger configuration for workflow step execution.
* Steps are queued to the __wkf_step_* topic.
* Creates a queue trigger configuration for workflow step execution.
* Steps are queued to the step topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_step_*'
* createStepQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_step_*'
* createStepQueueTrigger({ namespace: 'custom' })
*/
export const STEP_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_step_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export function createStepQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('step', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Default step queue trigger (no namespace). Backward compatible.
*/
export const STEP_QUEUE_TRIGGER = createStepQueueTrigger();

/**
* Creates a queue trigger configuration for workflow orchestration.
* Workflows are queued to the workflow topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_workflow_*'
* createWorkflowQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_workflow_*'
* createWorkflowQueueTrigger({ namespace: 'custom' })
*/
export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('workflow', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Creates the optional second argument for generated `workflowEntrypoint()`
* calls. The namespace is resolved while building so generated route files do
* not need `WORKFLOW_QUEUE_NAMESPACE` at runtime.
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
}) {
const namespace = resolveQueueNamespace(options?.namespace);

if (!namespace) {
return '';
}

// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);

return `, { namespace: ${JSON.stringify(namespace)} }`;
}

/**
* Queue trigger configuration for workflow orchestration.
* Workflows are queued to the __wkf_workflow_* topic.
* Default queue trigger (no namespace). Backward compatible.
*/
export const WORKFLOW_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_workflow_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger();
8 changes: 7 additions & 1 deletion packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,13 @@ export {
getDecoratorOptionsForDirectory,
getDecoratorOptionsForDirectoryWithConfigPath,
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export {
createStepQueueTrigger,
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
STEP_QUEUE_TRIGGER,
WORKFLOW_QUEUE_TRIGGER,
} from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
Expand Down
6 changes: 3 additions & 3 deletions packages/builders/src/request-converter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,10 @@ async function normalizeRequest(request) {
function replaceGeneratedRouteExport(
content: string,
pattern: RegExp,
replacement: string,
replacement: string | ((substring: string, ...args: any[]) => string),
errorMessage: string
) {
const replacedContent = content.replace(pattern, replacement);
const replacedContent = content.replace(pattern, replacement as any);
if (replacedContent !== content) {
return replacedContent;
}
Expand All@@ -30,7 +30,7 @@ function replaceGeneratedRouteExport(

const routeCode = content.slice(0, sourceMapIndex);
const sourceMap = content.slice(sourceMapIndex);
const wrappedRouteCode = routeCode.replace(pattern, replacement);
const wrappedRouteCode = routeCode.replace(pattern, replacement as any);
if (wrappedRouteCode === routeCode) {
throw new Error(errorMessage);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/runtime-import.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
import { describe, expect, test, vi } from 'vitest';

vi.mock('@vercel/functions', () => {
throw new Error('@vercel/functions should not load during runtime import');
});

describe('runtime entrypoint', () => {
test('does not load @vercel/functions during module evaluation', async () => {
await expect(import('./runtime')).resolves.toBeDefined();
});
});
14 changes: 10 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
import { parseWorkflowName } from '@workflow/utils/parse-name';
import {
type Event,
getQueueTopicPrefix,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
WorkflowInvokePayloadSchema,
Expand DownExpand Up@@ -126,12 +128,16 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
* @returns A function that can be used as a Vercel API route.
*/
export function workflowEntrypoint(
workflowCode: string
workflowCode: string,
options?: { namespace?: string }
): (req: Request) => Promise<Response> {
const namespace = resolveQueueNamespace(options?.namespace);
const workflowPrefix = getQueueTopicPrefix('workflow', namespace);

const { createQueueHandler, specVersion: worldSpecVersion } =
getWorldHandlers();
const handler = createQueueHandler(
'__wkf_workflow_',
workflowPrefix,
async (message_, metadata) => {
// Check if this is a health check message
// NOTE: Health check messages are intentionally unauthenticated for monitoring purposes.
Expand All@@ -156,7 +162,7 @@ export function workflowEntrypoint(
} = WorkflowInvokePayloadSchema.parse(message_);
const { requestId } = metadata;
// Extract the workflow name from the topic name
const workflowName = metadata.queueName.slice('__wkf_workflow_'.length);
const workflowName = metadata.queueName.slice(workflowPrefix.length);

// --- Max delivery check ---
// Enforce max delivery limit before any infrastructure calls.
Expand DownExpand Up@@ -744,7 +750,7 @@ export function workflowEntrypoint(
);
await queueMessage(
world,
getWorkflowQueueName(workflowName),
getWorkflowQueueName(workflowName, namespace),
{
runId,
traceCarrier: traceContext,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime/helpers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,23 @@ describe('getWorkflowQueueName', () => {
it('should throw for empty string', () => {
expect(() => getWorkflowQueueName('')).toThrow('Invalid workflow name');
});

it('should use default prefix when no namespace is provided', () => {
expect(getWorkflowQueueName('myFlow')).toBe('__wkf_workflow_myFlow');
expect(getWorkflowQueueName('myFlow', undefined)).toBe(
'__wkf_workflow_myFlow'
);
});

it('should use namespaced prefix when namespace is provided', () => {
expect(getWorkflowQueueName('myFlow', 'custom')).toBe(
'__custom_wkf_workflow_myFlow'
);
});

it('should reject invalid namespace in queue name construction', () => {
expect(() => getWorkflowQueueName('myFlow', '123bad')).toThrow();
});
});

describe('healthCheck', () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/core/src/runtime/helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,9 @@ import type {
World,
} from '@workflow/world';
import {
getQueueTopicPrefix,
HealthCheckPayloadSchema,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
} from '@workflow/world';
Expand All@@ -33,13 +35,20 @@ const SAFE_WORKFLOW_NAME_PATTERN = /^[a-zA-Z0-9_\-./@]+$/;
* Ensures the workflow name only contains safe characters before
* interpolating it into the queue name string.
*/
export function getWorkflowQueueName(workflowName: string): ValidQueueName {
export function getWorkflowQueueName(
workflowName: string,
namespace?: string
): ValidQueueName {
if (!SAFE_WORKFLOW_NAME_PATTERN.test(workflowName)) {
throw new Error(
`Invalid workflow name "${workflowName}": must only contain alphanumeric characters, underscores, hyphens, dots, forward slashes, or at signs`
);
}
return `__wkf_workflow_${workflowName}` as ValidQueueName;
const prefix = getQueueTopicPrefix(
'workflow',
resolveQueueNamespace(namespace)
);
return `${prefix}${workflowName}` as ValidQueueName;
}

const generateId = monotonicFactory();
Expand DownExpand Up@@ -324,16 +333,14 @@ async function readHealthCheckResponse(
export async function healthCheck(
world: World,
endpoint: HealthCheckEndpoint,
options?: HealthCheckOptions
options?: HealthCheckOptions & { namespace?: string }
): Promise<HealthCheckResult> {
const timeout = options?.timeout ?? DEFAULT_HEALTH_CHECK_TIMEOUT;
const correlationId = generateId();
const streamName = getHealthCheckStreamName(correlationId);

const queueName: ValidQueueName =
endpoint === 'workflow'
? '__wkf_workflow_health_check'
: '__wkf_step_health_check';
const queueName =
`${getQueueTopicPrefix(endpoint, resolveQueueNamespace(options?.namespace))}health_check` as ValidQueueName;

const startTime = Date.now();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Backport #2305: feat(core): add optional namespace for queue topic prefix by github-actions[bot] · Pull Request #2341 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/queue-namespace-primitive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@workflow/world": minor
"@workflow/builders": minor
"@workflow/core": minor
"@workflow/world-local": minor
"@workflow/world-postgres": minor
---

Add an optional `namespace` parameter that scopes queue topic prefixes to `__{namespace}_wkf_workflow_*`. This allows configuring multiple frameworks in the same deployment without queue topic collision.
6 changes: 3 additions & 3 deletions packages/astro/src/builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,11 +164,11 @@ export const prerender = false;`,
// Normalize request, needed for preserving request through astro
workflowsRouteContent = replaceGeneratedRouteExport(
workflowsRouteContent,
/const handler = workflowEntrypoint\(workflowCode\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
`${NORMALIZE_REQUEST_CODE}
/const handler = workflowEntrypoint\(workflowCode(?<options>[^)]*)\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
(_match, options = '') => `${NORMALIZE_REQUEST_CODE}
const handleWorkflowRequest = async ({request}) => {
const normalRequest = await normalizeRequest(request);
return workflowEntrypoint(workflowCode)(normalRequest);
return workflowEntrypoint(workflowCode${options})(normalRequest);
};

export const HEAD = handleWorkflowRequest;
Expand Down
6 changes: 5 additions & 1 deletion packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
applySwcTransform,
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import { getImportPath } from './module-specifier.js';
Expand DownExpand Up@@ -994,6 +995,9 @@ export abstract class BaseBuilder {
}
}

const workflowEntrypointOptionsCode =
createWorkflowEntrypointOptionsCode();

const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;

Expand All@@ -1003,7 +1007,7 @@ import { workflowEntrypoint } from 'workflow/runtime';

const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

const handler = workflowEntrypoint(workflowCode);
const handler = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});

export const HEAD = handler;
export const POST = handler;`;
Expand Down
51 changes: 51 additions & 0 deletions packages/builders/src/constants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
} from './constants.js';

describe('createWorkflowQueueTrigger', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('uses the default workflow topic without a namespace', () => {
expect(createWorkflowQueueTrigger().topic).toBe('__wkf_workflow_*');
});

it('uses an explicit namespace when provided', () => {
expect(createWorkflowQueueTrigger({ namespace: 'custom' }).topic).toBe(
'__custom_wkf_workflow_*'
);
});

it('uses WORKFLOW_QUEUE_NAMESPACE when no explicit namespace is provided', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowQueueTrigger().topic).toBe('__custom_wkf_workflow_*');
});
});

describe('createWorkflowEntrypointOptionsCode', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('omits runtime options without a namespace', () => {
expect(createWorkflowEntrypointOptionsCode()).toBe('');
});

it('inlines an explicit namespace', () => {
expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe(
', { namespace: "custom" }'
);
});

it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowEntrypointOptionsCode()).toBe(
', { namespace: "custom" }'
);
});
});
118 changes: 100 additions & 18 deletions packages/builders/src/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,105 @@
const QUEUE_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/;

function resolveQueueNamespace(namespace?: string): string | undefined {
return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined;
}

function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) {
if (namespace !== undefined) {
if (!QUEUE_NAMESPACE_PATTERN.test(namespace)) {
throw new Error(
`Invalid queue namespace "${namespace}": must be lowercase alphanumeric, starting with a letter`
);
}

return `__${namespace}_wkf_${kind}_`;
}

return `__wkf_${kind}_`;
}

/**
* Queue trigger configuration for workflow step execution.
* Steps are queued to the __wkf_step_* topic.
* Creates a queue trigger configuration for workflow step execution.
* Steps are queued to the step topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_step_*'
* createStepQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_step_*'
* createStepQueueTrigger({ namespace: 'custom' })
*/
export const STEP_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_step_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export function createStepQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('step', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Default step queue trigger (no namespace). Backward compatible.
*/
export const STEP_QUEUE_TRIGGER = createStepQueueTrigger();

/**
* Creates a queue trigger configuration for workflow orchestration.
* Workflows are queued to the workflow topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_workflow_*'
* createWorkflowQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_workflow_*'
* createWorkflowQueueTrigger({ namespace: 'custom' })
*/
export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('workflow', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Creates the optional second argument for generated `workflowEntrypoint()`
* calls. The namespace is resolved while building so generated route files do
* not need `WORKFLOW_QUEUE_NAMESPACE` at runtime.
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
}) {
const namespace = resolveQueueNamespace(options?.namespace);

if (!namespace) {
return '';
}

// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);

return `, { namespace: ${JSON.stringify(namespace)} }`;
}

/**
* Queue trigger configuration for workflow orchestration.
* Workflows are queued to the __wkf_workflow_* topic.
* Default queue trigger (no namespace). Backward compatible.
*/
export const WORKFLOW_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_workflow_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger();
8 changes: 7 additions & 1 deletion packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,13 @@ export {
getDecoratorOptionsForDirectory,
getDecoratorOptionsForDirectoryWithConfigPath,
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export {
createStepQueueTrigger,
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
STEP_QUEUE_TRIGGER,
WORKFLOW_QUEUE_TRIGGER,
} from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
Expand Down
6 changes: 3 additions & 3 deletions packages/builders/src/request-converter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,10 @@ async function normalizeRequest(request) {
function replaceGeneratedRouteExport(
content: string,
pattern: RegExp,
replacement: string,
replacement: string | ((substring: string, ...args: any[]) => string),
errorMessage: string
) {
const replacedContent = content.replace(pattern, replacement);
const replacedContent = content.replace(pattern, replacement as any);
if (replacedContent !== content) {
return replacedContent;
}
Expand All@@ -30,7 +30,7 @@ function replaceGeneratedRouteExport(

const routeCode = content.slice(0, sourceMapIndex);
const sourceMap = content.slice(sourceMapIndex);
const wrappedRouteCode = routeCode.replace(pattern, replacement);
const wrappedRouteCode = routeCode.replace(pattern, replacement as any);
if (wrappedRouteCode === routeCode) {
throw new Error(errorMessage);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/runtime-import.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
import { describe, expect, test, vi } from 'vitest';

vi.mock('@vercel/functions', () => {
throw new Error('@vercel/functions should not load during runtime import');
});

describe('runtime entrypoint', () => {
test('does not load @vercel/functions during module evaluation', async () => {
await expect(import('./runtime')).resolves.toBeDefined();
});
});
14 changes: 10 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
import { parseWorkflowName } from '@workflow/utils/parse-name';
import {
type Event,
getQueueTopicPrefix,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
WorkflowInvokePayloadSchema,
Expand DownExpand Up@@ -126,12 +128,16 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
* @returns A function that can be used as a Vercel API route.
*/
export function workflowEntrypoint(
workflowCode: string
workflowCode: string,
options?: { namespace?: string }
): (req: Request) => Promise<Response> {
const namespace = resolveQueueNamespace(options?.namespace);
const workflowPrefix = getQueueTopicPrefix('workflow', namespace);

const { createQueueHandler, specVersion: worldSpecVersion } =
getWorldHandlers();
const handler = createQueueHandler(
'__wkf_workflow_',
workflowPrefix,
async (message_, metadata) => {
// Check if this is a health check message
// NOTE: Health check messages are intentionally unauthenticated for monitoring purposes.
Expand All@@ -156,7 +162,7 @@ export function workflowEntrypoint(
} = WorkflowInvokePayloadSchema.parse(message_);
const { requestId } = metadata;
// Extract the workflow name from the topic name
const workflowName = metadata.queueName.slice('__wkf_workflow_'.length);
const workflowName = metadata.queueName.slice(workflowPrefix.length);

// --- Max delivery check ---
// Enforce max delivery limit before any infrastructure calls.
Expand DownExpand Up@@ -744,7 +750,7 @@ export function workflowEntrypoint(
);
await queueMessage(
world,
getWorkflowQueueName(workflowName),
getWorkflowQueueName(workflowName, namespace),
{
runId,
traceCarrier: traceContext,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime/helpers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,23 @@ describe('getWorkflowQueueName', () => {
it('should throw for empty string', () => {
expect(() => getWorkflowQueueName('')).toThrow('Invalid workflow name');
});

it('should use default prefix when no namespace is provided', () => {
expect(getWorkflowQueueName('myFlow')).toBe('__wkf_workflow_myFlow');
expect(getWorkflowQueueName('myFlow', undefined)).toBe(
'__wkf_workflow_myFlow'
);
});

it('should use namespaced prefix when namespace is provided', () => {
expect(getWorkflowQueueName('myFlow', 'custom')).toBe(
'__custom_wkf_workflow_myFlow'
);
});

it('should reject invalid namespace in queue name construction', () => {
expect(() => getWorkflowQueueName('myFlow', '123bad')).toThrow();
});
});

describe('healthCheck', () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/core/src/runtime/helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,9 @@ import type {
World,
} from '@workflow/world';
import {
getQueueTopicPrefix,
HealthCheckPayloadSchema,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
} from '@workflow/world';
Expand All@@ -33,13 +35,20 @@ const SAFE_WORKFLOW_NAME_PATTERN = /^[a-zA-Z0-9_\-./@]+$/;
* Ensures the workflow name only contains safe characters before
* interpolating it into the queue name string.
*/
export function getWorkflowQueueName(workflowName: string): ValidQueueName {
export function getWorkflowQueueName(
workflowName: string,
namespace?: string
): ValidQueueName {
if (!SAFE_WORKFLOW_NAME_PATTERN.test(workflowName)) {
throw new Error(
`Invalid workflow name "${workflowName}": must only contain alphanumeric characters, underscores, hyphens, dots, forward slashes, or at signs`
);
}
return `__wkf_workflow_${workflowName}` as ValidQueueName;
const prefix = getQueueTopicPrefix(
'workflow',
resolveQueueNamespace(namespace)
);
return `${prefix}${workflowName}` as ValidQueueName;
}

const generateId = monotonicFactory();
Expand DownExpand Up@@ -324,16 +333,14 @@ async function readHealthCheckResponse(
export async function healthCheck(
world: World,
endpoint: HealthCheckEndpoint,
options?: HealthCheckOptions
options?: HealthCheckOptions & { namespace?: string }
): Promise<HealthCheckResult> {
const timeout = options?.timeout ?? DEFAULT_HEALTH_CHECK_TIMEOUT;
const correlationId = generateId();
const streamName = getHealthCheckStreamName(correlationId);

const queueName: ValidQueueName =
endpoint === 'workflow'
? '__wkf_workflow_health_check'
: '__wkf_step_health_check';
const queueName =
`${getQueueTopicPrefix(endpoint, resolveQueueNamespace(options?.namespace))}health_check` as ValidQueueName;

const startTime = Date.now();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Backport #2305: feat(core): add optional namespace for queue topic prefix by github-actions[bot] · Pull Request #2341 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/queue-namespace-primitive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@workflow/world": minor
"@workflow/builders": minor
"@workflow/core": minor
"@workflow/world-local": minor
"@workflow/world-postgres": minor
---

Add an optional `namespace` parameter that scopes queue topic prefixes to `__{namespace}_wkf_workflow_*`. This allows configuring multiple frameworks in the same deployment without queue topic collision.
6 changes: 3 additions & 3 deletions packages/astro/src/builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,11 +164,11 @@ export const prerender = false;`,
// Normalize request, needed for preserving request through astro
workflowsRouteContent = replaceGeneratedRouteExport(
workflowsRouteContent,
/const handler = workflowEntrypoint\(workflowCode\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
`${NORMALIZE_REQUEST_CODE}
/const handler = workflowEntrypoint\(workflowCode(?<options>[^)]*)\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
(_match, options = '') => `${NORMALIZE_REQUEST_CODE}
const handleWorkflowRequest = async ({request}) => {
const normalRequest = await normalizeRequest(request);
return workflowEntrypoint(workflowCode)(normalRequest);
return workflowEntrypoint(workflowCode${options})(normalRequest);
};

export const HEAD = handleWorkflowRequest;
Expand Down
6 changes: 5 additions & 1 deletion packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
applySwcTransform,
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import { getImportPath } from './module-specifier.js';
Expand DownExpand Up@@ -994,6 +995,9 @@ export abstract class BaseBuilder {
}
}

const workflowEntrypointOptionsCode =
createWorkflowEntrypointOptionsCode();

const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;

Expand All@@ -1003,7 +1007,7 @@ import { workflowEntrypoint } from 'workflow/runtime';

const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

const handler = workflowEntrypoint(workflowCode);
const handler = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});

export const HEAD = handler;
export const POST = handler;`;
Expand Down
51 changes: 51 additions & 0 deletions packages/builders/src/constants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
} from './constants.js';

describe('createWorkflowQueueTrigger', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('uses the default workflow topic without a namespace', () => {
expect(createWorkflowQueueTrigger().topic).toBe('__wkf_workflow_*');
});

it('uses an explicit namespace when provided', () => {
expect(createWorkflowQueueTrigger({ namespace: 'custom' }).topic).toBe(
'__custom_wkf_workflow_*'
);
});

it('uses WORKFLOW_QUEUE_NAMESPACE when no explicit namespace is provided', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowQueueTrigger().topic).toBe('__custom_wkf_workflow_*');
});
});

describe('createWorkflowEntrypointOptionsCode', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('omits runtime options without a namespace', () => {
expect(createWorkflowEntrypointOptionsCode()).toBe('');
});

it('inlines an explicit namespace', () => {
expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe(
', { namespace: "custom" }'
);
});

it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowEntrypointOptionsCode()).toBe(
', { namespace: "custom" }'
);
});
});
118 changes: 100 additions & 18 deletions packages/builders/src/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,105 @@
const QUEUE_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/;

function resolveQueueNamespace(namespace?: string): string | undefined {
return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined;
}

function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) {
if (namespace !== undefined) {
if (!QUEUE_NAMESPACE_PATTERN.test(namespace)) {
throw new Error(
`Invalid queue namespace "${namespace}": must be lowercase alphanumeric, starting with a letter`
);
}

return `__${namespace}_wkf_${kind}_`;
}

return `__wkf_${kind}_`;
}

/**
* Queue trigger configuration for workflow step execution.
* Steps are queued to the __wkf_step_* topic.
* Creates a queue trigger configuration for workflow step execution.
* Steps are queued to the step topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_step_*'
* createStepQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_step_*'
* createStepQueueTrigger({ namespace: 'custom' })
*/
export const STEP_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_step_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export function createStepQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('step', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Default step queue trigger (no namespace). Backward compatible.
*/
export const STEP_QUEUE_TRIGGER = createStepQueueTrigger();

/**
* Creates a queue trigger configuration for workflow orchestration.
* Workflows are queued to the workflow topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_workflow_*'
* createWorkflowQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_workflow_*'
* createWorkflowQueueTrigger({ namespace: 'custom' })
*/
export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('workflow', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Creates the optional second argument for generated `workflowEntrypoint()`
* calls. The namespace is resolved while building so generated route files do
* not need `WORKFLOW_QUEUE_NAMESPACE` at runtime.
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
}) {
const namespace = resolveQueueNamespace(options?.namespace);

if (!namespace) {
return '';
}

// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);

return `, { namespace: ${JSON.stringify(namespace)} }`;
}

/**
* Queue trigger configuration for workflow orchestration.
* Workflows are queued to the __wkf_workflow_* topic.
* Default queue trigger (no namespace). Backward compatible.
*/
export const WORKFLOW_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_workflow_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger();
8 changes: 7 additions & 1 deletion packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,13 @@ export {
getDecoratorOptionsForDirectory,
getDecoratorOptionsForDirectoryWithConfigPath,
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export {
createStepQueueTrigger,
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
STEP_QUEUE_TRIGGER,
WORKFLOW_QUEUE_TRIGGER,
} from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
Expand Down
6 changes: 3 additions & 3 deletions packages/builders/src/request-converter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,10 @@ async function normalizeRequest(request) {
function replaceGeneratedRouteExport(
content: string,
pattern: RegExp,
replacement: string,
replacement: string | ((substring: string, ...args: any[]) => string),
errorMessage: string
) {
const replacedContent = content.replace(pattern, replacement);
const replacedContent = content.replace(pattern, replacement as any);
if (replacedContent !== content) {
return replacedContent;
}
Expand All@@ -30,7 +30,7 @@ function replaceGeneratedRouteExport(

const routeCode = content.slice(0, sourceMapIndex);
const sourceMap = content.slice(sourceMapIndex);
const wrappedRouteCode = routeCode.replace(pattern, replacement);
const wrappedRouteCode = routeCode.replace(pattern, replacement as any);
if (wrappedRouteCode === routeCode) {
throw new Error(errorMessage);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/runtime-import.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
import { describe, expect, test, vi } from 'vitest';

vi.mock('@vercel/functions', () => {
throw new Error('@vercel/functions should not load during runtime import');
});

describe('runtime entrypoint', () => {
test('does not load @vercel/functions during module evaluation', async () => {
await expect(import('./runtime')).resolves.toBeDefined();
});
});
14 changes: 10 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
import { parseWorkflowName } from '@workflow/utils/parse-name';
import {
type Event,
getQueueTopicPrefix,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
WorkflowInvokePayloadSchema,
Expand DownExpand Up@@ -126,12 +128,16 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
* @returns A function that can be used as a Vercel API route.
*/
export function workflowEntrypoint(
workflowCode: string
workflowCode: string,
options?: { namespace?: string }
): (req: Request) => Promise<Response> {
const namespace = resolveQueueNamespace(options?.namespace);
const workflowPrefix = getQueueTopicPrefix('workflow', namespace);

const { createQueueHandler, specVersion: worldSpecVersion } =
getWorldHandlers();
const handler = createQueueHandler(
'__wkf_workflow_',
workflowPrefix,
async (message_, metadata) => {
// Check if this is a health check message
// NOTE: Health check messages are intentionally unauthenticated for monitoring purposes.
Expand All@@ -156,7 +162,7 @@ export function workflowEntrypoint(
} = WorkflowInvokePayloadSchema.parse(message_);
const { requestId } = metadata;
// Extract the workflow name from the topic name
const workflowName = metadata.queueName.slice('__wkf_workflow_'.length);
const workflowName = metadata.queueName.slice(workflowPrefix.length);

// --- Max delivery check ---
// Enforce max delivery limit before any infrastructure calls.
Expand DownExpand Up@@ -744,7 +750,7 @@ export function workflowEntrypoint(
);
await queueMessage(
world,
getWorkflowQueueName(workflowName),
getWorkflowQueueName(workflowName, namespace),
{
runId,
traceCarrier: traceContext,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime/helpers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,23 @@ describe('getWorkflowQueueName', () => {
it('should throw for empty string', () => {
expect(() => getWorkflowQueueName('')).toThrow('Invalid workflow name');
});

it('should use default prefix when no namespace is provided', () => {
expect(getWorkflowQueueName('myFlow')).toBe('__wkf_workflow_myFlow');
expect(getWorkflowQueueName('myFlow', undefined)).toBe(
'__wkf_workflow_myFlow'
);
});

it('should use namespaced prefix when namespace is provided', () => {
expect(getWorkflowQueueName('myFlow', 'custom')).toBe(
'__custom_wkf_workflow_myFlow'
);
});

it('should reject invalid namespace in queue name construction', () => {
expect(() => getWorkflowQueueName('myFlow', '123bad')).toThrow();
});
});

describe('healthCheck', () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/core/src/runtime/helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,9 @@ import type {
World,
} from '@workflow/world';
import {
getQueueTopicPrefix,
HealthCheckPayloadSchema,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
} from '@workflow/world';
Expand All@@ -33,13 +35,20 @@ const SAFE_WORKFLOW_NAME_PATTERN = /^[a-zA-Z0-9_\-./@]+$/;
* Ensures the workflow name only contains safe characters before
* interpolating it into the queue name string.
*/
export function getWorkflowQueueName(workflowName: string): ValidQueueName {
export function getWorkflowQueueName(
workflowName: string,
namespace?: string
): ValidQueueName {
if (!SAFE_WORKFLOW_NAME_PATTERN.test(workflowName)) {
throw new Error(
`Invalid workflow name "${workflowName}": must only contain alphanumeric characters, underscores, hyphens, dots, forward slashes, or at signs`
);
}
return `__wkf_workflow_${workflowName}` as ValidQueueName;
const prefix = getQueueTopicPrefix(
'workflow',
resolveQueueNamespace(namespace)
);
return `${prefix}${workflowName}` as ValidQueueName;
}

const generateId = monotonicFactory();
Expand DownExpand Up@@ -324,16 +333,14 @@ async function readHealthCheckResponse(
export async function healthCheck(
world: World,
endpoint: HealthCheckEndpoint,
options?: HealthCheckOptions
options?: HealthCheckOptions & { namespace?: string }
): Promise<HealthCheckResult> {
const timeout = options?.timeout ?? DEFAULT_HEALTH_CHECK_TIMEOUT;
const correlationId = generateId();
const streamName = getHealthCheckStreamName(correlationId);

const queueName: ValidQueueName =
endpoint === 'workflow'
? '__wkf_workflow_health_check'
: '__wkf_step_health_check';
const queueName =
`${getQueueTopicPrefix(endpoint, resolveQueueNamespace(options?.namespace))}health_check` as ValidQueueName;

const startTime = Date.now();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Backport #2305: feat(core): add optional namespace for queue topic prefix by github-actions[bot] · Pull Request #2341 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/queue-namespace-primitive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@workflow/world": minor
"@workflow/builders": minor
"@workflow/core": minor
"@workflow/world-local": minor
"@workflow/world-postgres": minor
---

Add an optional `namespace` parameter that scopes queue topic prefixes to `__{namespace}_wkf_workflow_*`. This allows configuring multiple frameworks in the same deployment without queue topic collision.
6 changes: 3 additions & 3 deletions packages/astro/src/builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,11 +164,11 @@ export const prerender = false;`,
// Normalize request, needed for preserving request through astro
workflowsRouteContent = replaceGeneratedRouteExport(
workflowsRouteContent,
/const handler = workflowEntrypoint\(workflowCode\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
`${NORMALIZE_REQUEST_CODE}
/const handler = workflowEntrypoint\(workflowCode(?<options>[^)]*)\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
(_match, options = '') => `${NORMALIZE_REQUEST_CODE}
const handleWorkflowRequest = async ({request}) => {
const normalRequest = await normalizeRequest(request);
return workflowEntrypoint(workflowCode)(normalRequest);
return workflowEntrypoint(workflowCode${options})(normalRequest);
};

export const HEAD = handleWorkflowRequest;
Expand Down
6 changes: 5 additions & 1 deletion packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
applySwcTransform,
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import { getImportPath } from './module-specifier.js';
Expand DownExpand Up@@ -994,6 +995,9 @@ export abstract class BaseBuilder {
}
}

const workflowEntrypointOptionsCode =
createWorkflowEntrypointOptionsCode();

const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;

Expand All@@ -1003,7 +1007,7 @@ import { workflowEntrypoint } from 'workflow/runtime';

const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

const handler = workflowEntrypoint(workflowCode);
const handler = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});

export const HEAD = handler;
export const POST = handler;`;
Expand Down
51 changes: 51 additions & 0 deletions packages/builders/src/constants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
} from './constants.js';

describe('createWorkflowQueueTrigger', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('uses the default workflow topic without a namespace', () => {
expect(createWorkflowQueueTrigger().topic).toBe('__wkf_workflow_*');
});

it('uses an explicit namespace when provided', () => {
expect(createWorkflowQueueTrigger({ namespace: 'custom' }).topic).toBe(
'__custom_wkf_workflow_*'
);
});

it('uses WORKFLOW_QUEUE_NAMESPACE when no explicit namespace is provided', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowQueueTrigger().topic).toBe('__custom_wkf_workflow_*');
});
});

describe('createWorkflowEntrypointOptionsCode', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('omits runtime options without a namespace', () => {
expect(createWorkflowEntrypointOptionsCode()).toBe('');
});

it('inlines an explicit namespace', () => {
expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe(
', { namespace: "custom" }'
);
});

it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowEntrypointOptionsCode()).toBe(
', { namespace: "custom" }'
);
});
});
118 changes: 100 additions & 18 deletions packages/builders/src/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,105 @@
const QUEUE_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/;

function resolveQueueNamespace(namespace?: string): string | undefined {
return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined;
}

function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) {
if (namespace !== undefined) {
if (!QUEUE_NAMESPACE_PATTERN.test(namespace)) {
throw new Error(
`Invalid queue namespace "${namespace}": must be lowercase alphanumeric, starting with a letter`
);
}

return `__${namespace}_wkf_${kind}_`;
}

return `__wkf_${kind}_`;
}

/**
* Queue trigger configuration for workflow step execution.
* Steps are queued to the __wkf_step_* topic.
* Creates a queue trigger configuration for workflow step execution.
* Steps are queued to the step topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_step_*'
* createStepQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_step_*'
* createStepQueueTrigger({ namespace: 'custom' })
*/
export const STEP_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_step_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export function createStepQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('step', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Default step queue trigger (no namespace). Backward compatible.
*/
export const STEP_QUEUE_TRIGGER = createStepQueueTrigger();

/**
* Creates a queue trigger configuration for workflow orchestration.
* Workflows are queued to the workflow topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_workflow_*'
* createWorkflowQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_workflow_*'
* createWorkflowQueueTrigger({ namespace: 'custom' })
*/
export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('workflow', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Creates the optional second argument for generated `workflowEntrypoint()`
* calls. The namespace is resolved while building so generated route files do
* not need `WORKFLOW_QUEUE_NAMESPACE` at runtime.
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
}) {
const namespace = resolveQueueNamespace(options?.namespace);

if (!namespace) {
return '';
}

// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);

return `, { namespace: ${JSON.stringify(namespace)} }`;
}

/**
* Queue trigger configuration for workflow orchestration.
* Workflows are queued to the __wkf_workflow_* topic.
* Default queue trigger (no namespace). Backward compatible.
*/
export const WORKFLOW_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_workflow_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger();
8 changes: 7 additions & 1 deletion packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,13 @@ export {
getDecoratorOptionsForDirectory,
getDecoratorOptionsForDirectoryWithConfigPath,
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export {
createStepQueueTrigger,
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
STEP_QUEUE_TRIGGER,
WORKFLOW_QUEUE_TRIGGER,
} from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
Expand Down
6 changes: 3 additions & 3 deletions packages/builders/src/request-converter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,10 @@ async function normalizeRequest(request) {
function replaceGeneratedRouteExport(
content: string,
pattern: RegExp,
replacement: string,
replacement: string | ((substring: string, ...args: any[]) => string),
errorMessage: string
) {
const replacedContent = content.replace(pattern, replacement);
const replacedContent = content.replace(pattern, replacement as any);
if (replacedContent !== content) {
return replacedContent;
}
Expand All@@ -30,7 +30,7 @@ function replaceGeneratedRouteExport(

const routeCode = content.slice(0, sourceMapIndex);
const sourceMap = content.slice(sourceMapIndex);
const wrappedRouteCode = routeCode.replace(pattern, replacement);
const wrappedRouteCode = routeCode.replace(pattern, replacement as any);
if (wrappedRouteCode === routeCode) {
throw new Error(errorMessage);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/runtime-import.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
import { describe, expect, test, vi } from 'vitest';

vi.mock('@vercel/functions', () => {
throw new Error('@vercel/functions should not load during runtime import');
});

describe('runtime entrypoint', () => {
test('does not load @vercel/functions during module evaluation', async () => {
await expect(import('./runtime')).resolves.toBeDefined();
});
});
14 changes: 10 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
import { parseWorkflowName } from '@workflow/utils/parse-name';
import {
type Event,
getQueueTopicPrefix,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
WorkflowInvokePayloadSchema,
Expand DownExpand Up@@ -126,12 +128,16 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
* @returns A function that can be used as a Vercel API route.
*/
export function workflowEntrypoint(
workflowCode: string
workflowCode: string,
options?: { namespace?: string }
): (req: Request) => Promise<Response> {
const namespace = resolveQueueNamespace(options?.namespace);
const workflowPrefix = getQueueTopicPrefix('workflow', namespace);

const { createQueueHandler, specVersion: worldSpecVersion } =
getWorldHandlers();
const handler = createQueueHandler(
'__wkf_workflow_',
workflowPrefix,
async (message_, metadata) => {
// Check if this is a health check message
// NOTE: Health check messages are intentionally unauthenticated for monitoring purposes.
Expand All@@ -156,7 +162,7 @@ export function workflowEntrypoint(
} = WorkflowInvokePayloadSchema.parse(message_);
const { requestId } = metadata;
// Extract the workflow name from the topic name
const workflowName = metadata.queueName.slice('__wkf_workflow_'.length);
const workflowName = metadata.queueName.slice(workflowPrefix.length);

// --- Max delivery check ---
// Enforce max delivery limit before any infrastructure calls.
Expand DownExpand Up@@ -744,7 +750,7 @@ export function workflowEntrypoint(
);
await queueMessage(
world,
getWorkflowQueueName(workflowName),
getWorkflowQueueName(workflowName, namespace),
{
runId,
traceCarrier: traceContext,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime/helpers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,23 @@ describe('getWorkflowQueueName', () => {
it('should throw for empty string', () => {
expect(() => getWorkflowQueueName('')).toThrow('Invalid workflow name');
});

it('should use default prefix when no namespace is provided', () => {
expect(getWorkflowQueueName('myFlow')).toBe('__wkf_workflow_myFlow');
expect(getWorkflowQueueName('myFlow', undefined)).toBe(
'__wkf_workflow_myFlow'
);
});

it('should use namespaced prefix when namespace is provided', () => {
expect(getWorkflowQueueName('myFlow', 'custom')).toBe(
'__custom_wkf_workflow_myFlow'
);
});

it('should reject invalid namespace in queue name construction', () => {
expect(() => getWorkflowQueueName('myFlow', '123bad')).toThrow();
});
});

describe('healthCheck', () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/core/src/runtime/helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,9 @@ import type {
World,
} from '@workflow/world';
import {
getQueueTopicPrefix,
HealthCheckPayloadSchema,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
} from '@workflow/world';
Expand All@@ -33,13 +35,20 @@ const SAFE_WORKFLOW_NAME_PATTERN = /^[a-zA-Z0-9_\-./@]+$/;
* Ensures the workflow name only contains safe characters before
* interpolating it into the queue name string.
*/
export function getWorkflowQueueName(workflowName: string): ValidQueueName {
export function getWorkflowQueueName(
workflowName: string,
namespace?: string
): ValidQueueName {
if (!SAFE_WORKFLOW_NAME_PATTERN.test(workflowName)) {
throw new Error(
`Invalid workflow name "${workflowName}": must only contain alphanumeric characters, underscores, hyphens, dots, forward slashes, or at signs`
);
}
return `__wkf_workflow_${workflowName}` as ValidQueueName;
const prefix = getQueueTopicPrefix(
'workflow',
resolveQueueNamespace(namespace)
);
return `${prefix}${workflowName}` as ValidQueueName;
}

const generateId = monotonicFactory();
Expand DownExpand Up@@ -324,16 +333,14 @@ async function readHealthCheckResponse(
export async function healthCheck(
world: World,
endpoint: HealthCheckEndpoint,
options?: HealthCheckOptions
options?: HealthCheckOptions & { namespace?: string }
): Promise<HealthCheckResult> {
const timeout = options?.timeout ?? DEFAULT_HEALTH_CHECK_TIMEOUT;
const correlationId = generateId();
const streamName = getHealthCheckStreamName(correlationId);

const queueName: ValidQueueName =
endpoint === 'workflow'
? '__wkf_workflow_health_check'
: '__wkf_step_health_check';
const queueName =
`${getQueueTopicPrefix(endpoint, resolveQueueNamespace(options?.namespace))}health_check` as ValidQueueName;

const startTime = Date.now();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Backport #2305: feat(core): add optional namespace for queue topic prefix by github-actions[bot] · Pull Request #2341 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/queue-namespace-primitive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@workflow/world": minor
"@workflow/builders": minor
"@workflow/core": minor
"@workflow/world-local": minor
"@workflow/world-postgres": minor
---

Add an optional `namespace` parameter that scopes queue topic prefixes to `__{namespace}_wkf_workflow_*`. This allows configuring multiple frameworks in the same deployment without queue topic collision.
6 changes: 3 additions & 3 deletions packages/astro/src/builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,11 +164,11 @@ export const prerender = false;`,
// Normalize request, needed for preserving request through astro
workflowsRouteContent = replaceGeneratedRouteExport(
workflowsRouteContent,
/const handler = workflowEntrypoint\(workflowCode\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
`${NORMALIZE_REQUEST_CODE}
/const handler = workflowEntrypoint\(workflowCode(?<options>[^)]*)\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
(_match, options = '') => `${NORMALIZE_REQUEST_CODE}
const handleWorkflowRequest = async ({request}) => {
const normalRequest = await normalizeRequest(request);
return workflowEntrypoint(workflowCode)(normalRequest);
return workflowEntrypoint(workflowCode${options})(normalRequest);
};

export const HEAD = handleWorkflowRequest;
Expand Down
6 changes: 5 additions & 1 deletion packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
applySwcTransform,
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import { getImportPath } from './module-specifier.js';
Expand DownExpand Up@@ -994,6 +995,9 @@ export abstract class BaseBuilder {
}
}

const workflowEntrypointOptionsCode =
createWorkflowEntrypointOptionsCode();

const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;

Expand All@@ -1003,7 +1007,7 @@ import { workflowEntrypoint } from 'workflow/runtime';

const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

const handler = workflowEntrypoint(workflowCode);
const handler = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});

export const HEAD = handler;
export const POST = handler;`;
Expand Down
51 changes: 51 additions & 0 deletions packages/builders/src/constants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
} from './constants.js';

describe('createWorkflowQueueTrigger', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('uses the default workflow topic without a namespace', () => {
expect(createWorkflowQueueTrigger().topic).toBe('__wkf_workflow_*');
});

it('uses an explicit namespace when provided', () => {
expect(createWorkflowQueueTrigger({ namespace: 'custom' }).topic).toBe(
'__custom_wkf_workflow_*'
);
});

it('uses WORKFLOW_QUEUE_NAMESPACE when no explicit namespace is provided', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowQueueTrigger().topic).toBe('__custom_wkf_workflow_*');
});
});

describe('createWorkflowEntrypointOptionsCode', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('omits runtime options without a namespace', () => {
expect(createWorkflowEntrypointOptionsCode()).toBe('');
});

it('inlines an explicit namespace', () => {
expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe(
', { namespace: "custom" }'
);
});

it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowEntrypointOptionsCode()).toBe(
', { namespace: "custom" }'
);
});
});
118 changes: 100 additions & 18 deletions packages/builders/src/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,105 @@
const QUEUE_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/;

function resolveQueueNamespace(namespace?: string): string | undefined {
return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined;
}

function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) {
if (namespace !== undefined) {
if (!QUEUE_NAMESPACE_PATTERN.test(namespace)) {
throw new Error(
`Invalid queue namespace "${namespace}": must be lowercase alphanumeric, starting with a letter`
);
}

return `__${namespace}_wkf_${kind}_`;
}

return `__wkf_${kind}_`;
}

/**
* Queue trigger configuration for workflow step execution.
* Steps are queued to the __wkf_step_* topic.
* Creates a queue trigger configuration for workflow step execution.
* Steps are queued to the step topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_step_*'
* createStepQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_step_*'
* createStepQueueTrigger({ namespace: 'custom' })
*/
export const STEP_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_step_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export function createStepQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('step', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Default step queue trigger (no namespace). Backward compatible.
*/
export const STEP_QUEUE_TRIGGER = createStepQueueTrigger();

/**
* Creates a queue trigger configuration for workflow orchestration.
* Workflows are queued to the workflow topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_workflow_*'
* createWorkflowQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_workflow_*'
* createWorkflowQueueTrigger({ namespace: 'custom' })
*/
export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('workflow', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Creates the optional second argument for generated `workflowEntrypoint()`
* calls. The namespace is resolved while building so generated route files do
* not need `WORKFLOW_QUEUE_NAMESPACE` at runtime.
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
}) {
const namespace = resolveQueueNamespace(options?.namespace);

if (!namespace) {
return '';
}

// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);

return `, { namespace: ${JSON.stringify(namespace)} }`;
}

/**
* Queue trigger configuration for workflow orchestration.
* Workflows are queued to the __wkf_workflow_* topic.
* Default queue trigger (no namespace). Backward compatible.
*/
export const WORKFLOW_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_workflow_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger();
8 changes: 7 additions & 1 deletion packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,13 @@ export {
getDecoratorOptionsForDirectory,
getDecoratorOptionsForDirectoryWithConfigPath,
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export {
createStepQueueTrigger,
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
STEP_QUEUE_TRIGGER,
WORKFLOW_QUEUE_TRIGGER,
} from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
Expand Down
6 changes: 3 additions & 3 deletions packages/builders/src/request-converter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,10 @@ async function normalizeRequest(request) {
function replaceGeneratedRouteExport(
content: string,
pattern: RegExp,
replacement: string,
replacement: string | ((substring: string, ...args: any[]) => string),
errorMessage: string
) {
const replacedContent = content.replace(pattern, replacement);
const replacedContent = content.replace(pattern, replacement as any);
if (replacedContent !== content) {
return replacedContent;
}
Expand All@@ -30,7 +30,7 @@ function replaceGeneratedRouteExport(

const routeCode = content.slice(0, sourceMapIndex);
const sourceMap = content.slice(sourceMapIndex);
const wrappedRouteCode = routeCode.replace(pattern, replacement);
const wrappedRouteCode = routeCode.replace(pattern, replacement as any);
if (wrappedRouteCode === routeCode) {
throw new Error(errorMessage);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/runtime-import.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
import { describe, expect, test, vi } from 'vitest';

vi.mock('@vercel/functions', () => {
throw new Error('@vercel/functions should not load during runtime import');
});

describe('runtime entrypoint', () => {
test('does not load @vercel/functions during module evaluation', async () => {
await expect(import('./runtime')).resolves.toBeDefined();
});
});
14 changes: 10 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
import { parseWorkflowName } from '@workflow/utils/parse-name';
import {
type Event,
getQueueTopicPrefix,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
WorkflowInvokePayloadSchema,
Expand DownExpand Up@@ -126,12 +128,16 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
* @returns A function that can be used as a Vercel API route.
*/
export function workflowEntrypoint(
workflowCode: string
workflowCode: string,
options?: { namespace?: string }
): (req: Request) => Promise<Response> {
const namespace = resolveQueueNamespace(options?.namespace);
const workflowPrefix = getQueueTopicPrefix('workflow', namespace);

const { createQueueHandler, specVersion: worldSpecVersion } =
getWorldHandlers();
const handler = createQueueHandler(
'__wkf_workflow_',
workflowPrefix,
async (message_, metadata) => {
// Check if this is a health check message
// NOTE: Health check messages are intentionally unauthenticated for monitoring purposes.
Expand All@@ -156,7 +162,7 @@ export function workflowEntrypoint(
} = WorkflowInvokePayloadSchema.parse(message_);
const { requestId } = metadata;
// Extract the workflow name from the topic name
const workflowName = metadata.queueName.slice('__wkf_workflow_'.length);
const workflowName = metadata.queueName.slice(workflowPrefix.length);

// --- Max delivery check ---
// Enforce max delivery limit before any infrastructure calls.
Expand DownExpand Up@@ -744,7 +750,7 @@ export function workflowEntrypoint(
);
await queueMessage(
world,
getWorkflowQueueName(workflowName),
getWorkflowQueueName(workflowName, namespace),
{
runId,
traceCarrier: traceContext,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime/helpers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,23 @@ describe('getWorkflowQueueName', () => {
it('should throw for empty string', () => {
expect(() => getWorkflowQueueName('')).toThrow('Invalid workflow name');
});

it('should use default prefix when no namespace is provided', () => {
expect(getWorkflowQueueName('myFlow')).toBe('__wkf_workflow_myFlow');
expect(getWorkflowQueueName('myFlow', undefined)).toBe(
'__wkf_workflow_myFlow'
);
});

it('should use namespaced prefix when namespace is provided', () => {
expect(getWorkflowQueueName('myFlow', 'custom')).toBe(
'__custom_wkf_workflow_myFlow'
);
});

it('should reject invalid namespace in queue name construction', () => {
expect(() => getWorkflowQueueName('myFlow', '123bad')).toThrow();
});
});

describe('healthCheck', () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/core/src/runtime/helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,9 @@ import type {
World,
} from '@workflow/world';
import {
getQueueTopicPrefix,
HealthCheckPayloadSchema,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
} from '@workflow/world';
Expand All@@ -33,13 +35,20 @@ const SAFE_WORKFLOW_NAME_PATTERN = /^[a-zA-Z0-9_\-./@]+$/;
* Ensures the workflow name only contains safe characters before
* interpolating it into the queue name string.
*/
export function getWorkflowQueueName(workflowName: string): ValidQueueName {
export function getWorkflowQueueName(
workflowName: string,
namespace?: string
): ValidQueueName {
if (!SAFE_WORKFLOW_NAME_PATTERN.test(workflowName)) {
throw new Error(
`Invalid workflow name "${workflowName}": must only contain alphanumeric characters, underscores, hyphens, dots, forward slashes, or at signs`
);
}
return `__wkf_workflow_${workflowName}` as ValidQueueName;
const prefix = getQueueTopicPrefix(
'workflow',
resolveQueueNamespace(namespace)
);
return `${prefix}${workflowName}` as ValidQueueName;
}

const generateId = monotonicFactory();
Expand DownExpand Up@@ -324,16 +333,14 @@ async function readHealthCheckResponse(
export async function healthCheck(
world: World,
endpoint: HealthCheckEndpoint,
options?: HealthCheckOptions
options?: HealthCheckOptions & { namespace?: string }
): Promise<HealthCheckResult> {
const timeout = options?.timeout ?? DEFAULT_HEALTH_CHECK_TIMEOUT;
const correlationId = generateId();
const streamName = getHealthCheckStreamName(correlationId);

const queueName: ValidQueueName =
endpoint === 'workflow'
? '__wkf_workflow_health_check'
: '__wkf_step_health_check';
const queueName =
`${getQueueTopicPrefix(endpoint, resolveQueueNamespace(options?.namespace))}health_check` as ValidQueueName;

const startTime = Date.now();

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Backport #2305: feat(core): add optional namespace for queue topic prefix by github-actions[bot] · Pull Request #2341 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/queue-namespace-primitive.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
"@workflow/world": minor
"@workflow/builders": minor
"@workflow/core": minor
"@workflow/world-local": minor
"@workflow/world-postgres": minor
---

Add an optional `namespace` parameter that scopes queue topic prefixes to `__{namespace}_wkf_workflow_*`. This allows configuring multiple frameworks in the same deployment without queue topic collision.
6 changes: 3 additions & 3 deletions packages/astro/src/builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,11 +164,11 @@ export const prerender = false;`,
// Normalize request, needed for preserving request through astro
workflowsRouteContent = replaceGeneratedRouteExport(
workflowsRouteContent,
/const handler = workflowEntrypoint\(workflowCode\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
`${NORMALIZE_REQUEST_CODE}
/const handler = workflowEntrypoint\(workflowCode(?<options>[^)]*)\);\s*export const HEAD = handler;\s*export const POST = handler;?\s*$/m,
(_match, options = '') => `${NORMALIZE_REQUEST_CODE}
const handleWorkflowRequest = async ({request}) => {
const normalRequest = await normalizeRequest(request);
return workflowEntrypoint(workflowCode)(normalRequest);
return workflowEntrypoint(workflowCode${options})(normalRequest);
};

export const HEAD = handleWorkflowRequest;
Expand Down
6 changes: 5 additions & 1 deletion packages/builders/src/base-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,6 +13,7 @@ import {
applySwcTransform,
type WorkflowManifest,
} from './apply-swc-transform.js';
import { createWorkflowEntrypointOptionsCode } from './constants.js';
import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
import { getEsbuildTsconfigOptions } from './esbuild-tsconfig.js';
import { getImportPath } from './module-specifier.js';
Expand DownExpand Up@@ -994,6 +995,9 @@ export abstract class BaseBuilder {
}
}

const workflowEntrypointOptionsCode =
createWorkflowEntrypointOptionsCode();

const bundleFinal = async (interimBundle: string) => {
const workflowBundleCode = interimBundle;

Expand All@@ -1003,7 +1007,7 @@ import { workflowEntrypoint } from 'workflow/runtime';

const workflowCode = \`${workflowBundleCode.replace(/[\\`$]/g, '\\$&')}\`;

const handler = workflowEntrypoint(workflowCode);
const handler = workflowEntrypoint(workflowCode${workflowEntrypointOptionsCode});

export const HEAD = handler;
export const POST = handler;`;
Expand Down
51 changes: 51 additions & 0 deletions packages/builders/src/constants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
} from './constants.js';

describe('createWorkflowQueueTrigger', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('uses the default workflow topic without a namespace', () => {
expect(createWorkflowQueueTrigger().topic).toBe('__wkf_workflow_*');
});

it('uses an explicit namespace when provided', () => {
expect(createWorkflowQueueTrigger({ namespace: 'custom' }).topic).toBe(
'__custom_wkf_workflow_*'
);
});

it('uses WORKFLOW_QUEUE_NAMESPACE when no explicit namespace is provided', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowQueueTrigger().topic).toBe('__custom_wkf_workflow_*');
});
});

describe('createWorkflowEntrypointOptionsCode', () => {
afterEach(() => {
delete process.env.WORKFLOW_QUEUE_NAMESPACE;
});

it('omits runtime options without a namespace', () => {
expect(createWorkflowEntrypointOptionsCode()).toBe('');
});

it('inlines an explicit namespace', () => {
expect(createWorkflowEntrypointOptionsCode({ namespace: 'custom' })).toBe(
', { namespace: "custom" }'
);
});

it('inlines WORKFLOW_QUEUE_NAMESPACE at build time', () => {
process.env.WORKFLOW_QUEUE_NAMESPACE = 'custom';

expect(createWorkflowEntrypointOptionsCode()).toBe(
', { namespace: "custom" }'
);
});
});
118 changes: 100 additions & 18 deletions packages/builders/src/constants.ts
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,105 @@
const QUEUE_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*$/;

function resolveQueueNamespace(namespace?: string): string | undefined {
return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined;
}

function getQueueTopicPrefix(kind: 'workflow' | 'step', namespace?: string) {
if (namespace !== undefined) {
if (!QUEUE_NAMESPACE_PATTERN.test(namespace)) {
throw new Error(
`Invalid queue namespace "${namespace}": must be lowercase alphanumeric, starting with a letter`
);
}

return `__${namespace}_wkf_${kind}_`;
}

return `__wkf_${kind}_`;
}

/**
* Queue trigger configuration for workflow step execution.
* Steps are queued to the __wkf_step_* topic.
* Creates a queue trigger configuration for workflow step execution.
* Steps are queued to the step topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_step_*'
* createStepQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_step_*'
* createStepQueueTrigger({ namespace: 'custom' })
*/
export const STEP_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_step_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export function createStepQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('step', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Default step queue trigger (no namespace). Backward compatible.
*/
export const STEP_QUEUE_TRIGGER = createStepQueueTrigger();

/**
* Creates a queue trigger configuration for workflow orchestration.
* Workflows are queued to the workflow topic.
*
* When `namespace` is provided, the trigger topic is scoped to avoid
* collisions with other frameworks or direct Workflow SDK usage in the
* same deployment.
*
* @example
* // default: topic = '__wkf_workflow_*'
* createWorkflowQueueTrigger()
*
* @example
* // namespaced: topic = '__custom_wkf_workflow_*'
* createWorkflowQueueTrigger({ namespace: 'custom' })
*/
export function createWorkflowQueueTrigger(options?: { namespace?: string }) {
const namespace = resolveQueueNamespace(options?.namespace);

return {
type: 'queue/v2beta' as const,
topic: `${getQueueTopicPrefix('workflow', namespace)}*`,
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
}

/**
* Creates the optional second argument for generated `workflowEntrypoint()`
* calls. The namespace is resolved while building so generated route files do
* not need `WORKFLOW_QUEUE_NAMESPACE` at runtime.
*/
export function createWorkflowEntrypointOptionsCode(options?: {
namespace?: string;
}) {
const namespace = resolveQueueNamespace(options?.namespace);

if (!namespace) {
return '';
}

// Reuse prefix construction for namespace validation.
getQueueTopicPrefix('workflow', namespace);

return `, { namespace: ${JSON.stringify(namespace)} }`;
}

/**
* Queue trigger configuration for workflow orchestration.
* Workflows are queued to the __wkf_workflow_* topic.
* Default queue trigger (no namespace). Backward compatible.
*/
export const WORKFLOW_QUEUE_TRIGGER = {
type: 'queue/v2beta' as const,
topic: '__wkf_workflow_*',
consumer: 'default',
retryAfterSeconds: 5, // Delay between retries (default: 60)
initialDelaySeconds: 0, // Initial delay before first delivery (default: 0)
};
export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger();
8 changes: 7 additions & 1 deletion packages/builders/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,13 @@ export {
getDecoratorOptionsForDirectory,
getDecoratorOptionsForDirectoryWithConfigPath,
} from './config-helpers.js';
export { STEP_QUEUE_TRIGGER, WORKFLOW_QUEUE_TRIGGER } from './constants.js';
export {
createStepQueueTrigger,
createWorkflowEntrypointOptionsCode,
createWorkflowQueueTrigger,
STEP_QUEUE_TRIGGER,
WORKFLOW_QUEUE_TRIGGER,
} from './constants.js';
export { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js';
export {
clearModuleSpecifierCache,
Expand Down
6 changes: 3 additions & 3 deletions packages/builders/src/request-converter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,10 @@ async function normalizeRequest(request) {
function replaceGeneratedRouteExport(
content: string,
pattern: RegExp,
replacement: string,
replacement: string | ((substring: string, ...args: any[]) => string),
errorMessage: string
) {
const replacedContent = content.replace(pattern, replacement);
const replacedContent = content.replace(pattern, replacement as any);
if (replacedContent !== content) {
return replacedContent;
}
Expand All@@ -30,7 +30,7 @@ function replaceGeneratedRouteExport(

const routeCode = content.slice(0, sourceMapIndex);
const sourceMap = content.slice(sourceMapIndex);
const wrappedRouteCode = routeCode.replace(pattern, replacement);
const wrappedRouteCode = routeCode.replace(pattern, replacement as any);
if (wrappedRouteCode === routeCode) {
throw new Error(errorMessage);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/runtime-import.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
import { describe, expect, test, vi } from 'vitest';

vi.mock('@vercel/functions', () => {
throw new Error('@vercel/functions should not load during runtime import');
});

describe('runtime entrypoint', () => {
test('does not load @vercel/functions during module evaluation', async () => {
await expect(import('./runtime')).resolves.toBeDefined();
});
});
14 changes: 10 additions & 4 deletions packages/core/src/runtime.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ import {
import { parseWorkflowName } from '@workflow/utils/parse-name';
import {
type Event,
getQueueTopicPrefix,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
WorkflowInvokePayloadSchema,
Expand DownExpand Up@@ -126,12 +128,16 @@ function hasRecordedTerminalRunEvent(events: Event[], runId: string): boolean {
* @returns A function that can be used as a Vercel API route.
*/
export function workflowEntrypoint(
workflowCode: string
workflowCode: string,
options?: { namespace?: string }
): (req: Request) => Promise<Response> {
const namespace = resolveQueueNamespace(options?.namespace);
const workflowPrefix = getQueueTopicPrefix('workflow', namespace);

const { createQueueHandler, specVersion: worldSpecVersion } =
getWorldHandlers();
const handler = createQueueHandler(
'__wkf_workflow_',
workflowPrefix,
async (message_, metadata) => {
// Check if this is a health check message
// NOTE: Health check messages are intentionally unauthenticated for monitoring purposes.
Expand All@@ -156,7 +162,7 @@ export function workflowEntrypoint(
} = WorkflowInvokePayloadSchema.parse(message_);
const { requestId } = metadata;
// Extract the workflow name from the topic name
const workflowName = metadata.queueName.slice('__wkf_workflow_'.length);
const workflowName = metadata.queueName.slice(workflowPrefix.length);

// --- Max delivery check ---
// Enforce max delivery limit before any infrastructure calls.
Expand DownExpand Up@@ -744,7 +750,7 @@ export function workflowEntrypoint(
);
await queueMessage(
world,
getWorkflowQueueName(workflowName),
getWorkflowQueueName(workflowName, namespace),
{
runId,
traceCarrier: traceContext,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime/helpers.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,23 @@ describe('getWorkflowQueueName', () => {
it('should throw for empty string', () => {
expect(() => getWorkflowQueueName('')).toThrow('Invalid workflow name');
});

it('should use default prefix when no namespace is provided', () => {
expect(getWorkflowQueueName('myFlow')).toBe('__wkf_workflow_myFlow');
expect(getWorkflowQueueName('myFlow', undefined)).toBe(
'__wkf_workflow_myFlow'
);
});

it('should use namespaced prefix when namespace is provided', () => {
expect(getWorkflowQueueName('myFlow', 'custom')).toBe(
'__custom_wkf_workflow_myFlow'
);
});

it('should reject invalid namespace in queue name construction', () => {
expect(() => getWorkflowQueueName('myFlow', '123bad')).toThrow();
});
});

describe('healthCheck', () => {
Expand Down
21 changes: 14 additions & 7 deletions packages/core/src/runtime/helpers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,7 +6,9 @@ import type {
World,
} from '@workflow/world';
import {
getQueueTopicPrefix,
HealthCheckPayloadSchema,
resolveQueueNamespace,
SPEC_VERSION_CURRENT,
SPEC_VERSION_LEGACY,
} from '@workflow/world';
Expand All@@ -33,13 +35,20 @@ const SAFE_WORKFLOW_NAME_PATTERN = /^[a-zA-Z0-9_\-./@]+$/;
* Ensures the workflow name only contains safe characters before
* interpolating it into the queue name string.
*/
export function getWorkflowQueueName(workflowName: string): ValidQueueName {
export function getWorkflowQueueName(
workflowName: string,
namespace?: string
): ValidQueueName {
if (!SAFE_WORKFLOW_NAME_PATTERN.test(workflowName)) {
throw new Error(
`Invalid workflow name "${workflowName}": must only contain alphanumeric characters, underscores, hyphens, dots, forward slashes, or at signs`
);
}
return `__wkf_workflow_${workflowName}` as ValidQueueName;
const prefix = getQueueTopicPrefix(
'workflow',
resolveQueueNamespace(namespace)
);
return `${prefix}${workflowName}` as ValidQueueName;
}

const generateId = monotonicFactory();
Expand DownExpand Up@@ -324,16 +333,14 @@ async function readHealthCheckResponse(
export async function healthCheck(
world: World,
endpoint: HealthCheckEndpoint,
options?: HealthCheckOptions
options?: HealthCheckOptions & { namespace?: string }
): Promise<HealthCheckResult> {
const timeout = options?.timeout ?? DEFAULT_HEALTH_CHECK_TIMEOUT;
const correlationId = generateId();
const streamName = getHealthCheckStreamName(correlationId);

const queueName: ValidQueueName =
endpoint === 'workflow'
? '__wkf_workflow_health_check'
: '__wkf_step_health_check';
const queueName =
`${getQueueTopicPrefix(endpoint, resolveQueueNamespace(options?.namespace))}health_check` as ValidQueueName;

const startTime = Date.now();

Expand Down
Loading