Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/void/src/cli/cf-build-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ import {
import { type ModuleType, OutputContainerSchema } from '@cloudflare/config';
import ignore from 'ignore';
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'pathslash';
import type { Plugin } from 'vite';
import { migrateBuiltWranglerConfig, record } from './cf-config-migrate.ts';
import { buildContainerImage } from './container-image.ts';
import { readGeneratedWranglerConfigFile } from './generated-wrangler-config.ts';
import { WORKER_CONDITIONS } from './worker-conditions.ts';

export interface CfBuildOutput {
directory: string;
Expand Down Expand Up @@ -321,6 +323,37 @@ async function prepareNativeBuildOutput(
}
}

// The Cloudflare Vite plugin's additional module rules (CompiledWasm, Data and Text).
const additionalModuleRE = /\.(?:wasm(?:\?module)?|bin|txt|html|sql)$/;
const modulePlaceholderRE = /__VOID_MODULE__(.*?)__VOID_MODULE__/g;

/** Emit additional module imports beside the bundle, like `@cloudflare/vite-plugin`. */
function additionalModulesPlugin(): Plugin {
return {
name: 'void:additional-modules',
enforce: 'pre',
async resolveId(source, importer, options) {
if (!additionalModuleRE.test(source) && !source.startsWith('#')) {
return null;
}
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
if (!resolved || !additionalModuleRE.test(resolved.id)) {
return resolved;
}
return { id: `__VOID_MODULE__${resolved.id.split('?')[0]}__VOID_MODULE__`, external: true };
},
renderChunk(code, chunk) {
return code.replace(modulePlaceholderRE, (_, path: string) => {
const fileName = this.getFileName(
this.emitFile({ type: 'asset', name: basename(path), source: readFileSync(path) }),
);
const importPath = relative(dirname(chunk.fileName), fileName);
return importPath.startsWith('.') ? importPath : `./${importPath}`;
});
},
};
}

/** Bundle a generated Worker entry with Vite, then hand the result to `prepareCfBuildOutput`. */
async function prepareBundledBuildOutput(options: {
projectRoot: string;
Expand All @@ -339,6 +372,8 @@ async function prepareBundledBuildOutput(options: {
root: options.projectRoot,
publicDir: false,
logLevel: 'error',
plugins: [additionalModulesPlugin()],
resolve: { conditions: WORKER_CONDITIONS },
build: {
outDir: scratch,
emptyOutDir: true,
Expand Down
9 changes: 9 additions & 0 deletions packages/void/src/cli/worker-conditions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// workerd's conditions, then a browser build's, so packages pick their Worker builds.
export const WORKER_CONDITIONS: Array<string> = [
'workerd',
'worker',
'browser',
'module',
'import',
'default',
];
3 changes: 1 addition & 2 deletions packages/void/src/cli/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { MigrationPlanBundle } from '../migrations/plan.ts';
import { voidDbBundlerPlugin } from '../plugin-drizzle.ts';
import { readProjectPaths } from '../project-paths.ts';
import { generateQueueResolverSource } from '../queues/dispatch.ts';
import { WORKER_CONDITIONS } from './worker-conditions.ts';
import { transformWorkerRequireBundle } from './worker-require-transform.ts';

export type WrapperFeatures = {
Expand Down Expand Up @@ -312,8 +313,6 @@ async function bundleMigrationHandler(
}
}

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

/** pg's workerd socket and its CommonJS pool, resolved from the pg that `fromPath` loads. */
Expand Down
130 changes: 130 additions & 0 deletions packages/void/test/unit/cf-build-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,136 @@ it(
CF_BUILD_TIMEOUT_MS,
);

it(
'keeps framework Wasm, text and data imports as Worker modules',
async () => {
const root = mkdtempSync(join(tmpdir(), 'void-cf-framework-wasm-test-'));
roots.push(root);
const output = join(root, 'framework');
mkdirSync(join(output, 'chunks'), { recursive: true });
writeFileSync(
join(root, 'package.json'),
JSON.stringify({ type: 'module', imports: { '#plain': './framework/plain.wasm' } }),
);
writeFileSync(
join(root, 'wrangler.json'),
JSON.stringify({
name: 'void-framework-test',
account_id: 'account-id',
main: 'framework/_worker.js',
compatibility_date: '2026-09-29',
assets: { directory: 'framework', binding: 'ASSETS' },
}),
);
// Imports `wbg`, like wasm-bindgen output.
const bindgenWasm = Buffer.from([
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,
]);
const plainWasm = Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]);
mkdirSync(join(root, 'node_modules/bindgen'), { recursive: true });
writeFileSync(join(root, 'node_modules/bindgen/index_bg.wasm'), bindgenWasm);
writeFileSync(join(output, 'plain.wasm'), plainWasm);
writeFileSync(join(output, 'message.txt'), 'text module');
writeFileSync(join(output, 'data.bin'), Buffer.from([0, 255]));
writeFileSync(
join(output, '_worker.js'),
"import plain from './plain.wasm'; import message from './message.txt'; import data from './data.bin';\n" +
"import { render } from './chunks/render.js';\n" +
'export default { async fetch() { return new Response(String([plain, message, data, await render()])) } };\n',
);
writeFileSync(
join(output, 'chunks/render.js'),
"import plain from '#plain';\n" +
"export async function render() { return String(plain) + String(await import('bindgen/index_bg.wasm')); }\n",
);
writeFileSync(join(output, '.assetsignore'), '_worker.js\n');

await withPrepared(
prepareCfFrameworkBuildOutput({
projectRoot: root,
configPath: join(root, 'wrangler.json'),
accountId: 'account-id',
}),
async (prepared) => {
const built = await readBuildOutput(prepared.directory);
const { modules } = built.workers.default.config.manifest!;
const bundle = getWorkerBundleDir(prepared.directory);
const additional = Object.keys(modules)
.filter((name) => modules[name].type !== 'esm')
.map((name) => [modules[name].type, readFileSync(join(bundle, name))]);
expect(additional).toHaveLength(4);
expect(additional).toEqual(
expect.arrayContaining([
['wasm', bindgenWasm],
['wasm', plainWasm],
['text', Buffer.from('text module')],
['data', Buffer.from([0, 255])],
]),
);
expectCfDryRun(prepared.directory);
},
);
},
CF_BUILD_TIMEOUT_MS,
);

it(
'resolves framework Worker dependencies with Worker conditions',
async () => {
const root = mkdtempSync(join(tmpdir(), 'void-cf-framework-conditions-test-'));
roots.push(root);
const output = join(root, 'framework');
mkdirSync(output);
writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' }));
writeFileSync(
join(root, 'wrangler.json'),
JSON.stringify({
name: 'void-framework-test',
account_id: 'account-id',
main: 'framework/_worker.js',
compatibility_date: '2026-09-29',
assets: { directory: 'framework', binding: 'ASSETS' },
}),
);
const dual = join(root, 'node_modules/dual');
mkdirSync(dual, { recursive: true });
writeFileSync(
join(dual, 'package.json'),
JSON.stringify({
name: 'dual',
type: 'module',
exports: { workerd: './worker.js', browser: './browser.js', default: './node.js' },
}),
);
writeFileSync(join(dual, 'worker.js'), "export const target = 'dual-workerd';\n");
writeFileSync(join(dual, 'browser.js'), "export const target = 'dual-browser';\n");
writeFileSync(join(dual, 'node.js'), "export const target = 'dual-node';\n");
writeFileSync(
join(output, '_worker.js'),
"import { target } from 'dual';\n" +
'export default { fetch() { return new Response(target) } };\n',
);
writeFileSync(join(output, '.assetsignore'), '_worker.js\n');

await withPrepared(
prepareCfFrameworkBuildOutput({
projectRoot: root,
configPath: join(root, 'wrangler.json'),
accountId: 'account-id',
}),
async (prepared) => {
const bundle = readFileSync(
join(getWorkerBundleDir(prepared.directory), 'index.js'),
'utf8',
);
expect(bundle).toContain('dual-workerd');
expect(bundle).not.toContain('dual-browser');
},
);
},
CF_BUILD_TIMEOUT_MS,
);

it(
'accepts a nested hashed Worker entry with a relative module import',
async () => {
Expand Down