diff --git a/.github/scripts/packed-consumer.mjs b/.github/scripts/packed-consumer.mjs index bd10bc362..2cc4d2310 100644 --- a/.github/scripts/packed-consumer.mjs +++ b/.github/scripts/packed-consumer.mjs @@ -272,6 +272,11 @@ try { join(fixture, 'routes/api/packed.ts'), `import { defineHandler } from 'void';\nimport { kv } from 'void/kv';\nexport const GET = defineHandler(async () => { await kv.put('packed', 'ok'); return { value: await kv.get('packed') }; });\n`, ); + // void/email pulls a CommonJS dependency into the Worker; only an installed package shows whether dev serves it. + writeFileSync( + join(fixture, 'routes/api/packed-email.ts'), + `import { defineHandler } from 'void';\nimport { sendEmail } from 'void/email';\nexport const GET = defineHandler(async () => { return sendEmail({ from: 'packed@example.com', to: 'inbox@example.com', subject: 'Packed', text: 'ok' }); });\n`, + ); mkdirSync(join(fixture, 'public'), { recursive: true }); writeFileSync(join(fixture, 'public/packed.txt'), label); const vite = join(fixture, 'node_modules/vite/bin/vite.js'); @@ -335,6 +340,16 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E const apiBody = await apiResponse.text(); assert.equal(apiResponse.status, 200, `${apiBody}\n${task.failure()}`); assert.deepEqual(JSON.parse(apiBody), { value: 'ok' }); + const emailResponse = await fetch(`${base}/api/packed-email`); + const emailBody = await emailResponse.text(); + assert.equal(emailResponse.status, 200, `${emailBody}\n${task.failure()}`); + // Preview runs the built Worker: no dev inbox and no send binding. + const email = JSON.parse(emailBody); + if (mode === 'dev') { + assert.equal(email.ok, true, emailBody); + } else { + assert.equal(email.error?.code, 'BINDING_MISSING', emailBody); + } if (full && framework === 'react') { const markdown = await fetch(`${base}/markdown`); assert.equal(markdown.status, 200, task.failure()); @@ -375,7 +390,7 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E } } console.log( - `Passed packed ${label} (${suite}): init, frozen install, public Vite build/typecheck, assets, KV, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}`, + `Passed packed ${label} (${suite}): init, frozen install, public Vite build/typecheck, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}`, ); } catch (error) { failures.push({ fixture: label, error: String(error.stack ?? error) }); diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index ae26ec0f9..26384479c 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -208,7 +208,9 @@ export function voidPlugin(options?: { // graph and therefore another envContext, so request bindings disappear for // the prebundled copy. Every return path includes this plugin, including // framework and Node targets that do not all use bindingRuntimePlugin. - const serverRuntimeOptimizerPlugin = createServerRuntimeOptimizerPlugin(); + const serverRuntimeOptimizerPlugin = createServerRuntimeOptimizerPlugin( + !isNodeTarget(config.target) && !frameworkOwnsCloudflareRuntime, + ); // Scan queues/websocket routes early so both framework-mode and default-mode // branches can safely close over these bindings. const queues = framework ? [] : application.queues; diff --git a/packages/void/src/plugin-worker-environment.ts b/packages/void/src/plugin-worker-environment.ts index 5e12785b7..020b09a24 100644 --- a/packages/void/src/plugin-worker-environment.ts +++ b/packages/void/src/plugin-worker-environment.ts @@ -130,13 +130,26 @@ export function createWranglerCompatPersistPlugin( }; } -export function createServerRuntimeOptimizerPlugin(): Plugin { +export function createServerRuntimeOptimizerPlugin(voidOwnsWorkerRuntime: boolean): Plugin { const serverRuntimeOptimizerPlugin: Plugin = { name: 'void:server-runtime-optimizer', configEnvironment(name) { - if (name !== 'client') { + if (name === 'client') { + return; + } + if (!voidOwnsWorkerRuntime) { return { optimizeDeps: { exclude: ['void'] } }; } + return { + optimizeDeps: { + exclude: ['void'], + // Excluding void also skips converting its CommonJS dependencies, which workerd cannot + // evaluate: an installed void/email fails with "exports is not defined". Runtimes a + // framework owns stay untouched; Node loads CommonJS itself, and forcing an include + // there turns on SSR prebundling the framework did not ask for. + include: ['void > email-validator'], + }, + }; }, }; diff --git a/packages/void/test/unit/plugin-dev-asset-routing.test.ts b/packages/void/test/unit/plugin-dev-asset-routing.test.ts index f213f480c..6b7859fc7 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -72,6 +72,15 @@ function createFrameworkProjectRoot(): string { return root; } +function createFrameworkOwnedRuntimeProjectRoot(): string { + const root = createProjectRoot(); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ type: 'module', dependencies: { '@analogjs/platform': '^2.0.0' } }), + ); + return root; +} + function createNodeProjectRoot(): string { const root = createProjectRoot(); writeFileSync( @@ -220,12 +229,19 @@ describe('voidPlugin dev asset routing', () => { }); it('keeps one Void runtime module graph in core, framework, and Node servers', async () => { + const workerdOptimizer = { + optimizeDeps: { exclude: ['void'], include: ['void > email-validator'] }, + }; await expect( resolveRuntimeOptimizerConfig(createApiOnlyProjectRoot(), 'void_worker'), - ).resolves.toEqual({ optimizeDeps: { exclude: ['void'] } }); + ).resolves.toEqual(workerdOptimizer); vi.restoreAllMocks(); await expect( resolveRuntimeOptimizerConfig(createFrameworkProjectRoot(), 'ssr'), + ).resolves.toEqual(workerdOptimizer); + vi.restoreAllMocks(); + await expect( + resolveRuntimeOptimizerConfig(createFrameworkOwnedRuntimeProjectRoot(), 'ssr'), ).resolves.toEqual({ optimizeDeps: { exclude: ['void'] } }); vi.restoreAllMocks(); await expect(resolveRuntimeOptimizerConfig(createNodeProjectRoot(), 'ssr')).resolves.toEqual({