Skip to content

Commit 70c275d

Browse files
PleasurecruiseBrooooooklynclaude
authored
fix(deploy): bundle framework Workers with Cloudflare module rules and Worker conditions (#540)
Fixes #539 `prepareBundledBuildOutput` bundles framework Worker output again with a plain Vite lib build. It now matches `@cloudflare/vite-plugin` in two ways: - **Additional modules.** A plugin applies the Cloudflare Vite plugin's module rules: `.wasm` and `.wasm?module`, `.bin`, `.txt`, `.html` and `.sql`, including `#` subpath imports that resolve to them. Matching imports stay external, and each file is emitted beside the bundle with a path relative to the chunk that imports it. `copyBundle` already uploads these files as `wasm`, `data` and `text` modules. The Cloudflare plugin does not export its rules, so the plugin repeats them. - **Resolve conditions.** The build uses `WORKER_CONDITIONS`, which Void's other Worker bundles already use. The constant moves from `wrapper.ts` to its own module so that `cf-build-output.ts` still loads Vite lazily. Both changes apply to every bundle that `prepareBundledBuildOutput` builds, including the static Worker. ## Tests `packages/void/test/unit/cf-build-output.test.ts`: - A framework Worker with wasm-bindgen-style and plain Wasm, text and data imports, a `#` subpath import and a dynamic import produces `wasm`, `text` and `data` modules and passes the `cf` dry run. - A dependency with `workerd` and `browser` exports resolves to its `workerd` build. Each test fails when its change is removed. `vp run test`, `vp run --filter ./packages/void typecheck`, `vp lint` and `vp fmt --check` pass. I also prepared the output of a SvelteKit app that uses `@resvg/resvg-wasm` and micromark: `index_bg.wasm` is uploaded as a `wasm` module, and the bundle contains the Worker build of `decode-named-character-reference`. ## Follow-up changes (21e826b, 1cb2d9f) ``` import './a.txt' ─┐ ┌─> a.txt (text) import './a.bin' ─┤ same bytes, before: ──> │ one file, one type ✗ │ same bytes, now: ──> └─> a.txt (text) + a.bin (data) ✓ import './sa"me.bin' before: ENOENT ✗ now: sa_me.bin ✓ esm-env DEV before: not folded now: folded to false ✓ ``` - Each module file is emitted once per source path, with its own name and extension. Files with the same bytes are no longer merged, so each import keeps its module type. - The external id holds a counter key instead of the path, and emitted names keep only letters, digits, `.`, `_` and `-`. A quote in a path no longer breaks the import. - The build adds the `production` condition, like `@cloudflare/vite-plugin` does for production builds. It is fixed rather than taken from `NODE_ENV`, because a deploy bundle is always a production build. Without it, the SvelteKit playground bundle lost the production build of `esm-env`. - The reference regex is replaced by a small `indexOf` loop. Each build uses a random marker, so app strings that look like a module id stay as they are. Tests: the Wasm test adds same-bytes `.txt`/`.bin` files, a quoted file name (`'` on Windows) and an app string that looks like the old marker; the conditions test adds a package with `development`/`production` exports. Left out, since they behave the same before this PR (and in `@cloudflare/vite-plugin` for the second): - Packages without `exports` still prefer the `browser` main field. - Wasm reached through an export subpath without a `.wasm` suffix is not kept as a module. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: LongYinan <github@lyn.one> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent cedcd98 commit 70c275d

4 files changed

Lines changed: 290 additions & 2 deletions

File tree

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

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { randomBytes } from 'node:crypto';
12
import {
23
cpSync,
34
copyFileSync,
@@ -26,9 +27,11 @@ import {
2627
import { type ModuleType, OutputContainerSchema } from '@cloudflare/config';
2728
import ignore from 'ignore';
2829
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'pathslash';
30+
import type { Plugin } from 'vite';
2931
import { migrateBuiltWranglerConfig, record } from './cf-config-migrate.ts';
3032
import { buildContainerImage } from './container-image.ts';
3133
import { readGeneratedWranglerConfigFile } from './generated-wrangler-config.ts';
34+
import { WORKER_CONDITIONS } from './worker-conditions.ts';
3235

3336
export interface CfBuildOutput {
3437
directory: string;
@@ -321,6 +324,122 @@ async function prepareNativeBuildOutput(
321324
}
322325
}
323326

327+
// The additional module rules of the Cloudflare Vite plugin, which does not export them.
328+
// Hook filters need a RegExp; `additionalModulePath` applies the same rules.
329+
const ADDITIONAL_MODULE_FILTER = /\.(?:wasm(?:\?module)?|bin|txt|html|sql)$/;
330+
const ADDITIONAL_MODULE_EXTENSIONS = new Set(['.wasm', '.bin', '.txt', '.html', '.sql']);
331+
332+
/** Keep letters, digits, `.`, `_` and `-`: the name ends up inside a JS import string. */
333+
function safeFileStem(stem: string): string {
334+
let safe = '';
335+
for (const char of stem) {
336+
const code = char.charCodeAt(0);
337+
const allowed =
338+
(code >= 48 && code <= 57) ||
339+
(code >= 65 && code <= 90) ||
340+
(code >= 97 && code <= 122) ||
341+
char === '.' ||
342+
char === '_' ||
343+
char === '-';
344+
safe += allowed ? char : '_';
345+
}
346+
return safe || 'module';
347+
}
348+
349+
/** The file behind a resolved id that is uploaded as a Worker module, if it is one. */
350+
function additionalModulePath(id: string): string | undefined {
351+
const query = id.indexOf('?');
352+
const path = query === -1 ? id : id.slice(0, query);
353+
if (query !== -1 && !(id.slice(query) === '?module' && path.endsWith('.wasm'))) {
354+
return undefined;
355+
}
356+
return ADDITIONAL_MODULE_EXTENSIONS.has(extname(path)) ? path : undefined;
357+
}
358+
359+
/**
360+
* Keep Wasm, data and text imports out of the bundle and emit each file beside it, as
361+
* `@cloudflare/vite-plugin` does, so `copyBundle` uploads them as Worker modules.
362+
*/
363+
function additionalModulesPlugin(): Plugin {
364+
// The external id carries a key, not the path: Rolldown escapes ids in the output.
365+
// The random marker keeps app strings that look like an id from being rewritten.
366+
const marker = `__VOID_MODULE_${randomBytes(8).toString('hex')}__`;
367+
const paths: Array<string> = [];
368+
const keys = new Map<string, number>();
369+
// Each file keeps its own name and extension. Assets with the same bytes would
370+
// otherwise be merged, and the extension picks the module type.
371+
const fileNames = new Map<string, string>();
372+
const usedFileNames = new Set<string>();
373+
return {
374+
name: 'void:additional-modules',
375+
enforce: 'pre',
376+
resolveId: {
377+
filter: { id: [ADDITIONAL_MODULE_FILTER, /^#/] },
378+
async handler(source, importer, options) {
379+
const resolved = await this.resolve(source, importer, options);
380+
const path = resolved ? additionalModulePath(resolved.id) : undefined;
381+
if (!path) {
382+
return resolved;
383+
}
384+
let key = keys.get(path);
385+
if (key === undefined) {
386+
key = paths.push(path) - 1;
387+
keys.set(path, key);
388+
}
389+
return { id: `${marker}${key}${marker}`, external: true };
390+
},
391+
},
392+
renderChunk: {
393+
filter: { code: marker },
394+
handler(code, chunk) {
395+
const emit = (path: string): string => {
396+
let fileName = fileNames.get(path);
397+
if (fileName === undefined) {
398+
const extension = extname(path);
399+
const stem = safeFileStem(basename(path, extension));
400+
fileName = stem + extension;
401+
for (let count = 2; usedFileNames.has(fileName); count++) {
402+
fileName = `${stem}${count}${extension}`;
403+
}
404+
usedFileNames.add(fileName);
405+
fileNames.set(path, fileName);
406+
this.emitFile({
407+
type: 'asset',
408+
fileName,
409+
originalFileName: path,
410+
source: readFileSync(path),
411+
});
412+
}
413+
return fileName;
414+
};
415+
let output = '';
416+
let cursor = 0;
417+
let start = code.indexOf(marker);
418+
while (start !== -1) {
419+
const keyStart = start + marker.length;
420+
const end = code.indexOf(marker, keyStart);
421+
if (end === -1) {
422+
break;
423+
}
424+
const keyText = code.slice(keyStart, end);
425+
const path = keyText === String(Number(keyText)) ? paths[Number(keyText)] : undefined;
426+
if (path === undefined) {
427+
start = code.indexOf(marker, keyStart);
428+
continue;
429+
}
430+
const importPath = relative(dirname(chunk.fileName), emit(path));
431+
output +=
432+
code.slice(cursor, start) +
433+
(importPath.startsWith('.') ? importPath : `./${importPath}`);
434+
cursor = end + marker.length;
435+
start = code.indexOf(marker, cursor);
436+
}
437+
return output + code.slice(cursor);
438+
},
439+
},
440+
};
441+
}
442+
324443
/** Bundle a generated Worker entry with Vite, then hand the result to `prepareCfBuildOutput`. */
325444
async function prepareBundledBuildOutput(options: {
326445
projectRoot: string;
@@ -339,6 +458,10 @@ async function prepareBundledBuildOutput(options: {
339458
root: options.projectRoot,
340459
publicDir: false,
341460
logLevel: 'error',
461+
plugins: [additionalModulesPlugin()],
462+
// Like @cloudflare/vite-plugin: Worker conditions plus the build mode. A deploy
463+
// bundle is always a production build, whatever NODE_ENV says.
464+
resolve: { conditions: [...WORKER_CONDITIONS, 'production'] },
342465
build: {
343466
outDir: scratch,
344467
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: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,163 @@ 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+
// Same bytes, different module types; the quote must survive the module id.
597+
// Windows does not allow `"` in file names.
598+
const quoted = process.platform === 'win32' ? "sa'me.bin" : 'sa"me.bin';
599+
writeFileSync(join(output, 'same.txt'), 'same');
600+
writeFileSync(join(output, quoted), 'same');
601+
writeFileSync(
602+
join(output, '_worker.js'),
603+
"import plain from './plain.wasm'; import message from './message.txt'; import data from './data.bin';\n" +
604+
`import sameText from './same.txt'; import sameData from ${JSON.stringify(`./${quoted}`)};\n` +
605+
"import { render } from './chunks/render.js';\n" +
606+
// App text that looks like the old module id stays as it is.
607+
"export const looksLikeId = '__VOID_MODULE__0__VOID_MODULE__';\n" +
608+
'export default { async fetch() { return new Response(String([plain, message, data, sameText, sameData, await render()])) } };\n',
609+
);
610+
writeFileSync(
611+
join(output, 'chunks/render.js'),
612+
"import plain from '#plain';\n" +
613+
"export async function render() { return String(plain) + String(await import('bindgen/index_bg.wasm')); }\n",
614+
);
615+
writeFileSync(join(output, '.assetsignore'), '_worker.js\n');
616+
617+
await withPrepared(
618+
prepareCfFrameworkBuildOutput({
619+
projectRoot: root,
620+
configPath: join(root, 'wrangler.json'),
621+
accountId: 'account-id',
622+
}),
623+
async (prepared) => {
624+
const built = await readBuildOutput(prepared.directory);
625+
const { modules } = built.workers.default.config.manifest!;
626+
const bundle = getWorkerBundleDir(prepared.directory);
627+
const additional = Object.keys(modules)
628+
.filter((name) => modules[name].type !== 'esm')
629+
.map((name) => [modules[name].type, readFileSync(join(bundle, name))]);
630+
expect(additional).toHaveLength(6);
631+
expect(readFileSync(join(bundle, 'index.js'), 'utf8')).toContain(
632+
'__VOID_MODULE__0__VOID_MODULE__',
633+
);
634+
expect(additional).toEqual(
635+
expect.arrayContaining([
636+
['wasm', bindgenWasm],
637+
['wasm', plainWasm],
638+
['text', Buffer.from('text module')],
639+
['data', Buffer.from([0, 255])],
640+
['text', Buffer.from('same')],
641+
['data', Buffer.from('same')],
642+
]),
643+
);
644+
expectCfDryRun(prepared.directory);
645+
},
646+
);
647+
},
648+
CF_BUILD_TIMEOUT_MS,
649+
);
650+
651+
it(
652+
'resolves framework Worker dependencies with Worker conditions',
653+
async () => {
654+
const root = mkdtempSync(join(tmpdir(), 'void-cf-framework-conditions-test-'));
655+
roots.push(root);
656+
const output = join(root, 'framework');
657+
mkdirSync(output);
658+
writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' }));
659+
writeFileSync(
660+
join(root, 'wrangler.json'),
661+
JSON.stringify({
662+
name: 'void-framework-test',
663+
account_id: 'account-id',
664+
main: 'framework/_worker.js',
665+
compatibility_date: '2026-09-29',
666+
assets: { directory: 'framework', binding: 'ASSETS' },
667+
}),
668+
);
669+
const dual = join(root, 'node_modules/dual');
670+
mkdirSync(dual, { recursive: true });
671+
writeFileSync(
672+
join(dual, 'package.json'),
673+
JSON.stringify({
674+
name: 'dual',
675+
type: 'module',
676+
exports: { workerd: './worker.js', browser: './browser.js', default: './node.js' },
677+
}),
678+
);
679+
const modes = join(root, 'node_modules/modes');
680+
mkdirSync(modes, { recursive: true });
681+
writeFileSync(
682+
join(modes, 'package.json'),
683+
JSON.stringify({
684+
name: 'modes',
685+
type: 'module',
686+
exports: { development: './dev.js', production: './prod.js', default: './dev.js' },
687+
}),
688+
);
689+
writeFileSync(join(modes, 'dev.js'), "export const mode = 'modes-development';\n");
690+
writeFileSync(join(modes, 'prod.js'), "export const mode = 'modes-production';\n");
691+
writeFileSync(join(dual, 'worker.js'), "export const target = 'dual-workerd';\n");
692+
writeFileSync(join(dual, 'browser.js'), "export const target = 'dual-browser';\n");
693+
writeFileSync(join(dual, 'node.js'), "export const target = 'dual-node';\n");
694+
writeFileSync(
695+
join(output, '_worker.js'),
696+
"import { target } from 'dual'; import { mode } from 'modes';\n" +
697+
'export default { fetch() { return new Response(target + mode) } };\n',
698+
);
699+
writeFileSync(join(output, '.assetsignore'), '_worker.js\n');
700+
701+
await withPrepared(
702+
prepareCfFrameworkBuildOutput({
703+
projectRoot: root,
704+
configPath: join(root, 'wrangler.json'),
705+
accountId: 'account-id',
706+
}),
707+
async (prepared) => {
708+
const bundle = readFileSync(
709+
join(getWorkerBundleDir(prepared.directory), 'index.js'),
710+
'utf8',
711+
);
712+
expect(bundle).toContain('dual-workerd');
713+
expect(bundle).not.toContain('dual-browser');
714+
expect(bundle).toContain('modes-production');
715+
expect(bundle).not.toContain('modes-development');
716+
},
717+
);
718+
},
719+
CF_BUILD_TIMEOUT_MS,
720+
);
721+
565722
it(
566723
'accepts a nested hashed Worker entry with a relative module import',
567724
async () => {

0 commit comments

Comments
 (0)