Skip to content

Commit f383dec

Browse files
fix(deploy): emit additional modules and use Worker conditions in framework Worker bundles
1 parent 6ab6559 commit f383dec

4 files changed

Lines changed: 175 additions & 2 deletions

File tree

‎packages/void/src/cli/cf-build-output.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ import {
2626
import { type ModuleType, OutputContainerSchema } from '@cloudflare/config';
2727
import ignore from 'ignore';
2828
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'pathslash';
29+
import type { Plugin } from 'vite';
2930
import { migrateBuiltWranglerConfig, record } from './cf-config-migrate.ts';
3031
import { buildContainerImage } from './container-image.ts';
3132
import { readGeneratedWranglerConfigFile } from './generated-wrangler-config.ts';
33+
import { WORKER_CONDITIONS } from './worker-conditions.ts';
3234

3335
export interface CfBuildOutput {
3436
directory: string;
@@ -321,6 +323,37 @@ async function prepareNativeBuildOutput(
321323
}
322324
}
323325

326+
// The Cloudflare Vite plugin's additional module rules (CompiledWasm, Data and Text).
327+
const additionalModuleRE = /\.(?:wasm(?:\?module)?|bin|txt|html|sql)$/;
328+
const modulePlaceholderRE = /__VOID_MODULE__(.*?)__VOID_MODULE__/g;
329+
330+
/** Emit additional module imports beside the bundle, like `@cloudflare/vite-plugin`. */
331+
function additionalModulesPlugin(): Plugin {
332+
return {
333+
name: 'void:additional-modules',
334+
enforce: 'pre',
335+
async resolveId(source, importer, options) {
336+
if (!additionalModuleRE.test(source) && !source.startsWith('#')) {
337+
return null;
338+
}
339+
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
340+
if (!resolved || !additionalModuleRE.test(resolved.id)) {
341+
return resolved;
342+
}
343+
return { id: `__VOID_MODULE__${resolved.id.split('?')[0]}__VOID_MODULE__`, external: true };
344+
},
345+
renderChunk(code, chunk) {
346+
return code.replace(modulePlaceholderRE, (_, path: string) => {
347+
const fileName = this.getFileName(
348+
this.emitFile({ type: 'asset', name: basename(path), source: readFileSync(path) }),
349+
);
350+
const importPath = relative(dirname(chunk.fileName), fileName);
351+
return importPath.startsWith('.') ? importPath : `./${importPath}`;
352+
});
353+
},
354+
};
355+
}
356+
324357
/** Bundle a generated Worker entry with Vite, then hand the result to `prepareCfBuildOutput`. */
325358
async function prepareBundledBuildOutput(options: {
326359
projectRoot: string;
@@ -339,6 +372,8 @@ async function prepareBundledBuildOutput(options: {
339372
root: options.projectRoot,
340373
publicDir: false,
341374
logLevel: 'error',
375+
plugins: [additionalModulesPlugin()],
376+
resolve: { conditions: WORKER_CONDITIONS },
342377
build: {
343378
outDir: scratch,
344379
emptyOutDir: true,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// workerd's conditions, then a browser build's, so packages pick their Worker builds.
2+
export const WORKER_CONDITIONS: Array<string> = [
3+
'workerd',
4+
'worker',
5+
'browser',
6+
'module',
7+
'import',
8+
'default',
9+
];

‎packages/void/src/cli/wrapper.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { MigrationPlanBundle } from '../migrations/plan.ts';
1818
import { voidDbBundlerPlugin } from '../plugin-drizzle.ts';
1919
import { readProjectPaths } from '../project-paths.ts';
2020
import { generateQueueResolverSource } from '../queues/dispatch.ts';
21+
import { WORKER_CONDITIONS } from './worker-conditions.ts';
2122
import { transformWorkerRequireBundle } from './worker-require-transform.ts';
2223

2324
export type WrapperFeatures = {
@@ -312,8 +313,6 @@ async function bundleMigrationHandler(
312313
}
313314
}
314315

315-
// workerd's conditions, then a browser build's, so packages pick their Worker builds.
316-
const WORKER_CONDITIONS = ['workerd', 'worker', 'browser', 'module', 'import', 'default'];
317316
const SQL_WORKER_EXTERNAL = [/^cloudflare:/, /^node:/, /^pgpass$/];
318317

319318
/** pg's workerd socket and its CommonJS pool, resolved from the pg that `fromPath` loads. */

‎packages/void/test/unit/cf-build-output.test.ts‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,136 @@ it(
562562
CF_BUILD_TIMEOUT_MS,
563563
);
564564

565+
it(
566+
'keeps framework Wasm, text and data imports as Worker modules',
567+
async () => {
568+
const root = mkdtempSync(join(tmpdir(), 'void-cf-framework-wasm-test-'));
569+
roots.push(root);
570+
const output = join(root, 'framework');
571+
mkdirSync(join(output, 'chunks'), { recursive: true });
572+
writeFileSync(
573+
join(root, 'package.json'),
574+
JSON.stringify({ type: 'module', imports: { '#plain': './framework/plain.wasm' } }),
575+
);
576+
writeFileSync(
577+
join(root, 'wrangler.json'),
578+
JSON.stringify({
579+
name: 'void-framework-test',
580+
account_id: 'account-id',
581+
main: 'framework/_worker.js',
582+
compatibility_date: '2026-09-29',
583+
assets: { directory: 'framework', binding: 'ASSETS' },
584+
}),
585+
);
586+
// Imports `wbg`, like wasm-bindgen output.
587+
const bindgenWasm = Buffer.from([
588+
0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 2, 9, 1, 3, 119, 98, 103, 1, 102, 0, 0,
589+
]);
590+
const plainWasm = Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]);
591+
mkdirSync(join(root, 'node_modules/bindgen'), { recursive: true });
592+
writeFileSync(join(root, 'node_modules/bindgen/index_bg.wasm'), bindgenWasm);
593+
writeFileSync(join(output, 'plain.wasm'), plainWasm);
594+
writeFileSync(join(output, 'message.txt'), 'text module');
595+
writeFileSync(join(output, 'data.bin'), Buffer.from([0, 255]));
596+
writeFileSync(
597+
join(output, '_worker.js'),
598+
"import plain from './plain.wasm'; import message from './message.txt'; import data from './data.bin';\n" +
599+
"import { render } from './chunks/render.js';\n" +
600+
'export default { async fetch() { return new Response(String([plain, message, data, await render()])) } };\n',
601+
);
602+
writeFileSync(
603+
join(output, 'chunks/render.js'),
604+
"import plain from '#plain';\n" +
605+
"export async function render() { return String(plain) + String(await import('bindgen/index_bg.wasm')); }\n",
606+
);
607+
writeFileSync(join(output, '.assetsignore'), '_worker.js\n');
608+
609+
await withPrepared(
610+
prepareCfFrameworkBuildOutput({
611+
projectRoot: root,
612+
configPath: join(root, 'wrangler.json'),
613+
accountId: 'account-id',
614+
}),
615+
async (prepared) => {
616+
const built = await readBuildOutput(prepared.directory);
617+
const { modules } = built.workers.default.config.manifest!;
618+
const bundle = getWorkerBundleDir(prepared.directory);
619+
const additional = Object.keys(modules)
620+
.filter((name) => modules[name].type !== 'esm')
621+
.map((name) => [modules[name].type, readFileSync(join(bundle, name))]);
622+
expect(additional).toHaveLength(4);
623+
expect(additional).toEqual(
624+
expect.arrayContaining([
625+
['wasm', bindgenWasm],
626+
['wasm', plainWasm],
627+
['text', Buffer.from('text module')],
628+
['data', Buffer.from([0, 255])],
629+
]),
630+
);
631+
expectCfDryRun(prepared.directory);
632+
},
633+
);
634+
},
635+
CF_BUILD_TIMEOUT_MS,
636+
);
637+
638+
it(
639+
'resolves framework Worker dependencies with Worker conditions',
640+
async () => {
641+
const root = mkdtempSync(join(tmpdir(), 'void-cf-framework-conditions-test-'));
642+
roots.push(root);
643+
const output = join(root, 'framework');
644+
mkdirSync(output);
645+
writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' }));
646+
writeFileSync(
647+
join(root, 'wrangler.json'),
648+
JSON.stringify({
649+
name: 'void-framework-test',
650+
account_id: 'account-id',
651+
main: 'framework/_worker.js',
652+
compatibility_date: '2026-09-29',
653+
assets: { directory: 'framework', binding: 'ASSETS' },
654+
}),
655+
);
656+
const dual = join(root, 'node_modules/dual');
657+
mkdirSync(dual, { recursive: true });
658+
writeFileSync(
659+
join(dual, 'package.json'),
660+
JSON.stringify({
661+
name: 'dual',
662+
type: 'module',
663+
exports: { workerd: './worker.js', browser: './browser.js', default: './node.js' },
664+
}),
665+
);
666+
writeFileSync(join(dual, 'worker.js'), "export const target = 'dual-workerd';\n");
667+
writeFileSync(join(dual, 'browser.js'), "export const target = 'dual-browser';\n");
668+
writeFileSync(join(dual, 'node.js'), "export const target = 'dual-node';\n");
669+
writeFileSync(
670+
join(output, '_worker.js'),
671+
"import { target } from 'dual';\n" +
672+
'export default { fetch() { return new Response(target) } };\n',
673+
);
674+
writeFileSync(join(output, '.assetsignore'), '_worker.js\n');
675+
676+
await withPrepared(
677+
prepareCfFrameworkBuildOutput({
678+
projectRoot: root,
679+
configPath: join(root, 'wrangler.json'),
680+
accountId: 'account-id',
681+
}),
682+
async (prepared) => {
683+
const bundle = readFileSync(
684+
join(getWorkerBundleDir(prepared.directory), 'index.js'),
685+
'utf8',
686+
);
687+
expect(bundle).toContain('dual-workerd');
688+
expect(bundle).not.toContain('dual-browser');
689+
},
690+
);
691+
},
692+
CF_BUILD_TIMEOUT_MS,
693+
);
694+
565695
it(
566696
'accepts a nested hashed Worker entry with a relative module import',
567697
async () => {

0 commit comments

Comments
 (0)