From 19b0c541f3fc47a81a79e41dff57ebd33130966e Mon Sep 17 00:00:00 2001 From: Shinya Fujino Date: Fri, 6 Nov 2026 02:44:38 +0900 Subject: [PATCH] fix(deploy): read server values from void/env in cron and queue jobs The deploy wrapper's cron and queue bundle is a library build, which defaults to browser conditions. `void/env` resolved to its client variant there, so a deployed job that read a server key threw "is not exposed to the client". The same job worked in `vp dev`, and `/__void/ready` still returned 200, so the failure only showed up after a deploy. The bundle now uses Worker conditions, as `bundleEntry` does. Dependencies with `workerd` or `worker` exports also get their Worker builds in jobs. Co-Authored-By: Claude Opus 5.5 --- packages/void/src/cli/wrapper.ts | 3 ++ packages/void/test/unit/wrapper.test.ts | 60 ++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index f23a5cc99..e2a811951 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -141,6 +141,9 @@ export async function generateWrapper( }), ], resolve: { + // Like bundleEntry: Worker conditions plus the build mode. A library build defaults to + // browser conditions, where `void/env` is the client variant that throws on a server key. + conditions: [...WORKER_CONDITIONS, 'production'], // Cron/queue scaffolds intentionally import their lightweight runtime helpers from the // public `void` entry. This focused build does not run voidPlugin(), so reproduce its // exact bare-import redirect here; otherwise Vite follows the plugin/CLI entry and pulls diff --git a/packages/void/test/unit/wrapper.test.ts b/packages/void/test/unit/wrapper.test.ts index 01301d6f5..6e4976902 100644 --- a/packages/void/test/unit/wrapper.test.ts +++ b/packages/void/test/unit/wrapper.test.ts @@ -759,6 +759,7 @@ describe('generateWrapper with migrations', { timeout: migrationTestTimeout }, ( bindings?: Record; d1Databases?: Array; compatibilityDate?: string; + compatibilityFlags?: Array; } = {}, ): Promise { // Upload every module, as the platform does; the wrapper imports the handler statically. @@ -773,7 +774,7 @@ describe('generateWrapper with migrations', { timeout: migrationTestTimeout }, ( modulesRoot: workerDir, modules, compatibilityDate: options.compatibilityDate ?? '2026-02-24', - compatibilityFlags: ['nodejs_compat'], + compatibilityFlags: options.compatibilityFlags ?? ['nodejs_compat'], bindings: { __VOID_PROXY_TOKEN: 'proxy-token', ...options.bindings }, d1Databases: options.d1Databases, }), @@ -1162,6 +1163,63 @@ export default async function () { expect(handlers).not.toContain('withRuntimeEnv'); }); + // A D1 framework Worker has only `nodejs_als`; PostgreSQL deploys add `nodejs_compat`, and run + // jobs in a request scope. Under browser conditions, reading a server key from `void/env` throws. + it.each([ + { database: 'D1', config: {}, compatibilityFlags: ['nodejs_als'] }, + { database: 'PostgreSQL', config: { database: 'pg' }, compatibilityFlags: ['nodejs_compat'] }, + ])( + 'gives $database cron and queue jobs server values from void/env in workerd', + async ({ config, compatibilityFlags }) => { + const { workerDir, projectRoot } = createMigrationFixture(); + writeFileSync(join(projectRoot, 'void.json'), JSON.stringify(config)); + const job = `import { env } from 'void/env'; + +export default async function () { + if (env.STRIPE_KEY !== 'sk_test') throw new Error('unexpected STRIPE_KEY'); +} +`; + mkdirSync(join(projectRoot, 'crons')); + writeFileSync(join(projectRoot, 'crons', 'read-env.ts'), job); + mkdirSync(join(projectRoot, 'queues')); + writeFileSync(join(projectRoot, 'queues', 'read-env.ts'), job); + mkdirSync(join(projectRoot, 'node_modules')); + symlinkSync( + join(import.meta.dirname, '..', '..'), + join(projectRoot, 'node_modules', 'void'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + + await generateWrapper(workerDir, 'index.mjs', projectRoot, { + ...base, + crons: [{ name: 'read-env', filePath: 'read-env.ts', crons: ['0 * * * *'] }], + queues: [{ name: 'read-env', filePath: 'read-env.ts', bindingName: 'QUEUE_READ_ENV' }], + }); + + const options = { bindings: { STRIPE_KEY: 'sk_test' }, compatibilityFlags }; + expect( + await fetchWrappedWorker( + workerDir, + { path: '/__void/scheduled', body: { cron: '0 * * * *', scheduledTime: Date.now() } }, + options, + ), + ).toEqual({ ok: true }); + expect( + await fetchWrappedWorker( + workerDir, + { + path: '/__void/queue', + body: { + queue: 'read-env', + messages: [{ id: 'one', timestamp: new Date().toISOString(), body: {}, attempts: 1 }], + }, + }, + options, + ), + ).toMatchObject({ ok: true }); + }, + ); + it('does not generate migration handler when hasMigrations is false', async () => { const { workerDir, projectRoot } = createMigrationFixture(); mkdirSync(join(projectRoot, 'crons'), { recursive: true });