diff --git a/.changeset/queue-namespace-primitive.md b/.changeset/queue-namespace-primitive.md new file mode 100644 index 0000000000..392bf87975 --- /dev/null +++ b/.changeset/queue-namespace-primitive.md @@ -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. diff --git a/packages/astro/src/builder.ts b/packages/astro/src/builder.ts index f7c6d9b0f0..0818c78c10 100644 --- a/packages/astro/src/builder.ts +++ b/packages/astro/src/builder.ts @@ -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(?[^)]*)\);\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; diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 8eaf0281b1..dded6edaab 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -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'; @@ -994,6 +995,9 @@ export abstract class BaseBuilder { } } + const workflowEntrypointOptionsCode = + createWorkflowEntrypointOptionsCode(); + const bundleFinal = async (interimBundle: string) => { const workflowBundleCode = interimBundle; @@ -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;`; diff --git a/packages/builders/src/constants.test.ts b/packages/builders/src/constants.test.ts new file mode 100644 index 0000000000..2474a7e1a5 --- /dev/null +++ b/packages/builders/src/constants.test.ts @@ -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" }' + ); + }); +}); diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index 0eef6488f7..3a00a368eb 100644 --- a/packages/builders/src/constants.ts +++ b/packages/builders/src/constants.ts @@ -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(); diff --git a/packages/builders/src/index.ts b/packages/builders/src/index.ts index e0ea433227..ae2cde4ae1 100644 --- a/packages/builders/src/index.ts +++ b/packages/builders/src/index.ts @@ -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, diff --git a/packages/builders/src/request-converter.ts b/packages/builders/src/request-converter.ts index 97d998dd7e..a83ae79c38 100644 --- a/packages/builders/src/request-converter.ts +++ b/packages/builders/src/request-converter.ts @@ -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; } @@ -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); } diff --git a/packages/core/src/runtime-import.test.ts b/packages/core/src/runtime-import.test.ts new file mode 100644 index 0000000000..59d4c68c20 --- /dev/null +++ b/packages/core/src/runtime-import.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index b6cc110446..76efbdf1c6 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -9,6 +9,8 @@ import { import { parseWorkflowName } from '@workflow/utils/parse-name'; import { type Event, + getQueueTopicPrefix, + resolveQueueNamespace, SPEC_VERSION_CURRENT, SPEC_VERSION_LEGACY, WorkflowInvokePayloadSchema, @@ -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 { + 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. @@ -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. @@ -744,7 +750,7 @@ export function workflowEntrypoint( ); await queueMessage( world, - getWorkflowQueueName(workflowName), + getWorkflowQueueName(workflowName, namespace), { runId, traceCarrier: traceContext, diff --git a/packages/core/src/runtime/helpers.test.ts b/packages/core/src/runtime/helpers.test.ts index 40a8406fca..dce8e173c1 100644 --- a/packages/core/src/runtime/helpers.test.ts +++ b/packages/core/src/runtime/helpers.test.ts @@ -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', () => { diff --git a/packages/core/src/runtime/helpers.ts b/packages/core/src/runtime/helpers.ts index 2b7e5f21c4..5aa76d38f5 100644 --- a/packages/core/src/runtime/helpers.ts +++ b/packages/core/src/runtime/helpers.ts @@ -6,7 +6,9 @@ import type { World, } from '@workflow/world'; import { + getQueueTopicPrefix, HealthCheckPayloadSchema, + resolveQueueNamespace, SPEC_VERSION_CURRENT, SPEC_VERSION_LEGACY, } from '@workflow/world'; @@ -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(); @@ -324,16 +333,14 @@ async function readHealthCheckResponse( export async function healthCheck( world: World, endpoint: HealthCheckEndpoint, - options?: HealthCheckOptions + options?: HealthCheckOptions & { namespace?: string } ): Promise { 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(); diff --git a/packages/core/src/runtime/resume-hook.ts b/packages/core/src/runtime/resume-hook.ts index c193364061..c778dbc861 100644 --- a/packages/core/src/runtime/resume-hook.ts +++ b/packages/core/src/runtime/resume-hook.ts @@ -1,4 +1,3 @@ -import { waitUntil } from '@vercel/functions'; import { ERROR_SLUGS, HookNotFoundError, diff --git a/packages/core/src/runtime/start.ts b/packages/core/src/runtime/start.ts index 1e26a37fc6..7cd2b419ee 100644 --- a/packages/core/src/runtime/start.ts +++ b/packages/core/src/runtime/start.ts @@ -18,10 +18,10 @@ import type { Serializable } from '../schemas.js'; import { dehydrateWorkflowArguments } from '../serialization.js'; import * as Attribute from '../telemetry/semantic-conventions.js'; import { serializeTraceCarrier, trace } from '../telemetry.js'; -import { waitedUntil } from '../util.js'; import { version as workflowCoreVersion } from '../version.js'; import { getWorkflowQueueName } from './helpers.js'; import { Run } from './run.js'; +import { waitedUntil } from '../util.js'; import { getWorld } from './world.js'; /** ULID generator for client-side runId generation */ diff --git a/packages/core/src/runtime/step-handler.ts b/packages/core/src/runtime/step-handler.ts index 313eb222d2..9df9645d2e 100644 --- a/packages/core/src/runtime/step-handler.ts +++ b/packages/core/src/runtime/step-handler.ts @@ -13,6 +13,8 @@ import { import { pluralize } from '@workflow/utils'; import { getPort } from '@workflow/utils/get-port'; import { + getQueueTopicPrefix, + resolveQueueNamespace, SPEC_VERSION_CURRENT, type Step, StepInvokePayloadSchema, @@ -51,10 +53,13 @@ import { getWorld, getWorldHandlers } from './world.js'; const DEFAULT_STEP_MAX_RETRIES = 3; +const stepNamespace = resolveQueueNamespace(); +const stepPrefix = getQueueTopicPrefix('step', stepNamespace); + const { createQueueHandler, specVersion: worldSpecVersion } = getWorldHandlers(); const stepHandler = createQueueHandler( - '__wkf_step_', + stepPrefix, async (message_, metadata) => { // Check if this is a health check message // NOTE: Health check messages are intentionally unauthenticated for monitoring purposes. @@ -75,7 +80,7 @@ const stepHandler = createQueueHandler( requestedAt, } = StepInvokePayloadSchema.parse(message_); const { requestId } = metadata; - const stepNameFromQueue = metadata.queueName.slice('__wkf_step_'.length); + const stepNameFromQueue = metadata.queueName.slice(stepPrefix.length); // --- Max delivery check --- // Enforce max delivery limit before any infrastructure calls. @@ -110,7 +115,7 @@ const stepHandler = createQueueHandler( { requestId } ); // Re-queue the workflow to handle the failed step - await queueMessage(world, getWorkflowQueueName(workflowName), { + await queueMessage(world, getWorkflowQueueName(workflowName, stepNamespace), { runId: workflowRunId, traceCarrier: await serializeTraceCarrier(), requestedAt: new Date(), @@ -142,7 +147,7 @@ const stepHandler = createQueueHandler( // Execute step within the propagated trace context return await withTraceContext(traceContext, async () => { // Extract the step name from the topic name - const stepName = metadata.queueName.slice('__wkf_step_'.length); + const stepName = metadata.queueName.slice(stepPrefix.length); const world = getWorld(); const isVercel = process.env.VERCEL_URL !== undefined; @@ -242,7 +247,7 @@ const stepHandler = createQueueHandler( 'step.name': stepName, 'step.id': stepId, }); - await queueMessage(world, getWorkflowQueueName(workflowName), { + await queueMessage(world, getWorkflowQueueName(workflowName, stepNamespace), { runId: workflowRunId, traceCarrier: await serializeTraceCarrier(), requestedAt: new Date(), @@ -341,7 +346,7 @@ const stepHandler = createQueueHandler( }); // Re-invoke the workflow to handle the failed step - await queueMessage(world, getWorkflowQueueName(workflowName), { + await queueMessage(world, getWorkflowQueueName(workflowName, stepNamespace), { runId: workflowRunId, traceCarrier: await serializeTraceCarrier(), requestedAt: new Date(), @@ -408,7 +413,7 @@ const stepHandler = createQueueHandler( }); // Re-invoke the workflow to handle the failed step - await queueMessage(world, getWorkflowQueueName(workflowName), { + await queueMessage(world, getWorkflowQueueName(workflowName, stepNamespace), { runId: workflowRunId, traceCarrier: await serializeTraceCarrier(), requestedAt: new Date(), @@ -455,7 +460,7 @@ const stepHandler = createQueueHandler( throw failErr; } // Re-queue the workflow so it can process the step failure - await queueMessage(world, getWorkflowQueueName(workflowName), { + await queueMessage(world, getWorkflowQueueName(workflowName, stepNamespace), { runId: workflowRunId, traceCarrier: await serializeTraceCarrier(), requestedAt: new Date(), @@ -780,7 +785,7 @@ const stepHandler = createQueueHandler( } // Re-invoke the workflow to handle the failed/retrying step - await queueMessage(world, getWorkflowQueueName(workflowName), { + await queueMessage(world, getWorkflowQueueName(workflowName, stepNamespace), { runId: workflowRunId, traceCarrier: await serializeTraceCarrier(), requestedAt: new Date(), @@ -869,7 +874,7 @@ const stepHandler = createQueueHandler( }); // Queue the workflow continuation with the concurrently-resolved trace carrier - await queueMessage(world, getWorkflowQueueName(workflowName), { + await queueMessage(world, getWorkflowQueueName(workflowName, stepNamespace), { runId: workflowRunId, traceCarrier, requestedAt: new Date(), diff --git a/packages/core/src/runtime/wait-until.ts b/packages/core/src/runtime/wait-until.ts new file mode 100644 index 0000000000..67accb5c42 --- /dev/null +++ b/packages/core/src/runtime/wait-until.ts @@ -0,0 +1,21 @@ +export function waitUntil(promise: Promise): void { + void import('@vercel/functions').then(({ waitUntil }) => { + waitUntil(promise); + }); +} + +/** + * A small wrapper around `waitUntil` that also returns + * the result of the awaited promise. + */ +export async function waitedUntil(fn: () => Promise): Promise { + const result = fn(); + waitUntil( + result.catch(() => { + // Ignore error from the promise being rejected. + // It's expected that the invoker of `waitedUntil` + // will handle the error. + }) + ); + return result; +} diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index 6be7802fa3..a107df5fd8 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -171,11 +171,11 @@ export const POST = handleStepRequest;`, // Replace the default export with SvelteKit-compatible handler 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(?[^)]*)\);\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; diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index e2d15bedf5..7854299e41 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'node:fs'; import { rm } from 'node:fs/promises'; import path from 'node:path'; -import type { World } from '@workflow/world'; +import type { QueuePrefix, World } from '@workflow/world'; import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world'; import type { Config } from './config.js'; import { config } from './config.js'; @@ -34,10 +34,7 @@ export type { DirectHandler } from './queue.js'; export type LocalWorld = World & { /** Register a direct in-process handler for a queue prefix, bypassing HTTP. */ - registerHandler( - prefix: '__wkf_step_' | '__wkf_workflow_', - handler: DirectHandler - ): void; + registerHandler(prefix: QueuePrefix, handler: DirectHandler): void; /** Clear all workflow data (runs, steps, events, hooks, streams). */ clear(): Promise; }; diff --git a/packages/world-local/src/queue.test.ts b/packages/world-local/src/queue.test.ts index 8b9d2d1e8c..2cc9259dac 100644 --- a/packages/world-local/src/queue.test.ts +++ b/packages/world-local/src/queue.test.ts @@ -144,6 +144,27 @@ describe('queue timeout re-enqueue', () => { }); }); + it('routes namespaced queues to namespaced direct handlers', async () => { + const handlerImpl = vi.fn( + async (_message: unknown, metadata: { queueName: string }) => { + expect(metadata.queueName).toBe('__custom_wkf_step_test'); + return undefined; + } + ); + const handler = localQueue.createQueueHandler( + '__custom_wkf_step_', + handlerImpl + ); + + localQueue.registerHandler('__custom_wkf_step_', handler); + + await localQueue.queue('__custom_wkf_step_test' as any, stepPayload); + + await vi.waitFor(() => { + expect(handlerImpl).toHaveBeenCalledTimes(1); + }); + }); + it('queue retries immediately when handler returns timeoutSeconds: 0', async () => { const { setTimeout: mockSetTimeout } = await import('node:timers/promises'); vi.mocked(mockSetTimeout).mockClear(); diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index 7ac0277dad..6a7984b964 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -1,6 +1,12 @@ import { setTimeout } from 'node:timers/promises'; import type { Transport } from '@vercel/queue'; -import { MessageId, type Queue, ValidQueueName } from '@workflow/world'; +import { + MessageId, + parseQueueName, + type Queue, + type QueuePrefix, + ValidQueueName, +} from '@workflow/world'; import { Sema } from 'async-sema'; import { monotonicFactory } from 'ulid'; import { Agent } from 'undici'; @@ -60,10 +66,7 @@ export type LocalQueue = Queue & { /** Close the HTTP agent and release resources. */ close(): Promise; /** Register a direct in-process handler for a queue prefix, bypassing HTTP. */ - registerHandler( - prefix: '__wkf_step_' | '__wkf_workflow_', - handler: DirectHandler - ): void; + registerHandler(prefix: QueuePrefix, handler: DirectHandler): void; }; const DETACHED_ARRAYBUFFER_ERROR = @@ -92,15 +95,14 @@ function isDetachedArrayBufferQueueError(error: unknown): boolean { function getQueueRoute(queueName: ValidQueueName): { pathname: 'flow' | 'step'; - prefix: '__wkf_step_' | '__wkf_workflow_'; + prefix: QueuePrefix; } { - if (queueName.startsWith('__wkf_step_')) { - return { pathname: 'step', prefix: '__wkf_step_' }; - } - if (queueName.startsWith('__wkf_workflow_')) { - return { pathname: 'flow', prefix: '__wkf_workflow_' }; - } - throw new Error('Unknown queue name prefix'); + const { kind, prefix } = parseQueueName(queueName); + + return { + pathname: kind === 'workflow' ? 'flow' : 'step', + prefix, + }; } export function createQueue(config: Partial): LocalQueue { @@ -353,10 +355,7 @@ export function createQueue(config: Partial): LocalQueue { queue, createQueueHandler, getDeploymentId, - registerHandler( - prefix: '__wkf_step_' | '__wkf_workflow_', - handler: DirectHandler - ) { + registerHandler(prefix: QueuePrefix, handler: DirectHandler) { directHandlers.set(prefix, handler); }, async close() { diff --git a/packages/world-postgres/src/config.ts b/packages/world-postgres/src/config.ts index ca778914ea..2c74ff05c9 100644 --- a/packages/world-postgres/src/config.ts +++ b/packages/world-postgres/src/config.ts @@ -6,6 +6,11 @@ type PgConnectionConfig = export type PostgresWorldConfig = PgConnectionConfig & { jobPrefix?: string; + /** + * namespace for queue topic prefixes (e.g. 'custom' → '__custom_wkf_workflow_'). + * defaults to WORKFLOW_QUEUE_NAMESPACE env var if not provided. + */ + namespace?: string; queueConcurrency?: number; /** * Override the flush interval (in ms) for buffered stream writes. diff --git a/packages/world-postgres/src/queue.test.ts b/packages/world-postgres/src/queue.test.ts index dd2122ecde..83cf885cff 100644 --- a/packages/world-postgres/src/queue.test.ts +++ b/packages/world-postgres/src/queue.test.ts @@ -1,13 +1,13 @@ import { createServer, type Server } from 'node:http'; import { JsonTransport } from '@vercel/queue'; import { getWorkflowPort } from '@workflow/utils/get-port'; -import { MessageId, type QueuePayload } from '@workflow/world'; +import { MessageId, parseQueueName, type QueuePayload } from '@workflow/world'; +import { createLocalWorld } from '@workflow/world-local'; import { makeWorkerUtils, run, type WorkerUtils } from 'graphile-worker'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createLocalWorld } from '@workflow/world-local'; import { stepEntrypoint } from '../../core/dist/runtime/step-handler.js'; -import { createQueue } from './queue.js'; import { MessageData } from './message.js'; +import { createQueue } from './queue.js'; const transport = new JsonTransport(); const createdQueues: Array> = []; @@ -256,6 +256,74 @@ describe('postgres queue http execution', () => { } }); + it('serializes namespaced workflow queue execution for the same runId', async () => { + let resolveFirstRequestStarted!: () => void; + const firstRequestStarted = new Promise((resolve) => { + resolveFirstRequestStarted = resolve; + }); + let resolveReleaseFirstRequest!: () => void; + const releaseFirstRequest = new Promise((resolve) => { + resolveReleaseFirstRequest = resolve; + }); + let requestCount = 0; + let activeRequests = 0; + let maxActiveRequests = 0; + const fetchMock = vi.fn(async () => { + requestCount += 1; + activeRequests += 1; + maxActiveRequests = Math.max(maxActiveRequests, activeRequests); + + if (requestCount === 1) { + resolveFirstRequestStarted(); + await releaseFirstRequest; + } + + activeRequests -= 1; + return Response.json({ ok: true }); + }); + vi.stubGlobal('fetch', fetchMock); + process.env.WORKFLOW_LOCAL_BASE_URL = 'http://localhost:3000'; + + const queue = buildQueue( + { connectionString: 'postgres://test', namespace: 'custom' }, + pool + ); + try { + await queue.start(); + + const task = getTaskHandler('workflow_flows'); + const payload = { + runId: 'wrun_01ABC', + }; + const firstExecution = task( + buildMessageData('__custom_wkf_workflow_test-workflow', payload, { + messageId: MessageId.parse('msg_01ABC'), + }), + {} as any + ); + const secondExecution = task( + buildMessageData('__custom_wkf_workflow_test-workflow', payload, { + messageId: MessageId.parse('msg_01ABD'), + }), + {} as any + ); + + await firstRequestStarted; + await Promise.resolve(); + expect(requestCount).toBe(1); + expect(maxActiveRequests).toBe(1); + + resolveReleaseFirstRequest(); + await Promise.all([firstExecution, secondExecution]); + + expect(requestCount).toBe(2); + expect(maxActiveRequests).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + vi.unstubAllGlobals(); + } + }); + it('does not require a runId for workflow health-check payloads', async () => { const fetchMock = vi.fn(async () => Response.json({ ok: true })); vi.stubGlobal('fetch', fetchMock); @@ -328,6 +396,40 @@ describe('postgres queue http execution', () => { vi.useRealTimers(); } }); + + it('queues namespaced producer messages in graphile job metadata', async () => { + const queue = buildQueue( + { connectionString: 'postgres://test', namespace: 'custom' }, + pool + ); + await queue.start(); + + await queue.queue( + '__custom_wkf_step_test-step', + { + workflowName: 'test-workflow', + workflowRunId: 'run_01ABC', + workflowStartedAt: Date.now(), + stepId: 'step_01ABC', + }, + { + idempotencyKey: 'step_01ABC', + } + ); + + expect(workerUtilsMock.addJob).toHaveBeenCalledWith( + 'workflow_steps', + expect.objectContaining({ + attempt: 1, + id: 'test-step', + idempotencyKey: 'step_01ABC', + }), + expect.objectContaining({ + jobKey: 'step_01ABC', + maxAttempts: 3, + }) + ); + }); }); function buildQueue( @@ -349,9 +451,7 @@ function buildMessageData( messageId?: MessageId; } ) { - const [, id] = queueName.startsWith('__wkf_step_') - ? ['__wkf_step_', queueName.slice('__wkf_step_'.length)] - : ['__wkf_workflow_', queueName.slice('__wkf_workflow_'.length)]; + const { id } = parseQueueName(queueName); return MessageData.encode({ id, diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index d432cb0d11..d72541d6de 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -2,10 +2,14 @@ import * as Stream from 'node:stream'; import type { Transport } from '@vercel/queue'; import { getWorkflowPort } from '@workflow/utils/get-port'; import { + getQueuePrefixKind, + getQueueTopicPrefix, MessageId, + parseQueueName, type Queue, QueuePayloadSchema, type QueuePrefix, + resolveQueueNamespace, type ValidQueueName, WorkflowInvokePayloadSchema, } from '@workflow/world'; @@ -116,11 +120,13 @@ export function createQueue( }; const generateMessageId = monotonicFactory(); - const prefix = config.jobPrefix || 'workflow_'; - const Queues = { - __wkf_workflow_: `${prefix}flows`, - __wkf_step_: `${prefix}steps`, - } as const satisfies Record; + function getJobQueueName(queuePrefix: QueuePrefix): string { + const jobPrefix = config.jobPrefix || 'workflow_'; + + return getQueuePrefixKind(queuePrefix) === 'workflow' + ? `${jobPrefix}flows` + : `${jobPrefix}steps`; + } const createQueueHandler = localWorld.createQueueHandler; @@ -181,7 +187,7 @@ export function createQueue( : undefined; await utils.addJob( - Queues[queuePrefix], + getJobQueueName(queuePrefix), MessageData.encode({ id: queueId, data: Buffer.from(body), @@ -220,13 +226,7 @@ export function createQueue( } function getQueueRoute(queueName: ValidQueueName): 'flow' | 'step' { - if (queueName.startsWith('__wkf_step_')) { - return 'step'; - } - if (queueName.startsWith('__wkf_workflow_')) { - return 'flow'; - } - throw new Error('Unknown queue name prefix'); + return parseQueueName(queueName).kind === 'workflow' ? 'flow' : 'step'; } async function executeMessageOverHttp({ @@ -351,7 +351,7 @@ export function createQueue( const queue: Queue['queue'] = async (queue, message, opts) => { await start(); - const [queuePrefix, queueId] = parseQueueName(queue); + const { prefix: queuePrefix, id: queueId } = parseQueueName(queue); const body = transport.serialize(message) as Buffer; const messageId = MessageId.parse(`msg_${generateMessageId()}`); await addGraphileJob({ @@ -369,13 +369,15 @@ export function createQueue( }; function createTaskHandler(queue: QueuePrefix) { + const queueKind = getQueuePrefixKind(queue); + return async (payload: unknown, helpers: unknown) => { const messageData = MessageData.parse(payload); const graphileAttempt = GraphileHelpers.safeParse(helpers); const attempt = graphileAttempt.success ? graphileAttempt.data.job.attempts : messageData.attempt; - const queueName = `${queue}${messageData.id}` as const; + const queueName = `${queue}${messageData.id}` as ValidQueueName; const bodyStream = Stream.Readable.toWeb( Stream.Readable.from([messageData.data]) ); @@ -384,7 +386,7 @@ export function createQueue( ); QueuePayloadSchema.parse(body); const workflowRunSerializationKey = - queue === '__wkf_workflow_' + queueKind === 'workflow' ? (() => { const workflowInvoke = WorkflowInvokePayloadSchema.safeParse(body); @@ -486,12 +488,12 @@ export function createQueue( string, (payload: unknown, helpers: unknown) => Promise > = {}; - for (const [prefix, jobName] of Object.entries(Queues) as [ - QueuePrefix, - string, - ][]) { - taskList[jobName] = createTaskHandler(prefix); - } + const namespace = resolveQueueNamespace(config.namespace); + const workflowPrefix = getQueueTopicPrefix('workflow', namespace); + const stepPrefix = getQueueTopicPrefix('step', namespace); + taskList[getJobQueueName(workflowPrefix)] = + createTaskHandler(workflowPrefix); + taskList[getJobQueueName(stepPrefix)] = createTaskHandler(stepPrefix); runner = await run({ pgPool: pool, @@ -521,13 +523,3 @@ export function createQueue( }, }; } - -const parseQueueName = (name: ValidQueueName): [QueuePrefix, string] => { - const prefixes: QueuePrefix[] = ['__wkf_step_', '__wkf_workflow_']; - for (const prefix of prefixes) { - if (name.startsWith(prefix)) { - return [prefix, name.slice(prefix.length)]; - } - } - throw new Error(`Invalid queue name: ${name}`); -}; diff --git a/packages/world/src/index.ts b/packages/world/src/index.ts index 06ce262df3..50fd7ba678 100644 --- a/packages/world/src/index.ts +++ b/packages/world/src/index.ts @@ -12,11 +12,15 @@ export { HookSchema } from './hooks.js'; export type * from './interfaces.js'; export type * from './queue.js'; export { + getQueuePrefixKind, + getQueueTopicPrefix, HealthCheckPayloadSchema, MessageId, + parseQueueName, QueuePayloadSchema, QueuePrefix, RunInputSchema, + resolveQueueNamespace, StepInvokePayloadSchema, ValidQueueName, WorkflowInvokePayloadSchema, diff --git a/packages/world/src/queue.test.ts b/packages/world/src/queue.test.ts new file mode 100644 index 0000000000..6031d69633 --- /dev/null +++ b/packages/world/src/queue.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import { + getQueuePrefixKind, + getQueueTopicPrefix, + parseQueueName, + QueuePrefix, + ValidQueueName, +} from './queue.js'; + +describe('getQueueTopicPrefix', () => { + it('returns default workflow prefix without namespace', () => { + expect(getQueueTopicPrefix('workflow')).toBe('__wkf_workflow_'); + }); + + it('returns default step prefix without namespace', () => { + expect(getQueueTopicPrefix('step')).toBe('__wkf_step_'); + }); + + it('returns namespaced workflow prefix', () => { + expect(getQueueTopicPrefix('workflow', 'custom')).toBe( + '__custom_wkf_workflow_' + ); + }); + + it('returns namespaced step prefix', () => { + expect(getQueueTopicPrefix('step', 'custom')).toBe('__custom_wkf_step_'); + }); + + it('accepts multi-character namespace', () => { + expect(getQueueTopicPrefix('workflow', 'myframework123')).toBe( + '__myframework123_wkf_workflow_' + ); + }); + + it('throws for namespace starting with a digit', () => { + expect(() => getQueueTopicPrefix('workflow', '123abc')).toThrow(); + }); + + it('throws for uppercase namespace', () => { + expect(() => getQueueTopicPrefix('workflow', 'Custom')).toThrow(); + }); + + it('throws for empty namespace', () => { + expect(() => getQueueTopicPrefix('workflow', '')).toThrow(); + }); + + it('throws for namespace with special characters', () => { + expect(() => getQueueTopicPrefix('workflow', 'my-framework')).toThrow(); + expect(() => getQueueTopicPrefix('workflow', 'my_framework')).toThrow(); + }); + + it('returns undefined namespace same as no namespace', () => { + expect(getQueueTopicPrefix('workflow', undefined)).toBe( + getQueueTopicPrefix('workflow') + ); + }); +}); + +describe('QueuePrefix schema', () => { + it('accepts default workflow prefix', () => { + expect(QueuePrefix.parse('__wkf_workflow_')).toBe('__wkf_workflow_'); + }); + + it('accepts default step prefix', () => { + expect(QueuePrefix.parse('__wkf_step_')).toBe('__wkf_step_'); + }); + + it('accepts namespaced workflow prefix', () => { + expect(QueuePrefix.parse('__custom_wkf_workflow_')).toBe( + '__custom_wkf_workflow_' + ); + }); + + it('accepts namespaced step prefix', () => { + expect(QueuePrefix.parse('__custom_wkf_step_')).toBe('__custom_wkf_step_'); + }); + + it('rejects invalid prefix', () => { + expect(() => QueuePrefix.parse('bad_prefix')).toThrow(); + }); + + it('rejects prefix without trailing underscore', () => { + expect(() => QueuePrefix.parse('__wkf_workflow')).toThrow(); + }); + + it('rejects uppercase namespace', () => { + expect(() => QueuePrefix.parse('__Custom_wkf_workflow_')).toThrow(); + }); +}); + +describe('getQueuePrefixKind', () => { + it('identifies default prefixes', () => { + expect(getQueuePrefixKind('__wkf_workflow_')).toBe('workflow'); + expect(getQueuePrefixKind('__wkf_step_')).toBe('step'); + }); + + it('identifies namespaced prefixes', () => { + expect(getQueuePrefixKind('__custom_wkf_workflow_')).toBe('workflow'); + expect(getQueuePrefixKind('__custom_wkf_step_')).toBe('step'); + }); +}); + +describe('ValidQueueName schema', () => { + it('accepts default queue names', () => { + expect(ValidQueueName.parse('__wkf_workflow_myFlow')).toBe( + '__wkf_workflow_myFlow' + ); + }); + + it('accepts namespaced queue names', () => { + expect(ValidQueueName.parse('__custom_wkf_workflow_myFlow')).toBe( + '__custom_wkf_workflow_myFlow' + ); + }); + + it('accepts step queue names', () => { + expect(ValidQueueName.parse('__wkf_step_myStep')).toBe('__wkf_step_myStep'); + }); + + it('rejects prefix-only without a name', () => { + expect(() => ValidQueueName.parse('__wkf_workflow_')).toThrow(); + }); + + it('rejects invalid names', () => { + expect(() => ValidQueueName.parse('not_a_queue_name')).toThrow(); + }); +}); + +describe('parseQueueName', () => { + it('parses default workflow queue names', () => { + expect(parseQueueName('__wkf_workflow_myFlow')).toEqual({ + prefix: '__wkf_workflow_', + kind: 'workflow', + id: 'myFlow', + }); + }); + + it('parses namespaced workflow queue names', () => { + expect(parseQueueName('__custom_wkf_workflow_myFlow')).toEqual({ + prefix: '__custom_wkf_workflow_', + kind: 'workflow', + id: 'myFlow', + }); + }); + + it('parses namespaced step queue names', () => { + expect(parseQueueName('__custom_wkf_step_myStep')).toEqual({ + prefix: '__custom_wkf_step_', + kind: 'step', + id: 'myStep', + }); + }); +}); diff --git a/packages/world/src/queue.ts b/packages/world/src/queue.ts index 78c7bbe631..e18acb4b6e 100644 --- a/packages/world/src/queue.ts +++ b/packages/world/src/queue.ts @@ -1,14 +1,94 @@ import { z } from 'zod/v4'; -export const QueuePrefix = z.union([ - z.literal('__wkf_step_'), - z.literal('__wkf_workflow_'), -]); +export type QueueKind = 'workflow' | 'step'; + +/** + * Pattern matching valid queue prefixes: + * - `__wkf_workflow_` / `__wkf_step_` (default, no namespace) + * - `__{namespace}_wkf_workflow_` / `__{namespace}_wkf_step_` (namespaced) + * + * Namespace must be lowercase alphanumeric starting with a letter. + */ +export const QueuePrefix = z + .string() + .regex( + /^__(?:[a-z][a-z0-9]*_)?wkf_(?:workflow|step)_$/, + 'Must match __wkf_{workflow|step}_ or __{namespace}_wkf_{workflow|step}_' + ); export type QueuePrefix = z.infer; -export const ValidQueueName = z.templateLiteral([QueuePrefix, z.string()]); +export const ValidQueueName = z + .string() + .regex( + /^__(?:[a-z][a-z0-9]*_)?wkf_(?:workflow|step)_.+$/, + 'Must be a valid queue name with a recognized prefix' + ); export type ValidQueueName = z.infer; +const QueueNamespace = z + .string() + .regex( + /^[a-z][a-z0-9]*$/, + 'Must be lowercase alphanumeric, starting with a letter' + ); + +/** + * Resolves the active queue namespace from an explicit argument or the + * `WORKFLOW_QUEUE_NAMESPACE` env var. + */ +export function resolveQueueNamespace(namespace?: string): string | undefined { + return namespace ?? process.env.WORKFLOW_QUEUE_NAMESPACE ?? undefined; +} + +/** + * Builds a queue topic prefix for the given kind and optional namespace. + * + * - `getQueueTopicPrefix('workflow')` → `'__wkf_workflow_'` + * - `getQueueTopicPrefix('workflow', 'custom')` → `'__custom_wkf_workflow_'` + */ +export function getQueueTopicPrefix( + kind: QueueKind, + namespace?: string +): QueuePrefix { + if (namespace !== undefined) { + QueueNamespace.parse(namespace); + return `__${namespace}_wkf_${kind}_` as QueuePrefix; + } + return `__wkf_${kind}_` as QueuePrefix; +} + +export function getQueuePrefixKind(prefix: QueuePrefix): QueueKind { + const match = QueuePrefix.parse(prefix).match( + /^__(?:[a-z][a-z0-9]*_)?wkf_(workflow|step)_$/ + ); + + if (!match) { + throw new Error(`Invalid queue prefix: ${prefix}`); + } + + return match[1] as QueueKind; +} + +export function parseQueueName(name: ValidQueueName): { + prefix: QueuePrefix; + kind: QueueKind; + id: string; +} { + const match = name.match( + /^(__(?:[a-z][a-z0-9]*_)?wkf_(workflow|step)_)(.+)$/ + ); + + if (!match) { + throw new Error(`Invalid queue name: ${name}`); + } + + return { + prefix: QueuePrefix.parse(match[1]), + kind: match[2] as QueueKind, + id: match[3], + }; +} + export const MessageId = z .string() .brand<'MessageId'>() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25446ac598..64197ece58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,7 +621,7 @@ importers: devDependencies: '@nuxt/module-builder': specifier: 1.0.2 - version: 1.0.2(@nuxt/cli@3.35.2(@nuxt/schema@4.4.7)(cac@6.7.14)(magicast@0.5.2))(@vue/compiler-core@3.5.35)(esbuild@0.27.7)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)) + version: 1.0.2(@nuxt/cli@3.35.2(@nuxt/schema@4.4.7)(cac@6.7.14)(magicast@0.5.2))(@vue/compiler-core@3.5.35)(esbuild@0.28.0)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)) '@nuxt/schema': specifier: 4.4.7 version: 4.4.7 @@ -18902,7 +18902,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/module-builder@1.0.2(@nuxt/cli@3.35.2(@nuxt/schema@4.4.7)(cac@6.7.14)(magicast@0.5.2))(@vue/compiler-core@3.5.35)(esbuild@0.27.7)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))': + '@nuxt/module-builder@1.0.2(@nuxt/cli@3.35.2(@nuxt/schema@4.4.7)(cac@6.7.14)(magicast@0.5.2))(@vue/compiler-core@3.5.35)(esbuild@0.28.0)(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))': dependencies: '@nuxt/cli': 3.35.2(@nuxt/schema@4.4.7)(cac@6.7.14)(magicast@0.5.2) citty: 0.1.6 @@ -18910,14 +18910,14 @@ snapshots: defu: 6.1.4 jiti: 2.6.1 magic-regexp: 0.10.0 - mkdist: 2.4.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.27.7)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)) + mkdist: 2.4.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.28.0)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)) mlly: 1.8.0 pathe: 2.0.3 pkg-types: 2.3.0 tsconfck: 3.1.6(typescript@5.9.3) typescript: 5.9.3 - unbuild: 3.6.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.27.7)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)) - vue-sfc-transformer: 0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.27.7)(vue@3.5.35(typescript@5.9.3)) + unbuild: 3.6.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.28.0)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)) + vue-sfc-transformer: 0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.28.0)(vue@3.5.35(typescript@5.9.3)) transitivePeerDependencies: - '@vue/compiler-core' - esbuild @@ -28094,7 +28094,7 @@ snapshots: mkdirp@3.0.1: {} - mkdist@2.4.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.27.7)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)): + mkdist@2.4.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.28.0)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)): dependencies: autoprefixer: 10.4.21(postcss@8.5.6) citty: 0.1.6 @@ -28112,7 +28112,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 vue: 3.5.35(typescript@5.9.3) - vue-sfc-transformer: 0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.27.7)(vue@3.5.35(typescript@5.9.3)) + vue-sfc-transformer: 0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.28.0)(vue@3.5.35(typescript@5.9.3)) mlly@1.8.0: dependencies: @@ -32024,7 +32024,7 @@ snapshots: ultrahtml@1.6.0: {} - unbuild@3.6.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.27.7)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)): + unbuild@3.6.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.28.0)(vue@3.5.35(typescript@5.9.3)))(vue@3.5.35(typescript@5.9.3)): dependencies: '@rollup/plugin-alias': 5.1.1(rollup@4.60.0) '@rollup/plugin-commonjs': 28.0.9(rollup@4.60.0) @@ -33049,11 +33049,11 @@ snapshots: '@vue/compiler-sfc': 3.5.35 vite: 7.3.5(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) - vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.27.7)(vue@3.5.35(typescript@5.9.3)): + vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.35)(esbuild@0.28.0)(vue@3.5.35(typescript@5.9.3)): dependencies: '@babel/parser': 7.29.0 '@vue/compiler-core': 3.5.35 - esbuild: 0.27.7 + esbuild: 0.28.0 vue: 3.5.35(typescript@5.9.3) vue@3.5.30(typescript@5.9.3):