From 0fa621b86e4dbd87aa864bf2b464d5c042ac4833 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Fri, 11 Sep 2026 02:06:03 +0200 Subject: [PATCH 1/2] fix(void): normalize nested Cloudflare queue names --- docs/guide/queues.md | 8 ++--- packages/void/src/cli/deploy.ts | 11 ++++--- packages/void/src/queues/scan.ts | 34 ++++++++++++++++++--- packages/void/test/unit/queue-scan.test.ts | 30 ++++++++++++++++++ packages/void/test/unit/queue-types.test.ts | 4 +-- packages/void/test/unit/scan.test.ts | 16 +++++++++- 6 files changed, 86 insertions(+), 17 deletions(-) diff --git a/docs/guide/queues.md b/docs/guide/queues.md index 11263acea..608449c9f 100644 --- a/docs/guide/queues.md +++ b/docs/guide/queues.md @@ -8,11 +8,9 @@ Use queues to process work asynchronously, such as sending emails or handling up ## Defining queues -Create a file such as `queues/emails.ts` or `queues/order-notifications.ts`. The filename becomes the queue name. `.mts`, `.js`, and `.mjs` files are also supported. +Create files in `queues/**/*.ts`; `.mts`, `.js`, and `.mjs` also work. The queue name is inferred from its path, with nested path segments joined by `-`. For example, `queues/emails.ts` creates a queue named `"emails"`, and `queues/order/notifications.ts` creates `"order-notifications"`. A nested path must not normalize to the same name as another file: `queues/order/notifications.ts` and `queues/order-notifications.ts` conflict, so Void reports the collision and asks you to rename one. -::: warning Keep deployable queue files in one directory -Nested paths become part of the queue name: `queues/order/notifications.ts` produces `"order/notifications"`. That works locally, but Cloudflare rejects `/` in queue names. Use `queues/order-notifications.ts` for a queue you intend to deploy. -::: +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. Each queue file should export a default handler wrapped with [`defineQueue`](../reference/api.md#definequeue-t-handler). The generic `` parameter defines the message body type. That is the type of each `msg.body` in the batch, and it is also used by the typed `queues` proxy for `send()` calls. @@ -56,7 +54,7 @@ export const POST = defineHandler(async (c) => { }); ``` -The binding name is derived automatically: `QUEUE_` + queue name uppercased with non-alphanumeric characters replaced by `_`. For example, `queues/emails.ts` creates binding `QUEUE_EMAILS`. +The binding name is derived automatically: `QUEUE_` + queue name uppercased with non-alphanumeric characters replaced by `_`. For example, `queues/emails.ts` creates binding `QUEUE_EMAILS`, while `queues/order/notifications.ts` creates binding `QUEUE_ORDER_NOTIFICATIONS`. ## Per-message acknowledgment diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 524fe25d7..901d97363 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -4274,10 +4274,11 @@ export async function runCloudflareDeploy( if (options.provision || initiallyMissing.length > 0 || d1NeedsConfigReconciliation) { await provisionAndVerify(); } else { - // Queue names are taken VERBATIM from filenames (`queues/.ts` → ``, see - // `queues/scan.ts`) with no sanitization, and the probe below passes each one to wrangler - // as an ARGV positional. Gate them on the SAME `assertValidResourceName('queue', …)` the - // provisioner applies, BEFORE the probe runs: a name beginning with `-` is + // Queue names come from file paths (`queues/order/jobs.ts` → `order-jobs`, see + // `queues/scan.ts`), and the probe below passes each one to wrangler as an ARGV positional. + // Apart from joining path segments, names are not sanitized. Gate them on the SAME + // `assertValidResourceName('queue', …)` the provisioner applies, BEFORE the probe runs: + // a name beginning with `-` is // parsed by wrangler as a FLAG (`queues info --help` exits 0 printing usage), which the // probe would classify as inconclusive and wave through — silently forfeiting the // early-ordering guarantee this whole step exists to provide. Collect EVERY offending name @@ -4296,7 +4297,7 @@ export async function runCloudflareDeploy( 'Deploy blocked — these queue names are not valid Cloudflare queue names:', ...invalidQueues.map((name) => ` • ${name} (queue)`), '', - 'A queue takes its name from its filename in queues/ (queues/.ts → "").', + '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.', '', diff --git a/packages/void/src/queues/scan.ts b/packages/void/src/queues/scan.ts index be13aa2d7..bdf46937d 100644 --- a/packages/void/src/queues/scan.ts +++ b/packages/void/src/queues/scan.ts @@ -7,7 +7,7 @@ const QUEUE_EXTENSIONS = ['ts', 'mts', 'js', 'mjs']; export type QueueDefinition = { /** Relative file path from queues/ */ filePath: string; - /** Deterministic name (path without extension) */ + /** Deterministic name (path without extension, with directories joined by `-`) */ name: string; /** Binding name: QUEUE_ + uppercased name */ bindingName: string; @@ -22,7 +22,7 @@ export type QueueDefinition = { }; function extractName(filePath: string): string { - return filePath.replace(/\.[^.]+$/, ''); + return filePath.replace(/\.[^.]+$/, '').replaceAll('/', '-'); } export function deriveQueueBindingName(name: string): string { @@ -41,9 +41,9 @@ function parseQueueFiles( files: Array, ): Array { const queuesDirPath = isAbsolute(queuesDir) ? queuesDir : join(root, queuesDir); - return files + const definitions = files .filter((filePath) => !filePath.split('/').some((segment) => segment.startsWith('_'))) - .sort((leftFilePath, rightFilePath) => leftFilePath.localeCompare(rightFilePath)) + .sort() .map((filePath) => { const absPath = join(queuesDirPath, filePath); const source = readFileSync(absPath, 'utf-8'); @@ -58,6 +58,32 @@ function parseQueueFiles( retryDelay: extractConsumerOption(source, 'retryDelay'), }; }); + + const filesByName = new Map>(); + for (const definition of definitions) { + const matchingFiles = filesByName.get(definition.name) ?? []; + matchingFiles.push(definition.filePath); + filesByName.set(definition.name, matchingFiles); + } + + const collisions = [...filesByName] + .filter(([, matchingFiles]) => matchingFiles.length > 1) + .sort(([leftName], [rightName]) => (leftName < rightName ? -1 : leftName > rightName ? 1 : 0)) + .map( + ([name, matchingFiles]) => + ` ${JSON.stringify(name)}: ${matchingFiles.map((file) => JSON.stringify(file)).join(', ')}`, + ); + if (collisions.length > 0) { + throw new Error( + [ + 'queue: Multiple queue files resolve to the same queue name:', + ...collisions, + 'Rename the conflicting files so each queue has a unique name.', + ].join('\n'), + ); + } + + return definitions; } export async function scanQueues( diff --git a/packages/void/test/unit/queue-scan.test.ts b/packages/void/test/unit/queue-scan.test.ts index 93b0350ee..0a1f855e5 100644 --- a/packages/void/test/unit/queue-scan.test.ts +++ b/packages/void/test/unit/queue-scan.test.ts @@ -62,4 +62,34 @@ export default defineQueue(async (batch, env) => {});`, expect(queues[0].maxRetries).toBe(3); expect(queues[0].retryDelay).toBe(60); }); + + it('normalizes nested paths while preserving binding derivation', () => { + mkdirSync(join(root, 'queues', 'order'), { recursive: true }); + writeFileSync( + join(root, 'queues', 'order', 'notifications.ts'), + `export default async () => {};`, + ); + + expect(scanQueuesSync(root)).toEqual([ + { + filePath: 'order/notifications.ts', + name: 'order-notifications', + bindingName: 'QUEUE_ORDER_NOTIFICATIONS', + }, + ]); + }); + + it('reports normalized name collisions in deterministic path order', () => { + mkdirSync(join(root, 'queues', 'order'), { recursive: true }); + writeFileSync(join(root, 'queues', 'order-notifications.ts'), 'export default {};'); + writeFileSync(join(root, 'queues', 'order', 'notifications.ts'), 'export default {};'); + + expect(() => scanQueuesSync(root)).toThrow( + [ + 'queue: Multiple queue files resolve to the same queue name:', + ' "order-notifications": "order-notifications.ts", "order/notifications.ts"', + 'Rename the conflicting files so each queue has a unique name.', + ].join('\n'), + ); + }); }); diff --git a/packages/void/test/unit/queue-types.test.ts b/packages/void/test/unit/queue-types.test.ts index f4bf88816..90b611aff 100644 --- a/packages/void/test/unit/queue-types.test.ts +++ b/packages/void/test/unit/queue-types.test.ts @@ -19,14 +19,14 @@ describe('generateQueueTypes', () => { { filePath: 'emails.ts', name: 'emails', bindingName: 'QUEUE_EMAILS' }, { filePath: 'order/notifications.ts', - name: 'order/notifications', + name: 'order-notifications', bindingName: 'QUEUE_ORDER_NOTIFICATIONS', }, ]; const result = generateQueueTypes(queues, '/project/queues', '/project/.void'); expect(result).toContain('"emails": Queue<'); - expect(result).toContain('"order/notifications": Queue<'); + expect(result).toContain('"order-notifications": Queue<'); expect(result).toContain('import("../queues/order/notifications.ts")'); }); diff --git a/packages/void/test/unit/scan.test.ts b/packages/void/test/unit/scan.test.ts index 577b3bead..aae0e2452 100644 --- a/packages/void/test/unit/scan.test.ts +++ b/packages/void/test/unit/scan.test.ts @@ -192,10 +192,24 @@ describe('scanRoutes', () => { const result = await scanRoutes(TMP); expect(result.queues).toHaveLength(1); - expect(result.queues[0].name).toBe('order/notifications'); + expect(result.queues[0].name).toBe('order-notifications'); expect(result.queues[0].bindingName).toBe('QUEUE_ORDER_NOTIFICATIONS'); }); + it('rejects nested queue names that collide after normalization', async () => { + createFile('routes/api/hello.ts'); + createFile('queues/order-notifications.ts', 'export default async () => {}'); + createFile('queues/order/notifications.ts', 'export default async () => {}'); + + await expect(scanRoutes(TMP)).rejects.toThrow( + [ + 'queue: Multiple queue files resolve to the same queue name:', + ' "order-notifications": "order-notifications.ts", "order/notifications.ts"', + 'Rename the conflicting files so each queue has a unique name.', + ].join('\n'), + ); + }); + it('extracts maxBatchSize and maxBatchTimeout', async () => { createFile('routes/api/hello.ts'); createFile( From 7ea0e185bd27be8ba77822b28a18d861cd29af7d Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Fri, 11 Sep 2026 02:08:37 +0200 Subject: [PATCH 2/2] test(void): cover normalized queue deploy names --- packages/void/AGENTS.md | 2 +- .../void/test/unit/cloudflare-deploy.test.ts | 21 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index e1f899e21..77404338a 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -56,7 +56,7 @@ src/ db-types.ts # (removed — Drizzle schema is now the type source) queue-types.ts # Generates .void/queues.d.ts (QueueMap with payload types) queues/ - scan.ts # Queue file scanner (queues/ dir, name from filename + consumer options) + scan.ts # Queue file scanner (name from relative path, nested segments joined by `-`, consumer options) runtime/ handler.ts # defineHandler(), defineHandler.withValidator(), defineMiddleware(), defineHead(), defineQueue() queues.ts # QueueMap stub interface + typed `queues` proxy using env.QUEUE_* diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index aa766cc7f..98ada77cf 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -2661,8 +2661,8 @@ describe('runCloudflareDeploy — pre-build queue existence probe', () => { describe('runCloudflareDeploy — pre-build queue name validation', () => { /** * A worker-bearing app whose desired queues come from the given `queues/` filenames. - * Queue names are the filenames verbatim (`queues/scan.ts` strips only the extension), - * so this fixture is how an adversarial name actually reaches the deploy. + * Queue names are derived from those paths (`queues/scan.ts` strips the extension and joins + * nested segments with `-`), so this fixture is how an adversarial name actually reaches deploy. */ function queueNameProject(...fileNames: Array): string { return makeProject({ @@ -2763,6 +2763,23 @@ describe('runCloudflareDeploy — pre-build queue name validation', () => { expect(trace.indexOf('wrangler:queues')).toBeLessThan(trace.indexOf('build')); expect(trace).toContain('wrangler:deploy'); }); + + it('probes a nested queue using its normalized Cloudflare resource name', async () => { + const root = queueNameProject('order/notifications.ts'); + const { deps, wranglerArgs, trace } = harness(); + await runCloudflareDeploy(root, { backend: 'cloudflare' }, silentCliLog, deps); + + expect(wranglerArgs.find((args) => args[0] === 'queues')).toEqual([ + 'queues', + 'info', + 'order-notifications', + '-c', + join(root, 'wrangler.jsonc'), + '--env=', + ]); + expect(trace.indexOf('wrangler:queues')).toBeLessThan(trace.indexOf('build')); + expect(trace).toContain('wrangler:deploy'); + }); }); describe('runCloudflareDeploy — skipped desired resource is fatal', () => {