diff --git a/docs/guide/queues.md b/docs/guide/queues.md index 5e6c8dd60..a5c73083f 100644 --- a/docs/guide/queues.md +++ b/docs/guide/queues.md @@ -10,7 +10,7 @@ Use queues to process work asynchronously, such as sending emails or handling up Create a consumer in `queues/`. TypeScript and JavaScript files are supported (`.ts`, `.mts`, `.js`, `.mjs`). Its path determines the queue name: `queues/emails.ts` creates `emails`, and `queues/order/notifications.ts` creates `order-notifications`. Each queue must have a unique name. -The resulting name must follow Cloudflare's queue naming rules: 1–63 characters, only letters, digits, and `-`, beginning and ending with a letter or digit. +The resulting name must follow Cloudflare's queue naming rules: 1–63 characters, only letters, digits, and `-`, beginning and ending with a letter or digit. On `void deploy --platform cloudflare`, the Cloudflare queue also carries the Worker name, so the limit applies to that full name; see [Cloudflare queue names](#cloudflare-queue-names). Export a default handler wrapped with [`defineQueue`](../reference/api/handlers.md#definequeue-t-handler). `T` defines the message body for both the consumer and calls to `send()`: @@ -116,6 +116,18 @@ deletes messages that exhaust their retries without one; see Void provisions queues and connects their producers and consumers when you deploy. +### Cloudflare queue names + +Queue names are unique per Cloudflare account. So `void deploy --platform cloudflare` names each queue after the Worker: in a Worker named `app-staging`, `queues/jobs.ts` uses the Cloudflare queue `app-staging-jobs` (a `_` in the Worker name becomes `-`). Two Workers in one account, such as `app-staging` and `app-production`, get separate queues. Your code keeps using the name from `queues/` (`queues.jobs.send()`), and `vp dev` uses that name too. + +The deploy stops before creating anything when: + +- a new queue's full name is longer than 63 characters. Shorten the Worker name or the file name. +- one queue's full name is the name of another file in `queues/`, such as `queues/jobs.ts` and `queues/app-jobs.ts` in Worker `app`. Rename one of the files. +- another Worker already consumes the Cloudflare queue the deploy would use. A Cloudflare queue has one consumer. + +A queue that the Worker already consumes keeps its name, so an app deployed before Worker-named queues keeps its queue (for example `jobs`) and the messages in it. The deploy prints a note when it keeps a name. To move to the Worker-named queue, remove the Worker's consumer from the old queue in the Cloudflare dashboard, then deploy again. Messages that reach the old queue before that deploy finishes stay there, so do this when the app is not sending messages, and check that the old queue is empty before you delete it. + ## Local development In native Void apps, sending a message through a queue binding automatically invokes the consumer in Miniflare. Local delivery uses the same handler as production. diff --git a/packages/deploy-cloudflare/test/operation-fixture.ts b/packages/deploy-cloudflare/test/operation-fixture.ts index b502a5913..cbc52bf49 100644 --- a/packages/deploy-cloudflare/test/operation-fixture.ts +++ b/packages/deploy-cloudflare/test/operation-fixture.ts @@ -99,7 +99,20 @@ export function operationFixtureResult(result: CloudflareCommandResult): Cloudfl } const queueName = result.stdout.match(/^Queue Name:\s*(\S+)/m)?.[1]; if (queueName) { - data = { name: queueName }; + // `Consumers: a, b` lists the consumer Workers; `-` is a non-Worker consumer. + const consumers = + result.stdout + .split('\n') + .find((line) => line.startsWith('Consumers:')) + ?.slice('Consumers:'.length) ?? ''; + data = { + name: queueName, + consumers: consumers + .split(',') + .map((name) => name.trim()) + .filter(Boolean) + .map((name) => (name === '-' ? null : name)), + }; } if (result.stdout.includes('No migrations to apply!')) { data = { pending: [] }; diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 2374f96e8..e872d248b 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -87,6 +87,7 @@ generated entries must preserve the module-boundary rules below. - **Repeatable framework wrappers**: `generateWrapper` recognizes current and legacy wrapper entries and preserves the original application across `--skip-build` runs. Compile handlers before replacing the entry, stage the wrapper for an atomic rename, and copy a new original only after an actual new build. A missing or recursively wrapped original requires a rebuild; readiness alone cannot validate the application entry. +- **Cloudflare queue names**: Only `void deploy --platform cloudflare` renames queues. Preflight (`cli/deploy-cloudflare-queues.ts`) reads the account and picks `-` (the Worker part in resource-name form, as for R2 and KV, untrimmed), or keeps the plain name that this Worker already consumes (Cloudflare's consumer list is the record: `void.lock.json` is shared by every Worker that deploys from the checkout). It stores the result in `desired.queueNames`, and the build gets it through `VOID_CLOUDFLARE_QUEUE_NAMES`. Bindings, the route table and the framework wrapper read the same map, and the resolver then matches names exactly, because a `-name` suffix can pick the wrong queue. Dev, previews, plain builds and the hosted platform keep plain names and the suffix fallback. - **Framework queue delivery**: Native queue handlers receive the original `MessageBatch`; only managed HTTP delivery serializes it. In deployed Workers the generated `/__void/queue` endpoint requires `__VOID_PROXY_TOKEN` (dev accepts only `x-void-dev-trigger`); it reconstructs `Date` timestamps and first-decision-wins ack/retry methods, and returns per-message decisions even if the consumer throws. Queue-only wrappers must also serve readiness. - **Shared cron expressions**: Each scheduled invocation runs every matching job once and awaits all results before surfacing failures. Native trigger configuration and managed scheduler delivery deduplicate expressions; keep this coordinated with worker dispatch to avoid multiplying jobs on managed delivery. diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index ebf91d53f..9a9b36a1b 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -618,6 +618,42 @@ function listAllResourcePages(command: Array, extraFlags: Array throw new Error(`Cloudflare ${command.join(' ')} exceeded the supported page limit.`); } +/** + * Each consumer's Worker script name, or null for one that is not a Worker (HTTP pull). + * Undefined when the list is incomplete or unreadable; only queue naming needs it. + */ +function queueConsumerWorkers(queue: Json): Array | undefined { + const consumers = queue.consumers; + if ( + !Array.isArray(consumers) || + (queue.consumers_total_count !== undefined && queue.consumers_total_count !== consumers.length) + ) { + return undefined; + } + const workers: Array = []; + for (const entry of consumers) { + const consumer = object(entry); + const { script_name: scriptName, script } = consumer; + // The same identity rules as consumer removal: any doubt leaves the list unread. + if ( + (scriptName !== undefined && (typeof scriptName !== 'string' || !scriptName)) || + (script !== undefined && (typeof script !== 'string' || !script)) || + (scriptName !== undefined && script !== undefined && scriptName !== script) + ) { + return undefined; + } + const name = scriptName ?? script; + if (typeof name === 'string' && (consumer.type === undefined || consumer.type === 'worker')) { + workers.push(name); + } else if (name === undefined && consumer.type === 'http_pull') { + workers.push(null); + } else { + return undefined; + } + } + return workers; +} + async function readAllQueues(): Promise> { // cf queues list has no page option, so read the API's paginated response directly. const queues: Array = []; @@ -813,8 +849,13 @@ async function resources(): Promise { return runCf(['queues', 'create', '--queue-name', operation.name]); case 'queues.info': { const queue = (await readAllQueues()).find((item) => item.queue_name === operation.name); + const consumers = queue && queueConsumerWorkers(queue); return queue - ? result(0, '', '', { id: queue.queue_id, name: queue.queue_name }) + ? result(0, '', '', { + id: queue.queue_id, + name: queue.queue_name, + ...(consumers ? { consumers } : {}), + }) : { ...result(1, '', 'Cloudflare Queue does not exist.'), failure: 'not-found' }; } default: diff --git a/packages/void/src/cli/deploy-cloudflare-build.ts b/packages/void/src/cli/deploy-cloudflare-build.ts index e5a6d4cba..f8c7570e2 100644 --- a/packages/void/src/cli/deploy-cloudflare-build.ts +++ b/packages/void/src/cli/deploy-cloudflare-build.ts @@ -10,7 +10,7 @@ import { type VoidConfig } from '../config.ts'; import { collectMigrations } from '../migrations/collect.ts'; import { SqliteMigrationError } from '../migrations/sqlite-validation.ts'; import { detectFramework } from '../plugin-inference.ts'; -import { CLOUDFLARE_DEPLOY_BUILD_ENV } from '../shared/env.ts'; +import { CLOUDFLARE_DEPLOY_BUILD_ENV, CLOUDFLARE_QUEUE_NAMES_BUILD_ENV } from '../shared/env.ts'; import { CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV } from '../shared/version-metadata.ts'; import { prepareCfBuildOutput, prepareCfFrameworkBuildOutput } from './cf-build-output.ts'; import { @@ -81,6 +81,10 @@ export async function buildCloudflareWorker(input: { // but local files cannot be serialized into the artifact this command uploads. deleteEnvVarCaseAware(buildChildEnv, CLOUDFLARE_DEPLOY_BUILD_ENV); buildChildEnv[CLOUDFLARE_DEPLOY_BUILD_ENV] = '1'; + deleteEnvVarCaseAware(buildChildEnv, CLOUDFLARE_QUEUE_NAMES_BUILD_ENV); + if (desired.queueNames) { + buildChildEnv[CLOUDFLARE_QUEUE_NAMES_BUILD_ENV] = JSON.stringify(desired.queueNames); + } deleteEnvVarCaseAware(buildChildEnv, CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV); if (existingWorker) { buildChildEnv[CLOUDFLARE_PRESERVE_BINDINGS_BUILD_ENV] = '1'; @@ -119,6 +123,7 @@ export async function buildCloudflareWorker(input: { requiredSecretNames: schemaFacts.requiredKeys, ai: needsAI ? { binding: aiBindingName } : undefined, preserveBindings: existingWorker !== null, + cloudflareQueueNames: desired.queueNames, }); } catch (error) { log.error( diff --git a/packages/void/src/cli/deploy-cloudflare-preflight.ts b/packages/void/src/cli/deploy-cloudflare-preflight.ts index 8bd9ba64e..c21a0570e 100644 --- a/packages/void/src/cli/deploy-cloudflare-preflight.ts +++ b/packages/void/src/cli/deploy-cloudflare-preflight.ts @@ -14,6 +14,7 @@ import { analyzeApplicationSync, type ApplicationAnalysis } from '../application import { MATERIALIZED_CLOUDFLARE_FILE } from '../cloudflare-config.ts'; import { resolveBindingNames, type VoidConfig } from '../config.ts'; import { detectFramework } from '../plugin-inference.ts'; +import { cloudflareQueueName } from '../queues/cloudflare-names.ts'; import { assertSandboxNamespaceConfig, resolveSandboxConfig } from '../sandbox/config.ts'; import { classifyCloudflareDeployApp } from './cloudflare-deploy/classify.ts'; import { approvedCloudflareIsrBinding, chooseCloudflareIsr } from './cloudflare-isr.ts'; @@ -37,6 +38,7 @@ import { connectCloudflareDeployAccount, discoverRootWranglerConfig, } from './deploy-cloudflare-helpers.ts'; +import { chooseCloudflareQueueNames } from './deploy-cloudflare-queues.ts'; import { log } from './deploy-helpers.ts'; import { resolveWranglerMigrationPattern, @@ -61,9 +63,7 @@ import { selfHostSchemaFailureMessage, type SelfHostSchemaFacts } from './env-va import { type CliLogger } from './log-file.ts'; import { getErrorMessage } from './output.ts'; import { - assertValidResourceName, checkCloudflareProvisionDrift, - isQueueConfirmedAbsent, provisionCloudflareResources, type ProvisionDriftEntry, } from './provision.ts'; @@ -580,51 +580,38 @@ export async function preflightCloudflareWorker(input: { } }; + // Queues have no id in the config, so the drift check cannot see a missing one. Naming reads + // each queue by name; only a confirmed not-found triggers provisioning. + let queueNames: Awaited>; + try { + queueNames = await chooseCloudflareQueueNames({ + desired, + workerName, + configPath, + runWrangler, + info: (message) => log.info(message), + }); + } catch (error) { + log.error(getErrorMessage(error)); + return exit(1); + } + if (!queueNames.ok) { + log.error(queueNames.message); + return exit(1); + } + const absentQueues = queueNames.absent; + const { missing: initiallyMissing } = checkCloudflareProvisionDrift(configPath, desired); // A handoff applies no migrations, so preserve the existing local migration settings. // Reconciling migrations_dir must not send an already-bound database to the provisioner. const d1NeedsConfigReconciliation = !existingWorker && d1MigrationsDirMissing(); - if (options.provision || initiallyMissing.length > 0 || d1NeedsConfigReconciliation) { + if ( + options.provision || + initiallyMissing.length > 0 || + d1NeedsConfigReconciliation || + absentQueues.length > 0 + ) { await provisionAndVerify(); - } else { - // Queue names come from unsanitized file paths. Report every invalid one before probing. - const invalidQueues = desired.queues.filter((name) => { - try { - assertValidResourceName('queue', name); - return false; - } catch { - return true; - } - }); - if (invalidQueues.length > 0) { - log.error( - [ - 'Deploy blocked — these queue names are not valid Cloudflare queue names:', - ...invalidQueues.map((name) => ` • ${name} (queue)`), - '', - 'A queue takes its name from its path in queues/; nested segments are joined with "-".', - 'A queue name must be 1–63 characters, contain only letters, digits and "-", and', - 'begin and end with a letter or digit.', - '', - 'Rename the offending file(s) in queues/ and retry.', - ].join('\n'), - ); - return exit(1); - } - // Queues have no id in the config, so the drift check cannot see a missing one. Probe each - // by name; only a confirmed not-found triggers provisioning, never an inconclusive probe. - const absentQueues: Array = []; - for (const name of desired.queues) { - const info = await Promise.resolve( - runWrangler({ type: 'queues.info', name, configPath }, { capture: true }), - ); - if (isQueueConfirmedAbsent(info)) { - absentQueues.push(name); - } - } - if (absentQueues.length > 0) { - await provisionAndVerify(); - } } // Persist the Worker name and account before the build. The CF Vite plugin copies @@ -640,11 +627,11 @@ export async function preflightCloudflareWorker(input: { ? { queues: { producers: frameworkQueues.map((queue) => ({ - queue: queue.name, + queue: cloudflareQueueName(queue.name, desired.queueNames), binding: queue.bindingName, })), consumers: frameworkQueues.map((queue) => ({ - queue: queue.name, + queue: cloudflareQueueName(queue.name, desired.queueNames), ...(queue.maxBatchSize != null && { max_batch_size: queue.maxBatchSize }), ...(queue.maxBatchTimeout != null && { max_batch_timeout: queue.maxBatchTimeout, diff --git a/packages/void/src/cli/deploy-cloudflare-queues.ts b/packages/void/src/cli/deploy-cloudflare-queues.ts new file mode 100644 index 000000000..c90626f63 --- /dev/null +++ b/packages/void/src/cli/deploy-cloudflare-queues.ts @@ -0,0 +1,160 @@ +import { isRecord } from '@void/deploy-core'; +import { + chooseCloudflareQueueName, + otherQueueConsumers, + type CloudflareQueueState, +} from '../queues/cloudflare-names.ts'; +import { isCleanCloudflareExit } from './cloudflare-process.ts'; +import { type CloudflareDeployDeps } from './deploy-types.ts'; +import { + assertValidResourceName, + isQueueConfirmedAbsent, + type DesiredResourceSet, +} from './provision.ts'; + +function isValidQueueName(name: string): boolean { + try { + assertValidResourceName('queue', name); + return true; + } catch { + return false; + } +} + +/** + * Pick each queue's Cloudflare name from the account's queues and write it into `desired`. + * Returns the chosen names that do not exist yet, or a message that blocks the deploy. + */ +export async function chooseCloudflareQueueNames(input: { + desired: DesiredResourceSet; + workerName: string; + configPath: string; + runWrangler: NonNullable; + info: (message: string) => void; +}): Promise<{ ok: true; absent: Array } | { ok: false; message: string }> { + const { desired, workerName, configPath, runWrangler } = input; + if (!desired.queueNames) { + return { ok: true, absent: [] }; + } + + const lookup = async (name: string): Promise => { + const info = await Promise.resolve( + runWrangler({ type: 'queues.info', name, configPath }, { capture: true }), + ); + if (isQueueConfirmedAbsent(info)) { + return null; + } + const data = info.data; + if ( + !isCleanCloudflareExit(info) || + !isRecord(data) || + data.name !== name || + !Array.isArray(data.consumers) + ) { + throw new Error( + `deploy: could not read Cloudflare queue ${name} and its consumers. Retry \`void deploy --platform cloudflare\`.`, + ); + } + return { consumers: data.consumers as Array }; + }; + + // Names come from unsanitized file paths. Report every invalid one before any lookup. + const invalid = Object.keys(desired.queueNames) + .filter((queueName) => !isValidQueueName(queueName)) + .map((queueName) => ` • ${queueName} (queue)`); + if (invalid.length > 0) { + return { + ok: false, + message: [ + 'Deploy blocked — these queue names are not valid Cloudflare queue names:', + ...invalid, + '', + 'A queue takes its name from its path in queues/; nested segments are joined with "-".', + 'A queue name must be 1–63 characters, contain only letters, digits and "-", and', + 'begin and end with a letter or digit.', + '', + 'Rename the offending file(s) in queues/ and retry.', + ].join('\n'), + }; + } + + // `queues/jobs.ts` and `queues/app-jobs.ts` in Worker `app` would both claim the Cloudflare + // queue `app-jobs`, and nothing says which one an existing `app-jobs` belongs to. + const queueNames = desired.queueNames; + const clashes = Object.keys(queueNames) + .filter((queueName) => Object.hasOwn(queueNames, queueNames[queueName]!)) + .map( + (queueName) => + ` • ${queueName} uses the Cloudflare queue ${queueNames[queueName]}, which is also a queue in queues/`, + ); + if (clashes.length > 0) { + return { + ok: false, + message: [ + 'Deploy blocked — these queues would share a Cloudflare queue:', + ...clashes, + '', + `A queue's Cloudflare name is the Worker name, "-", then its name from queues/.`, + 'Rename one of the files in queues/, then retry.', + ].join('\n'), + }; + } + + const names: Record = {}; + const absent: Array = []; + const tooLong: Array = []; + const taken: Array = []; + for (const queueName of Object.keys(desired.queueNames)) { + const scopedName = desired.queueNames[queueName]!; + const scoped = isValidQueueName(scopedName) ? await lookup(scopedName) : null; + // A queue this Worker consumed before queues were named after the Worker keeps its name. + const plain = scoped?.consumers.includes(workerName) ? undefined : await lookup(queueName); + const choice = chooseCloudflareQueueName({ workerName, queueName, scopedName, scoped, plain }); + names[queueName] = choice.name; + if (!isValidQueueName(choice.name)) { + tooLong.push(` • ${choice.name} (${choice.name.length} characters)`); + continue; + } + const state = choice.kept ? plain! : scoped; + if (state === null) { + absent.push(choice.name); + } + for (const consumer of otherQueueConsumers(state, workerName)) { + taken.push( + ` • ${choice.name} is consumed by ${consumer === null ? 'a non-Worker (HTTP pull) consumer' : `Worker ${consumer}`}`, + ); + } + if (choice.kept) { + input.info( + `Queue ${queueName} keeps its Cloudflare name ${choice.name}, because this Worker already consumes it.`, + ); + } + } + + if (tooLong.length > 0) { + return { + ok: false, + message: [ + 'Deploy blocked — these Cloudflare queue names are longer than 63 characters:', + ...tooLong, + '', + `A queue's Cloudflare name is the Worker name, "-", then its name from queues/.`, + 'Shorten the Worker name or rename the file(s) in queues/, then retry.', + ].join('\n'), + }; + } + if (taken.length > 0) { + return { + ok: false, + message: [ + `Deploy blocked — Worker ${workerName} cannot consume these queues:`, + ...taken, + '', + 'A Cloudflare queue has one consumer. Rename the file in queues/ or the Worker, then retry.', + ].join('\n'), + }; + } + desired.queueNames = names; + desired.queues = Object.values(names); + return { ok: true, absent }; +} diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts index 65523610e..7d21c36ce 100644 --- a/packages/void/src/cli/deploy-cloudflare-static.ts +++ b/packages/void/src/cli/deploy-cloudflare-static.ts @@ -18,6 +18,7 @@ import { analyzeApplication } from '../application-routing.ts'; import { type VoidConfig } from '../config.ts'; import { collectRevalidate, hasRevalidate } from '../isr-config.ts'; import { detectFramework, type DetectedFramework } from '../plugin-inference.ts'; +import { type CloudflareQueueNames } from '../queues/cloudflare-names.ts'; import { hasEmailHandlers } from '../router/email.ts'; import { selectNativeVersionMetadataBinding } from '../shared/version-metadata.ts'; import { validateSsrEntry } from '../ssr/entry.ts'; @@ -79,6 +80,7 @@ export async function prepareCloudflareFrameworkArtifact(options: { requiredSecretNames?: ReadonlyArray; ai?: { binding: string }; preserveBindings?: boolean; + cloudflareQueueNames?: CloudflareQueueNames; }): Promise { const { root, configPath, accountId, workerName, voidConfig, framework, preset, ai } = options; const { requiredSecretNames = [], preserveBindings = false } = options; @@ -106,6 +108,7 @@ export async function prepareCloudflareFrameworkArtifact(options: { await generateDeployWrapperIfNeeded(root, join(root, preset.workerDir), preset.workerMain, { crons, queues, + cloudflareQueueNames: options.cloudflareQueueNames, // Direct deployment applies migrations before upload; it does not need the // hosted platform's internal migration endpoint in its framework wrapper. hasMigrations: false, diff --git a/packages/void/src/cli/deploy-resources.ts b/packages/void/src/cli/deploy-resources.ts index 9bee0675f..ff8d010fe 100644 --- a/packages/void/src/cli/deploy-resources.ts +++ b/packages/void/src/cli/deploy-resources.ts @@ -10,6 +10,7 @@ import { analyzeApplication, completeApplicationAnalysis } from '../application- import { MATERIALIZED_CLOUDFLARE_FILE } from '../cloudflare-config.ts'; import { type DatabaseDialect, type VoidConfig } from '../config.ts'; import { collectRevalidate, hasRevalidate } from '../isr-config.ts'; +import { scopedCloudflareQueueName } from '../queues/cloudflare-names.ts'; import { hasEmailHandlers, type EmailHandlersScan } from '../router/email.ts'; import { DEFAULT_VOID_AI_BINDING } from './deploy-binding-snapshot.ts'; import { type CloudflareAuthSecretFailureReason } from './deploy-types.ts'; @@ -270,8 +271,17 @@ export async function computeDesiredCloudflareResources( reasons.set(binding, analysis.reasons.r2[0]!); } - // Queues (resource names — account-unique; the build binds them for native apps). - desired.queues = analysis.queues.map((q) => q.name); + // Queue names are account-unique, so each is named after the Worker. Preflight keeps a + // queue this Worker already consumes under its old name. + if (analysis.queues.length > 0) { + desired.queueNames = Object.fromEntries( + analysis.queues.map((q) => [ + q.name, + scopedCloudflareQueueName(toValidWorkerName(workerName), q.name), + ]), + ); + desired.queues = Object.values(desired.queueNames); + } // The connection string is used only on create; later runs read the stored id. if (needsDatabase && dialect !== 'sqlite') { diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 930cb1508..92e6b9bfa 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -22,6 +22,7 @@ import { parse, type ParseError } from 'jsonc-parser'; import { basename, dirname, join, normalize, resolve } from 'pathslash'; import lockfile from 'proper-lockfile'; import { voidLog, voidWarn } from '../log.ts'; +import type { CloudflareQueueNames } from '../queues/cloudflare-names.ts'; import { scanWranglerDotenvKeys } from '../shared/dotenv.ts'; import { encodeCloudflareOperation, @@ -46,6 +47,8 @@ export interface DesiredResourceSet { r2?: { binding: string; bucketName: string }; /** Queue resource NAMES to ensure exist (account-unique). */ queues: Array; + /** Each `queues/` name to its entry in `queues`; set by `void deploy`. */ + queueNames?: CloudflareQueueNames; /** Hyperdrive config — a connection string is required only to CREATE one. */ hyperdrive?: { binding: string; name: string; connectionString: string }; } diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 2f0ba1b18..2d4b46f64 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1371,9 +1371,13 @@ export function writeProvisionedWranglerConfig( `this config. Remove the duplicate producers and retry.`, ); } + // A producer Void wrote earlier may move to the queue's new name; the update below + // rewrites its `queue` in place. + const voidOwned = + reconcileManagedQueues && managedProducerBindings.has(String(entry.binding)); if (matches.length === 1) { const existing = currentList[matches[0]]; - if (existing.queue !== entry.queue) { + if (existing.queue !== entry.queue && !voidOwned) { throw new Error( `deploy: cannot update "${configPath}" — queue producer binding ` + `${JSON.stringify(entry.binding)} is already bound to queue ` + diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index 36fd7f51e..b89de58fd 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -17,6 +17,7 @@ import { getDatabaseDialect, readConfig, type DatabaseDialect } from '../config. import type { MigrationPlanBundle } from '../migrations/plan.ts'; import { voidDbBundlerPlugin } from '../plugin-drizzle.ts'; import { readProjectPaths } from '../project-paths.ts'; +import { cloudflareQueueName, type CloudflareQueueNames } from '../queues/cloudflare-names.ts'; import { generateQueueResolverSource } from '../queues/dispatch.ts'; import { WORKER_CONDITIONS } from './worker-conditions.ts'; import { transformWorkerRequireBundle } from './worker-require-transform.ts'; @@ -26,6 +27,8 @@ export type WrapperFeatures = { crons: Array<{ name: string; filePath: string; crons: Array }>; /** Queue consumer definitions (from scanQueuesSync). */ queues: Array<{ name: string; filePath: string; bindingName: string }>; + /** Cloudflare queue names chosen by `void deploy --platform cloudflare`. */ + cloudflareQueueNames?: CloudflareQueueNames; /** Whether migrations are present (framework deploys need a /__void/migrate endpoint). */ hasMigrations?: boolean; /** Expose the readiness endpoint used by direct version activation. */ @@ -838,7 +841,14 @@ function generateHandlersSource( } lines.push(''); - lines.push(generateQueueResolverSource(features.queues.map((queue) => queue.name))); + const queueNames = features.queues.map((queue) => queue.name); + const cloudflareNames = features.cloudflareQueueNames; + lines.push( + generateQueueResolverSource( + queueNames, + cloudflareNames && queueNames.map((name) => cloudflareQueueName(name, cloudflareNames)), + ), + ); lines.push(''); lines.push( scoped diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 7765ae32f..e39a5b581 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -34,6 +34,7 @@ import { writeFileIfChanged } from './codegen/write-if-changed.ts'; import { readConfig, getDatabaseDialect } from './config.ts'; import { collectPages, collectRevalidate, hasRevalidate } from './isr-config.ts'; import { readProjectPaths } from './project-paths.ts'; +import { readDeployBuildQueueNames } from './queues/cloudflare-names.ts'; import { compileRouteTable, compileBrowserClient, @@ -621,6 +622,8 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug // so it never compiles the route table as dev. compileOptions.dev = config.command === 'serve' && !previewing; compileOptions.assetsDir = config.build.assetsDir; + compileOptions.cloudflareQueueNames = + config.command === 'build' ? readDeployBuildQueueNames() : undefined; compileOptions.reactRefresh = config.plugins.some( (plugin) => plugin.name === 'vite:react-babel' || diff --git a/packages/void/src/plugin-worker-bindings.ts b/packages/void/src/plugin-worker-bindings.ts index 37578de00..d6ac67434 100644 --- a/packages/void/src/plugin-worker-bindings.ts +++ b/packages/void/src/plugin-worker-bindings.ts @@ -18,6 +18,7 @@ import { } from './plugin-ai-setup.ts'; import { hasBinding, shouldInjectEmailDevInboxVar } from './plugin-binding-utils.ts'; import { type EnvSchemaFacts } from './plugin-env-schema.ts'; +import { cloudflareQueueName, readDeployBuildQueueNames } from './queues/cloudflare-names.ts'; import { isRouteEnabledForEnvironment, type RouteEnvironment } from './router/parse-filename.ts'; import { DEV_INBOX_ENV_VAR, DEV_INBOX_TOKEN_ENV_VAR } from './runtime/email/dev-inbox.ts'; import { @@ -273,12 +274,17 @@ export function mergeWorkerBindings( result.vars = vars; } - // Queues — add producer + consumer bindings for local dev + // Queues — producer + consumer bindings. `void deploy` picks the Cloudflare names; every + // other run uses the plain names. if (queues.length > 0) { + const cloudflareNames = viteCommand === 'build' ? readDeployBuildQueueNames() : undefined; result.queues = { - producers: queues.map((q) => ({ queue: q.name, binding: q.bindingName })), + producers: queues.map((q) => ({ + queue: cloudflareQueueName(q.name, cloudflareNames), + binding: q.bindingName, + })), consumers: queues.map((q) => ({ - queue: q.name, + queue: cloudflareQueueName(q.name, cloudflareNames), ...(q.maxBatchSize != null && { max_batch_size: q.maxBatchSize }), ...(q.maxBatchTimeout != null && { max_batch_timeout: q.maxBatchTimeout }), ...(q.maxRetries != null && { max_retries: q.maxRetries }), diff --git a/packages/void/src/queues/cloudflare-names.ts b/packages/void/src/queues/cloudflare-names.ts new file mode 100644 index 000000000..571720ff3 --- /dev/null +++ b/packages/void/src/queues/cloudflare-names.ts @@ -0,0 +1,96 @@ +import { CLOUDFLARE_DEPLOY_BUILD_ENV, CLOUDFLARE_QUEUE_NAMES_BUILD_ENV } from '../shared/env.ts'; + +/** + * Cloudflare queue names for `void deploy --platform cloudflare`. + * + * Queue names are unique per account, so a deploy names each queue after its + * Worker: `queues/jobs.ts` in Worker `app-staging` becomes `app-staging-jobs`. + * A queue the Worker already consumes keeps its name, so apps deployed before + * this rule keep their queue and its backlog. Dev, previews and the hosted + * platform keep the plain queue name. + */ + +/** Logical queue name (from `queues/`) to Cloudflare queue name. */ +export type CloudflareQueueNames = Readonly>; + +/** + * An existing Cloudflare queue, listed by its consumers: the Worker script + * name, or `null` for a consumer that is not a Worker (HTTP pull). `null` for + * the whole value means the queue does not exist. + */ +export type CloudflareQueueState = { consumers: ReadonlyArray } | null; + +/** `workerPrefix` is the Worker name in resource-name form (lowercase, `_` as `-`). */ +export function scopedCloudflareQueueName(workerPrefix: string, queueName: string): string { + return `${workerPrefix}-${queueName}`; +} + +const consumedBy = (state: CloudflareQueueState, workerName: string): boolean => + state?.consumers.includes(workerName) ?? false; + +/** + * Pick the Cloudflare name for one queue: its scoped name, or its plain name + * when this Worker already consumes that queue. `plain` may be `undefined` + * when it was not looked up, which is only needed when the Worker does not + * already consume the scoped queue. + */ +export function chooseCloudflareQueueName(input: { + workerName: string; + queueName: string; + scopedName: string; + scoped: CloudflareQueueState; + plain: CloudflareQueueState | undefined; +}): { name: string; kept: boolean } { + if ( + !consumedBy(input.scoped, input.workerName) && + consumedBy(input.plain ?? null, input.workerName) + ) { + return { name: input.queueName, kept: true }; + } + return { name: input.scopedName, kept: false }; +} + +/** Consumers of a queue other than this Worker. A queue has one consumer. */ +export function otherQueueConsumers( + state: CloudflareQueueState, + workerName: string, +): Array { + return state?.consumers.filter((consumer) => consumer !== workerName) ?? []; +} + +/** + * The names `void deploy` chose, read inside its Vite build. Returns undefined + * outside that build, where queues keep their plain names. + */ +export function readDeployBuildQueueNames(): CloudflareQueueNames | undefined { + if (process.env[CLOUDFLARE_DEPLOY_BUILD_ENV] !== '1') { + return undefined; + } + const raw = process.env[CLOUDFLARE_QUEUE_NAMES_BUILD_ENV]; + if (raw === undefined) { + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + parsed = undefined; + } + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) || + Object.values(parsed).some((name) => typeof name !== 'string' || name === '') + ) { + throw new Error(`queue: ${CLOUDFLARE_QUEUE_NAMES_BUILD_ENV} is not a valid queue name map.`); + } + return parsed as CloudflareQueueNames; +} + +/** The Cloudflare name of a queue: the deploy's choice, or its plain name. */ +export function cloudflareQueueName( + queueName: string, + names: CloudflareQueueNames | undefined, +): string { + return names && Object.hasOwn(names, queueName) ? names[queueName]! : queueName; +} diff --git a/packages/void/src/queues/dispatch.ts b/packages/void/src/queues/dispatch.ts index efb7552bb..223f6235d 100644 --- a/packages/void/src/queues/dispatch.ts +++ b/packages/void/src/queues/dispatch.ts @@ -3,8 +3,33 @@ * wrappers. Hosted relays send the exact logical name. Native Queue events may * expose a project-prefixed physical name, so fallback matching selects the * unique longest known `-name` suffix. Unknown or ambiguous inputs fail closed. + * + * `cloudflareNames` (aligned with `queueNames`) comes from + * `void deploy --platform cloudflare`, which knows each queue's exact name. + * Its events then match exactly: a suffix could pick the wrong queue, as + * `app-jobs` would for queues `jobs` and `app-jobs` in Worker `app`. Logical + * names still match, for the trigger endpoint. */ -export function generateQueueResolverSource(queueNames: ReadonlyArray): string { +export function generateQueueResolverSource( + queueNames: ReadonlyArray, + cloudflareNames?: ReadonlyArray, +): string { + if (cloudflareNames) { + const indexByName: Record = {}; + cloudflareNames.forEach((name, index) => { + indexByName[name] = index; + }); + queueNames.forEach((name, index) => { + if (!Object.hasOwn(indexByName, name)) { + indexByName[name] = index; + } + }); + return `const __queueIndexByName = ${JSON.stringify(indexByName)}; +function __resolveQueueIndex(queueName) { + if (typeof queueName !== "string" || !Object.hasOwn(__queueIndexByName, queueName)) return -1; + return __queueIndexByName[queueName]; +}`; + } return `const __queueNames = ${JSON.stringify(queueNames)}; function __resolveQueueIndex(queueName) { if (typeof queueName !== "string") return -1; diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index a83eccda6..891022a68 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -5,6 +5,7 @@ import type { DevRedirectRule, HeaderRule } from '../cli/types.ts'; import type { NotFoundHandling } from '../config.ts'; import type { MigrationPlanBundle } from '../migrations/plan.ts'; import { resolveLayoutIds } from '../pages/layout-resolution.ts'; +import { cloudflareQueueName, type CloudflareQueueNames } from '../queues/cloudflare-names.ts'; import { generateQueueResolverSource } from '../queues/dispatch.ts'; import { INTERNAL_BINDING_EXACT_NAMES, INTERNAL_BINDING_PREFIXES } from '../runtime/env-raw.ts'; import { ASSET_EXTENSIONS } from '../runtime/routing-assets.ts'; @@ -96,6 +97,8 @@ export type CompileOptions = { hasLive?: boolean; /** Removed WebSocket classes retained to preserve Durable Object storage. */ webSocketTombstones?: Array; + /** Cloudflare queue names chosen by `void deploy --platform cloudflare`. */ + cloudflareQueueNames?: CloudflareQueueNames; }; export type SsrCompileOptions = { @@ -507,7 +510,14 @@ export function compileRouteTable( } if (scan.queues.length > 0) { - lines.push(generateQueueResolverSource(scan.queues.map((queue) => queue.name))); + const queueNames = scan.queues.map((queue) => queue.name); + const cloudflareNames = options.cloudflareQueueNames; + lines.push( + generateQueueResolverSource( + queueNames, + cloudflareNames && queueNames.map((name) => cloudflareQueueName(name, cloudflareNames)), + ), + ); lines.push(''); // Queue dispatch as a standalone function — called directly by native queue handler // and via the authenticated HTTP route. No global mutable state needed. diff --git a/packages/void/src/shared/env.ts b/packages/void/src/shared/env.ts index 20283fc0a..6b3d2c126 100644 --- a/packages/void/src/shared/env.ts +++ b/packages/void/src/shared/env.ts @@ -35,6 +35,12 @@ export function loadLocalEnv( */ export const CLOUDFLARE_DEPLOY_BUILD_ENV = 'VOID_CLOUDFLARE_DEPLOY_BUILD'; +/** + * JSON map from each `queues/` name to the Cloudflare queue name that + * `void deploy --platform cloudflare` chose. Set only for its Vite build. + */ +export const CLOUDFLARE_QUEUE_NAMES_BUILD_ENV = 'VOID_CLOUDFLARE_QUEUE_NAMES'; + /** * Find legacy Vite dotenv variants. Void intentionally supports only `.env`; * production configuration is project-scoped remote state instead of a file. diff --git a/packages/void/test/integration/cloudflare-deploy-build.test.ts b/packages/void/test/integration/cloudflare-deploy-build.test.ts index 589a05ca5..98f2f7a73 100644 --- a/packages/void/test/integration/cloudflare-deploy-build.test.ts +++ b/packages/void/test/integration/cloudflare-deploy-build.test.ts @@ -1,4 +1,4 @@ -import { readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, normalize } from 'node:path'; import { parseRootWranglerConfig } from '@void/deploy-cloudflare'; import spawn from 'cross-spawn'; @@ -24,7 +24,10 @@ import { sandboxContainerConfig, SANDBOX_MIGRATION_TAG, } from '../../src/sandbox/config.ts'; -import { CLOUDFLARE_DEPLOY_BUILD_ENV } from '../../src/shared/env.ts'; +import { + CLOUDFLARE_DEPLOY_BUILD_ENV, + CLOUDFLARE_QUEUE_NAMES_BUILD_ENV, +} from '../../src/shared/env.ts'; // A real build of kitchen-sink must pass the deploy guard unmodified. Unit tests use hand-written // configs, so only this catches a plugin upgrade that emits a field the guard rejects. @@ -109,7 +112,12 @@ describe('playground/kitchen-sink --platform cloudflare real-build deploy guard' cwd: pg, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, - env: { ...process.env, [CLOUDFLARE_DEPLOY_BUILD_ENV]: '1' }, + env: { + ...process.env, + [CLOUDFLARE_DEPLOY_BUILD_ENV]: '1', + // Preflight passes the names it chose; a new account gets the Worker-scoped ones. + [CLOUDFLARE_QUEUE_NAMES_BUILD_ENV]: JSON.stringify(desired.queueNames), + }, }); expect(result.status, `${result.stdout ?? ''}${result.stderr ?? ''}`).toBe(0); @@ -158,8 +166,23 @@ describe('playground/kitchen-sink --platform cloudflare real-build deploy guard' producers: Array<{ queue: string }>; consumers: Array<{ queue: string }>; }; - expect(queues.producers).toContainEqual(expect.objectContaining({ queue: 'demo-queue' })); - expect(queues.consumers).toContainEqual(expect.objectContaining({ queue: 'demo-queue' })); + expect(queues.producers).toContainEqual({ + binding: 'QUEUE_DEMO_QUEUE', + queue: `${WORKER_NAME}-demo-queue`, + }); + expect(queues.consumers).toContainEqual( + expect.objectContaining({ queue: `${WORKER_NAME}-demo-queue` }), + ); + }); + + it('routes the Worker-scoped queue name to its handler by exact match', () => { + const worker = readdirSync(join(pg, 'dist', 'ssr'), { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('.js')) + .map((file) => readFileSync(join(pg, 'dist', 'ssr', file), 'utf8')) + .join('\n'); + expect(worker).toContain('__queueIndexByName'); + expect(worker).toContain(`"${WORKER_NAME}-demo-queue": 0`); + expect(worker).not.toContain('queueName.endsWith("-" + name)'); }); it('the real emitted config passes verifyGeneratedDeployConfig unmodified', () => { diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 89a6bb3e3..74ce099f0 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -486,6 +486,10 @@ function harness(overrides: Partial = {}): { if (args[0] === 'd1' && args[1] === 'migrations' && args[2] === 'list') { return { ...cleanExit, stdout: '✅ No migrations to apply!\n' }; } + // Every queue exists and has no consumer yet. + if (args[0] === 'queues' && args[1] === 'info') { + return { ...cleanExit, stdout: `Queue Name: ${args[2]}\n` }; + } return cleanExit; }); const deps: CloudflareDeployDeps = { @@ -1694,7 +1698,8 @@ describe('computeDesiredCloudflareResources — desired set from source', () => }); expect(desired.kvNamespaces).toEqual([{ binding: 'KV', title: 'myapp-kv' }]); expect(desired.r2).toEqual({ binding: 'STORAGE', bucketName: 'myapp-storage' }); - expect(desired.queues).toEqual(['jobs']); + expect(desired.queues).toEqual(['myapp-jobs']); + expect(desired.queueNames).toEqual({ jobs: 'myapp-jobs' }); expect(desired.hyperdrive).toBeUndefined(); }); @@ -3851,122 +3856,275 @@ describe('runCloudflareDeploy — full Void app queues', () => { }); }); -describe('runCloudflareDeploy — pre-build queue existence probe', () => { - /** A worker-bearing app desiring exactly one queue (`jobs`), fully config-provisioned. */ - function queueProject(): string { +describe('runCloudflareDeploy — Cloudflare queue names', () => { + /** A worker-bearing app with one queue (`jobs`), deployed as Worker `name`. */ + function queueProject(name = 'qapp', files: Record = {}): string { return makeProject({ 'package.json': JSON.stringify({ name: 'qapp' }), 'void.json': JSON.stringify({}), 'routes/index.ts': `export const GET = () => new Response('ok');`, 'queues/jobs.ts': `export default {};`, - 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'qapp' }, null, 2), + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name }, null, 2), + ...files, }); } + /** `queues info` output for a queue that exists, with its consumer Workers. */ + const queueExists = (name: string, ...consumers: Array): DeployWranglerRunResult => ({ + ...cleanExit, + stdout: `Queue Name: ${name}\nConsumers: ${consumers.join(', ')}\n`, + }); + const queueAbsent = (name: string): DeployWranglerRunResult => ({ + ...cleanExit, + status: 1, + stderr: `✘ [ERROR] Queue "${name}" does not exist.\n`, + }); + /** * The default harness plus a scripted `queues info` reply, so a test drives the - * REAL classifier with the exact output wrangler would print. Every other wrangler - * call keeps the harness behaviour (and its arg/trace recording). + * REAL classifier with the exact output wrangler would print. The build records the + * queue names it was given. */ function queueHarness( probe: (name: string) => DeployWranglerRunResult, overrides: Partial = {}, - ): ReturnType { + ): ReturnType & { builtQueueNames: Array } { + const builtQueueNames: Array = []; const h = harness(overrides); const base = h.deps.runWrangler as DeployWranglerRun; h.deps.runWrangler = operationFixture((args, opts) => { const passthrough = base(operationFixtureRequest(args), opts); return args[0] === 'queues' && args[1] === 'info' ? probe(args[2] as string) : passthrough; }); - return h; + const build = h.deps.runBuild!; + h.deps.runBuild = (env) => { + const names = env.VOID_CLOUDFLARE_QUEUE_NAMES; + builtQueueNames.push(names === undefined ? undefined : JSON.parse(names)); + return build(env); + }; + return { ...h, builtQueueNames }; + } + + /** Run the deploy capturing `log.error` and `log.info`, restoring the real logger after. */ + async function runCapturing( + root: string, + deps: CloudflareDeployDeps, + options: { provision?: boolean; fails?: boolean } = {}, + ): Promise<{ errors: Array; infos: Array }> { + const errors: Array = []; + const infos: Array = []; + const restore = { error: log.error, info: log.info }; + log.error = (message: string) => errors.push(message); + log.info = (message: string) => infos.push(message); + try { + const run = runCloudflareDeploy( + root, + { platform: 'cloudflare', ...(options.provision ? { provision: true } : {}) }, + silentCliLog, + deps, + ); + if (options.fails) { + await expect(run).rejects.toBeInstanceOf(ExitError); + } else { + await run; + } + } finally { + log.error = restore.error; + log.info = restore.info; + } + return { errors, infos }; } - it('probes each desired queue by name before the build', async () => { + const queueInfoArgs = (wranglerArgs: Array>) => + wranglerArgs.filter((a) => a[0] === 'queues' && a[1] === 'info').map((a) => a[2]); + + it('names a new queue after the Worker and reads the account before the build', async () => { const root = queueProject(); - const { deps, wranglerArgs, trace } = queueHarness((name) => ({ - ...cleanExit, - stdout: `Queue Name: ${name}\n`, - })); - await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); - const probeArgs = wranglerArgs.find((a) => a[0] === 'queues'); - expect(probeArgs).toEqual([ - 'queues', - 'info', - 'jobs', - '-c', - toSlash(join(root, 'wrangler.jsonc')), - '--env=', - ]); - // The probe runs before the build and everything after it. + const { deps, wranglerArgs, trace, builtQueueNames } = queueHarness(queueAbsent); + await runCapturing(root, deps); + // The scoped name, then the plain name an older deploy would have used. + expect(queueInfoArgs(wranglerArgs)).toEqual(['qapp-jobs', 'jobs']); expect(trace.indexOf('wrangler:queues')).toBeLessThan(trace.indexOf('build')); + // A confirmed not-found provisions the scoped queue. + expect(trace).toContain('provision'); + expect(builtQueueNames).toEqual([{ jobs: 'qapp-jobs' }]); expect(trace).toContain('wrangler:deploy'); }); - it('automatically provisions a queue when the probe confirms it does not exist', async () => { + it('keeps using a scoped queue this Worker already consumes', async () => { const root = queueProject(); - const { deps, trace, exitCalls } = queueHarness((name) => ({ - ...cleanExit, - status: 1, - stderr: `✘ [ERROR] Queue "${name}" does not exist.\n`, - })); - await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); - expect(exitCalls).toEqual([]); - expect(trace).toContain('wrangler:queues'); - expect(trace).toContain('provision'); - expect(trace).toContain('build'); - expect(trace).toContain('wrangler:deploy'); + const { deps, wranglerArgs, trace, builtQueueNames } = queueHarness((name) => + name === 'qapp-jobs' ? queueExists(name, 'qapp') : queueExists(name, 'qapp'), + ); + await runCapturing(root, deps); + // The plain queue is not read once the Worker is known to consume the scoped one. + expect(queueInfoArgs(wranglerArgs)).toEqual(['qapp-jobs']); + expect(trace).not.toContain('provision'); + expect(builtQueueNames).toEqual([{ jobs: 'qapp-jobs' }]); + }); + + it('keeps the plain queue a Worker deployed before queues were named after it', async () => { + const root = queueProject(); + const { deps, trace, builtQueueNames } = queueHarness((name) => + name === 'jobs' ? queueExists(name, 'qapp') : queueAbsent(name), + ); + const { infos } = await runCapturing(root, deps); + expect(trace).not.toContain('provision'); + expect(builtQueueNames).toEqual([{ jobs: 'jobs' }]); + expect(infos.join('\n')).toContain( + 'Queue jobs keeps its Cloudflare name jobs, because this Worker already consumes it.', + ); + }); + + it('gives staging and production separate queues in one account (#547)', async () => { + // Production deployed before queues were named after the Worker and consumes `jobs`. + const account = (name: string) => + name === 'jobs' ? queueExists(name, 'app-production') : queueAbsent(name); + + const staging = queueHarness(account); + await runCapturing(queueProject('app-staging'), staging.deps); + expect(staging.builtQueueNames).toEqual([{ jobs: 'app-staging-jobs' }]); + expect(staging.trace).toContain('provision'); + + const production = queueHarness(account); + await runCapturing(queueProject('app-production'), production.deps); + expect(production.builtQueueNames).toEqual([{ jobs: 'jobs' }]); + expect(production.trace).not.toContain('provision'); + }); + + it('gives staging and production separate queues in a new account', async () => { + const staging = queueHarness(queueAbsent); + await runCapturing(queueProject('app-staging'), staging.deps); + const production = queueHarness(queueAbsent); + await runCapturing(queueProject('app-production'), production.deps); + expect([...staging.builtQueueNames, ...production.builtQueueNames]).toEqual([ + { jobs: 'app-staging-jobs' }, + { jobs: 'app-production-jobs' }, + ]); + }); + + it('names queues after a Worker name with "_" in resource-name form', async () => { + // Wrangler accepts `my_app`; queue names allow only letters, digits and "-". + const fresh = queueHarness(queueAbsent); + await runCapturing(queueProject('my_app'), fresh.deps); + expect(fresh.builtQueueNames).toEqual([{ jobs: 'my-app-jobs' }]); + + // Consumers are listed by the Worker's real script name. + const deployed = queueHarness((name) => + name === 'jobs' ? queueExists(name, 'my_app') : queueAbsent(name), + ); + await runCapturing(queueProject('my_app'), deployed.deps); + expect(deployed.builtQueueNames).toEqual([{ jobs: 'jobs' }]); + }); + + it('refuses two queues that would share a Cloudflare queue, before any lookup', async () => { + const root = queueProject('app', { 'queues/app-jobs.ts': 'export default {};' }); + const { deps, wranglerArgs, trace, exitCalls } = queueHarness(queueAbsent); + const { errors } = await runCapturing(root, deps, { fails: true }); + expect(exitCalls).toEqual([1]); + expect(errors.join('\n')).toContain( + ' • jobs uses the Cloudflare queue app-jobs, which is also a queue in queues/', + ); + expect(queueInfoArgs(wranglerArgs)).toEqual([]); + expect(trace).not.toContain('build'); + }); + + it('refuses a queue that another Worker consumes, before the build', async () => { + // Worker `app` with queues/staging-jobs.ts and Worker `app-staging` with queues/jobs.ts + // both name a queue `app-staging-jobs`. + const root = queueProject('app-staging'); + const { deps, trace, exitCalls } = queueHarness((name) => + name === 'app-staging-jobs' ? queueExists(name, 'app') : queueAbsent(name), + ); + const { errors } = await runCapturing(root, deps, { fails: true }); + expect(exitCalls).toEqual([1]); + expect(errors.join('\n')).toContain('app-staging-jobs is consumed by Worker app'); + expect(trace).not.toContain('provision'); + expect(trace).not.toContain('build'); + }); + + it('refuses a queue that has a non-Worker consumer', async () => { + const root = queueProject(); + const { deps, trace } = queueHarness((name) => + name === 'qapp-jobs' ? queueExists(name, '-') : queueAbsent(name), + ); + const { errors } = await runCapturing(root, deps, { fails: true }); + expect(errors.join('\n')).toContain('qapp-jobs is consumed by a non-Worker (HTTP pull)'); + expect(trace).not.toContain('build'); + }); + + it('refuses a Cloudflare queue name longer than 63 characters without shortening it', async () => { + const worker = 'w'.repeat(59); + const root = queueProject(worker); + const { deps, wranglerArgs, trace } = queueHarness(queueAbsent); + const { errors } = await runCapturing(root, deps, { fails: true }); + expect(errors.join('\n')).toContain(` • ${worker}-jobs (64 characters)`); + // The scoped name is never read; the plain name is, in case an older deploy used it. + expect(queueInfoArgs(wranglerArgs)).toEqual(['jobs']); + expect(trace).not.toContain('provision'); + expect(trace).not.toContain('build'); }); - it('proceeds when the probe fails for a reason other than the queue being absent', async () => { + it('stops before the build when a queue cannot be read', async () => { const root = queueProject(); - // An auth failure is NOT absence — blocking here would be a false positive, and - // `wrangler deploy` still rejects a genuinely missing queue before uploading. - const { deps, trace } = queueHarness(() => ({ + // An auth failure is not absence. Without the consumer list the name cannot be chosen. + const { deps, trace, exitCalls } = queueHarness(() => ({ ...cleanExit, status: 1, stderr: '✘ [ERROR] A request to the Cloudflare API failed [code: 10000]\n', })); - await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); - expect(trace).toContain('build'); - expect(trace).toContain('wrangler:deploy'); + const { errors } = await runCapturing(root, deps, { fails: true }); + expect(exitCalls).toEqual([1]); + expect(errors.join('\n')).toContain('could not read Cloudflare queue qapp-jobs'); + expect(trace).not.toContain('provision'); + expect(trace).not.toContain('build'); }); - it('proceeds when a killed probe merely buffered a not-found line', async () => { + it('stops when a killed probe merely buffered a not-found line', async () => { const root = queueProject(); // A signal-killed child never ran to completion, so its buffered text is not proof. const { deps, trace } = queueHarness((name) => ({ - ...cleanExit, - status: 1, + ...queueAbsent(name), signal: 'SIGKILL', - stderr: `✘ [ERROR] Queue "${name}" does not exist.\n`, })); - await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); - expect(trace).toContain('build'); - expect(trace).toContain('wrangler:deploy'); + await runCapturing(root, deps, { fails: true }); + expect(trace).not.toContain('provision'); + expect(trace).not.toContain('build'); }); - it('never probes when the app desires no queues', async () => { + it('never reads queues when the app desires none', async () => { const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); const { deps, wranglerArgs } = harness(); + let builtQueueNames: string | undefined = 'UNSET'; + deps.runBuild = (env) => { + builtQueueNames = env.VOID_CLOUDFLARE_QUEUE_NAMES; + }; await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); expect(wranglerArgs.some((a) => a[0] === 'queues')).toBe(false); + expect(builtQueueNames).toBeUndefined(); }); - it('never probes under --provision (the provisioner creates the queue itself)', async () => { + it('drops an ambient queue name map from the build', async () => { + vi.stubEnv('VOID_CLOUDFLARE_QUEUE_NAMES', '{"jobs":"elsewhere"}'); + const root = d1Project(PROVISIONED_D1, INIT_MIGRATION); + const { deps } = harness(); + let builtQueueNames: string | undefined = 'UNSET'; + deps.runBuild = (env) => { + builtQueueNames = env.VOID_CLOUDFLARE_QUEUE_NAMES; + }; + await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); + expect(builtQueueNames).toBeUndefined(); + }); + + it('reads queues under --provision too, so it creates the chosen name', async () => { const root = queueProject(); - const { deps, wranglerArgs, trace } = queueHarness(() => ({ - ...cleanExit, - status: 1, - stderr: '✘ [ERROR] Queue "jobs" does not exist.\n', - })); - await runCloudflareDeploy( - root, - { platform: 'cloudflare', provision: true }, - silentCliLog, - deps, + const { deps, wranglerArgs, trace, builtQueueNames } = queueHarness((name) => + name === 'jobs' ? queueExists(name, 'qapp') : queueAbsent(name), ); + await runCapturing(root, deps, { provision: true }); + expect(queueInfoArgs(wranglerArgs)).toEqual(['qapp-jobs', 'jobs']); expect(trace).toContain('provision'); - expect(wranglerArgs.some((a) => a[0] === 'queues')).toBe(false); + expect(builtQueueNames).toEqual([{ jobs: 'jobs' }]); expect(trace).toContain('wrangler:deploy'); }); }); @@ -4068,7 +4226,7 @@ describe('runCloudflareDeploy — pre-build queue name validation', () => { expect(wranglerArgs.find((a) => a[0] === 'queues')).toEqual([ 'queues', 'info', - 'jobs', + 'qapp-jobs', '-c', toSlash(join(root, 'wrangler.jsonc')), '--env=', @@ -4085,7 +4243,7 @@ describe('runCloudflareDeploy — pre-build queue name validation', () => { expect(wranglerArgs.find((args) => args[0] === 'queues')).toEqual([ 'queues', 'info', - 'order-notifications', + 'qapp-order-notifications', '-c', toSlash(join(root, 'wrangler.jsonc')), '--env=', @@ -6051,7 +6209,8 @@ describe('runCloudflareDeploy — id-bearing binding identity pin', () => { it('passes a normal deploy whose artifact carries supported non-id bindings', async () => { // The pinned DB id is unchanged and the artifact carries the non-id bindings this source emits. - // `queues/jobs.ts` makes `jobs` a derived queue, and `routes/ai.ts` authorizes the AI binding. + // `queues/jobs.ts` makes `myapp-jobs` a derived queue, and `routes/ai.ts` authorizes the AI + // binding. const root = d1Project(PROVISIONED_D1, { ...INIT_MIGRATION, 'queues/jobs.ts': 'export default {};', @@ -6063,6 +6222,10 @@ describe('runCloudflareDeploy — id-bearing binding identity pin', () => { // No sandbox, Durable Objects or crons in this source, so drop those fixture fields. writeEmittedDeployArtifact(root, { ...KITCHEN_SINK_NON_ID_BINDINGS, + queues: { + producers: [{ binding: 'QUEUE_JOBS', queue: 'myapp-jobs' }], + consumers: [{ queue: 'myapp-jobs', max_batch_size: 10 }], + }, durable_objects: undefined, migrations: undefined, containers: undefined, @@ -6559,10 +6722,10 @@ describe('runCloudflareDeploy — framework artifact guard', () => { expect(parseRootWranglerConfig(join(root, 'wrangler.jsonc'))).toMatchObject({ queues: { - producers: [{ binding: 'QUEUE_EMAIL', queue: 'email' }], + producers: [{ binding: 'QUEUE_EMAIL', queue: 'skapp-email' }], consumers: [ { - queue: 'email', + queue: 'skapp-email', max_batch_size: 25, max_batch_timeout: 10, max_retries: 4, @@ -6573,6 +6736,26 @@ describe('runCloudflareDeploy — framework artifact guard', () => { }); }); + it('gives the framework wrapper the queue names it binds', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ + name: 'skapp', + dependencies: { '@sveltejs/kit': '2.0.0' }, + }), + 'void.json': JSON.stringify({}), + 'queues/email.ts': 'export default async function email() {}', + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'skapp' }, null, 2), + }); + const wrapperQueueNames: Array = []; + const { deps } = harness({ + prepareFrameworkArtifact: async (options) => { + wrapperQueueNames.push(options.cloudflareQueueNames); + }, + }); + await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); + expect(wrapperQueueNames).toEqual([{ email: 'skapp-email' }]); + }); + it('carries removed framework consumers through activation and acknowledges cleanup', async () => { const root = makeProject({ 'package.json': JSON.stringify({ @@ -6610,11 +6793,11 @@ describe('runCloudflareDeploy — framework artifact guard', () => { runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps), ).rejects.toBeInstanceOf(ExitError); expect(readWranglerManagedQueueCleanupCandidates(join(root, 'wrangler.jsonc'))).toEqual([ - 'email', + 'skapp-email', ]); await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); - expect(cleanupSnapshots).toEqual([['email'], ['email'], ['email']]); + expect(cleanupSnapshots).toEqual([['skapp-email'], ['skapp-email'], ['skapp-email']]); expect(readWranglerManagedQueueCleanupCandidates(join(root, 'wrangler.jsonc'))).toEqual([]); }); diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index e2a222be0..ca6f68f04 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -555,6 +555,82 @@ it('finds existing Queues beyond the first cf API page', async () => { expect(requestedPages).toEqual([1, 2]); }, 30_000); +it.each([ + { + label: 'Worker and HTTP pull consumers', + queue: { + consumers: [ + { type: 'worker', script_name: 'app-production', consumer_id: 'one' }, + { type: 'http_pull', consumer_id: 'two' }, + ], + consumers_total_count: 2, + }, + consumers: ['app-production', null], + }, + { label: 'no consumer', queue: { consumers: [], consumers_total_count: 0 }, consumers: [] }, + { + label: 'an incomplete consumer list', + queue: { consumers: [], consumers_total_count: 1 }, + consumers: undefined, + }, + { label: 'no consumer list', queue: {}, consumers: undefined }, + { + label: 'a legacy script field', + queue: { consumers: [{ type: 'worker', script: 'app' }], consumers_total_count: 1 }, + consumers: ['app'], + }, + { + label: 'a Worker consumer without a name', + queue: { consumers: [{ type: 'worker', script_name: '' }], consumers_total_count: 1 }, + consumers: undefined, + }, + { + label: 'two different Worker names on one consumer', + queue: { + consumers: [{ type: 'worker', script_name: 'app', script: 'other' }], + consumers_total_count: 1, + }, + consumers: undefined, + }, + { + label: 'a consumer of an unknown type', + queue: { consumers: [{ type: 'other' }], consumers_total_count: 1 }, + consumers: undefined, + }, +])( + 'reports a queue with $label', + async ({ queue, consumers }) => { + const directory = temporaryDirectory('void-cf-queue-consumers-'); + const api = await listen( + createServer((_request, response) => { + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + success: true, + result: [{ queue_id: 'queue-id', queue_name: 'jobs', ...queue }], + result_info: { page: 1, per_page: 1, total_pages: 1, total_count: 1 }, + }), + ); + }), + ); + const configPath = join(directory, 'wrangler.json'); + writeFileSync(configPath, JSON.stringify({ account_id: 'test-account' })); + const output = await runOperation( + directory, + { type: 'queues.info', name: 'jobs', configPath }, + tokenEnv(api, 'CF_API_TOKEN'), + ); + expect(output.status, output.stderr).toBe(0); + // Without a complete list the field is left out; only queue naming needs it. + expect(JSON.parse(output.stdout).data).toEqual({ + id: 'queue-id', + name: 'jobs', + ...(consumers ? { consumers } : {}), + }); + }, + 30_000, +); + it.each([ { label: 'Cloudflare script_name', diff --git a/packages/void/test/unit/queue-cloudflare-names.test.ts b/packages/void/test/unit/queue-cloudflare-names.test.ts new file mode 100644 index 000000000..fad6fed16 --- /dev/null +++ b/packages/void/test/unit/queue-cloudflare-names.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + chooseCloudflareQueueName, + cloudflareQueueName, + otherQueueConsumers, + readDeployBuildQueueNames, + scopedCloudflareQueueName, +} from '../../src/queues/cloudflare-names.ts'; +import { generateQueueResolverSource } from '../../src/queues/dispatch.ts'; + +/** Run the emitted resolver source and return its lookup function. */ +function resolver( + queueNames: Array, + cloudflareNames?: Array, +): (queueName: unknown) => number { + return new Function( + `${generateQueueResolverSource(queueNames, cloudflareNames)}\nreturn __resolveQueueIndex;`, + )() as (queueName: unknown) => number; +} + +describe('Cloudflare queue names', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('names a queue after its Worker without changing either part', () => { + expect(scopedCloudflareQueueName('app-staging', 'jobs')).toBe('app-staging-jobs'); + expect(scopedCloudflareQueueName('app', 'Order-Emails')).toBe('app-Order-Emails'); + }); + + it.each([ + ['a new account', null, null, 'app-jobs', false], + ['a scoped queue this Worker consumes', { consumers: ['app'] }, undefined, 'app-jobs', false], + ['a plain queue this Worker consumes', null, { consumers: ['app'] }, 'jobs', true], + ['a plain queue another Worker consumes', null, { consumers: ['other'] }, 'app-jobs', false], + ['a plain queue with no consumer', null, { consumers: [] }, 'app-jobs', false], + ['an unread plain queue', { consumers: [] }, undefined, 'app-jobs', false], + ])('picks the name for %s', (_case, scoped, plain, name, kept) => { + expect( + chooseCloudflareQueueName({ + workerName: 'app', + queueName: 'jobs', + scopedName: 'app-jobs', + scoped, + plain, + }), + ).toEqual({ name, kept }); + }); + + it('prefers the scoped queue when this Worker consumes both', () => { + expect( + chooseCloudflareQueueName({ + workerName: 'app', + queueName: 'jobs', + scopedName: 'app-jobs', + scoped: { consumers: ['app'] }, + plain: { consumers: ['app'] }, + }), + ).toEqual({ name: 'app-jobs', kept: false }); + }); + + it('lists the consumers that are not this Worker', () => { + expect(otherQueueConsumers(null, 'app')).toEqual([]); + expect(otherQueueConsumers({ consumers: ['app'] }, 'app')).toEqual([]); + expect(otherQueueConsumers({ consumers: ['other', null] }, 'app')).toEqual(['other', null]); + }); + + it('reads the queue name map only inside a deploy build', () => { + vi.stubEnv('VOID_CLOUDFLARE_QUEUE_NAMES', '{"jobs":"app-jobs"}'); + vi.stubEnv('VOID_CLOUDFLARE_DEPLOY_BUILD', ''); + expect(readDeployBuildQueueNames()).toBeUndefined(); + vi.stubEnv('VOID_CLOUDFLARE_DEPLOY_BUILD', '1'); + expect(readDeployBuildQueueNames()).toEqual({ jobs: 'app-jobs' }); + }); + + it.each(['not json', '[]', 'null', '{"jobs":1}', '{"jobs":""}'])( + 'rejects the queue name map %s', + (raw) => { + vi.stubEnv('VOID_CLOUDFLARE_DEPLOY_BUILD', '1'); + vi.stubEnv('VOID_CLOUDFLARE_QUEUE_NAMES', raw); + expect(() => readDeployBuildQueueNames()).toThrow( + 'queue: VOID_CLOUDFLARE_QUEUE_NAMES is not a valid queue name map.', + ); + }, + ); + + it('falls back to the plain name outside a deploy', () => { + expect(cloudflareQueueName('jobs', undefined)).toBe('jobs'); + expect(cloudflareQueueName('jobs', { jobs: 'app-jobs' })).toBe('app-jobs'); + expect(cloudflareQueueName('constructor', {})).toBe('constructor'); + }); +}); + +describe('queue resolver with Cloudflare names', () => { + it('matches each Cloudflare name exactly where a suffix would pick the wrong queue', () => { + // Worker `app-staging` with queues `jobs` and `staging-jobs`. + const names = ['jobs', 'staging-jobs']; + const scoped = ['app-staging-jobs', 'app-staging-staging-jobs']; + expect(resolver(names)('app-staging-jobs')).toBe(1); + const exact = resolver(names, scoped); + expect(exact('app-staging-jobs')).toBe(0); + expect(exact('app-staging-staging-jobs')).toBe(1); + }); + + it('prefers a Cloudflare name over an equal plain name', () => { + // Worker `x` with queues `jobs` and `x-jobs`: `x-jobs` is the Cloudflare name of `jobs`. + const exact = resolver(['jobs', 'x-jobs'], ['x-jobs', 'x-x-jobs']); + expect(exact('x-jobs')).toBe(0); + expect(exact('x-x-jobs')).toBe(1); + }); + + it('still matches plain names, which the trigger endpoint sends', () => { + const exact = resolver(['jobs', 'emails'], ['jobs', 'app-emails']); + expect(exact('jobs')).toBe(0); + expect(exact('emails')).toBe(1); + expect(exact('app-emails')).toBe(1); + }); + + it('fails closed on any other name', () => { + const exact = resolver(['jobs'], ['app-jobs']); + for (const name of ['other-app-jobs', 'app-jobs-2', 'toString', '__proto__', '', 1]) { + expect(exact(name)).toBe(-1); + } + }); +}); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 9d6408b51..22b706be6 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1077,6 +1077,54 @@ describe('writeProvisionedWranglerConfig', () => { ]); }); + it('moves a framework queue Void wrote earlier to its new Cloudflare name', () => { + setup('wrangler.jsonc', '{}'); + writeProvisionedWranglerConfig(tmpDir, { + queues: { + producers: [{ binding: 'QUEUE_JOBS', queue: 'jobs' }], + consumers: [{ queue: 'jobs' }], + reconcileVoidOwned: true, + }, + }); + + writeProvisionedWranglerConfig(tmpDir, { + queues: { + producers: [{ binding: 'QUEUE_JOBS', queue: 'app-jobs' }], + consumers: [{ queue: 'app-jobs' }], + reconcileVoidOwned: true, + }, + }); + + expect(parseRaw('wrangler.jsonc').queues).toEqual({ + producers: [{ binding: 'QUEUE_JOBS', queue: 'app-jobs' }], + consumers: [{ queue: 'app-jobs' }], + }); + // The old consumer is detached on the next activation. + expect(readWranglerManagedQueueCleanupCandidates(join(tmpDir, 'wrangler.jsonc'))).toEqual([ + 'app-jobs', + 'jobs', + ]); + }); + + it('still refuses to move a producer binding the user wrote', () => { + setup( + 'wrangler.jsonc', + JSON.stringify({ queues: { producers: [{ binding: 'QUEUE_JOBS', queue: 'jobs' }] } }), + ); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { + producers: [{ binding: 'QUEUE_JOBS', queue: 'app-jobs' }], + consumers: [{ queue: 'app-jobs' }], + reconcileVoidOwned: true, + }, + }), + ).toThrow('is already bound to queue "jobs"'); + expect(parseRaw('wrangler.jsonc').queues).toEqual({ + producers: [{ binding: 'QUEUE_JOBS', queue: 'jobs' }], + }); + }); + it('cancels a pending consumer removal when the queue is reintroduced before deploy', () => { setup('wrangler.jsonc', '{}'); const queues = { diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 8e51c63cb..c222712b5 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -536,6 +536,58 @@ describe('generated wrapper queue dispatch', () => { expect(env.calls).toEqual([]); }); + it('matches the Cloudflare queue names a deploy chose exactly', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-wrapper-queue-names-')); + const workerDir = join(root, 'server'); + roots.push(root); + mkdirSync(workerDir, { recursive: true }); + mkdirSync(join(root, 'queues'), { recursive: true }); + writeFileSync( + join(workerDir, 'index.mjs'), + 'export default { fetch() { return new Response("ok"); } };', + ); + for (const name of ['jobs', 'staging-jobs']) { + writeFileSync( + join(root, `queues/${name}.ts`), + `export default async function(_batch, env) { env.calls.push("${name}"); }`, + ); + } + + // Worker `app-staging`: a suffix match would send `app-staging-jobs` to `staging-jobs`. + await generateWrapper(workerDir, 'index.mjs', root, { + ...base, + queues: [ + { name: 'jobs', filePath: 'jobs.ts', bindingName: 'QUEUE_JOBS' }, + { name: 'staging-jobs', filePath: 'staging-jobs.ts', bindingName: 'QUEUE_STAGING_JOBS' }, + ], + cloudflareQueueNames: { + jobs: 'app-staging-jobs', + 'staging-jobs': 'app-staging-staging-jobs', + }, + }); + + const wrapper = (await import( + `${pathToFileURL(join(workerDir, 'index.mjs')).href}?test=${crypto.randomUUID()}` + )) as { + default: { + queue( + batch: MessageBatch, + env: { calls: Array }, + ctx: ExecutionContext, + ): Promise; + }; + }; + for (const [queue, expected] of [ + ['app-staging-jobs', 'jobs'], + ['app-staging-staging-jobs', 'staging-jobs'], + ]) { + const env = { calls: [] as Array }; + const batch = { queue, messages: [], ackAll: vi.fn(), retryAll: vi.fn() }; + await wrapper.default.queue(batch as unknown as MessageBatch, env, {} as ExecutionContext); + expect(env.calls).toEqual([expected]); + } + }); + it('passes native queue messages, dates, and acknowledgment methods through unchanged', async () => { const root = mkdtempSync(join(tmpdir(), 'void-native-queue-')); roots.push(root);