Skip to content
Merged
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
123 changes: 123 additions & 0 deletions packages/void/src/cli/cf-build-output.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto';
import {
cpSync,
copyFileSync,
Expand Down Expand Up @@ -26,9 +27,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 +324,122 @@ async function prepareNativeBuildOutput(
}
}

// The additional module rules of the Cloudflare Vite plugin, which does not export them.
// Hook filters need a RegExp; `additionalModulePath` applies the same rules.
const ADDITIONAL_MODULE_FILTER = /\.(?:wasm(?:\?module)?|bin|txt|html|sql)$/;
const ADDITIONAL_MODULE_EXTENSIONS = new Set(['.wasm', '.bin', '.txt', '.html', '.sql']);

/** Keep letters, digits, `.`, `_` and `-`: the name ends up inside a JS import string. */
function safeFileStem(stem: string): string {
let safe = '';
for (const char of stem) {
const code = char.charCodeAt(0);
const allowed =
(code >= 48 && code <= 57) ||
(code >= 65 && code <= 90) ||
(code >= 97 && code <= 122) ||
char === '.' ||
char === '_' ||
char === '-';
safe += allowed ? char : '_';
}
return safe || 'module';
}

/** The file behind a resolved id that is uploaded as a Worker module, if it is one. */
function additionalModulePath(id: string): string | undefined {
const query = id.indexOf('?');
const path = query === -1 ? id : id.slice(0, query);
if (query !== -1 && !(id.slice(query) === '?module' && path.endsWith('.wasm'))) {
return undefined;
}
return ADDITIONAL_MODULE_EXTENSIONS.has(extname(path)) ? path : undefined;
}

/**
* Keep Wasm, data and text imports out of the bundle and emit each file beside it, as
* `@cloudflare/vite-plugin` does, so `copyBundle` uploads them as Worker modules.
*/
function additionalModulesPlugin(): Plugin {
// The external id carries a key, not the path: Rolldown escapes ids in the output.
// The random marker keeps app strings that look like an id from being rewritten.
const marker = `__VOID_MODULE_${randomBytes(8).toString('hex')}__`;
const paths: Array<string> = [];
const keys = new Map<string, number>();
// Each file keeps its own name and extension. Assets with the same bytes would
// otherwise be merged, and the extension picks the module type.
const fileNames = new Map<string, string>();
const usedFileNames = new Set<string>();
return {
name: 'void:additional-modules',
enforce: 'pre',
resolveId: {
filter: { id: [ADDITIONAL_MODULE_FILTER, /^#/] },
async handler(source, importer, options) {
const resolved = await this.resolve(source, importer, options);
const path = resolved ? additionalModulePath(resolved.id) : undefined;
if (!path) {
return resolved;
}
let key = keys.get(path);
if (key === undefined) {
key = paths.push(path) - 1;
keys.set(path, key);
}
return { id: `${marker}${key}${marker}`, external: true };
},
},
renderChunk: {
filter: { code: marker },
handler(code, chunk) {
const emit = (path: string): string => {
let fileName = fileNames.get(path);
if (fileName === undefined) {
const extension = extname(path);
const stem = safeFileStem(basename(path, extension));
fileName = stem + extension;
for (let count = 2; usedFileNames.has(fileName); count++) {
fileName = `${stem}${count}${extension}`;
}
usedFileNames.add(fileName);
fileNames.set(path, fileName);
this.emitFile({
type: 'asset',
fileName,
originalFileName: path,
source: readFileSync(path),
});
}
return fileName;
};
let output = '';
let cursor = 0;
let start = code.indexOf(marker);
while (start !== -1) {
const keyStart = start + marker.length;
const end = code.indexOf(marker, keyStart);
if (end === -1) {
break;
}
const keyText = code.slice(keyStart, end);
const path = keyText === String(Number(keyText)) ? paths[Number(keyText)] : undefined;
Comment thread
Brooooooklyn marked this conversation as resolved.
if (path === undefined) {
start = code.indexOf(marker, keyStart);
continue;
}
const importPath = relative(dirname(chunk.fileName), emit(path));
output +=
code.slice(cursor, start) +
(importPath.startsWith('.') ? importPath : `./${importPath}`);
cursor = end + marker.length;
start = code.indexOf(marker, cursor);
}
return output + code.slice(cursor);
},
},
};
}

/** Bundle a generated Worker entry with Vite, then hand the result to `prepareCfBuildOutput`. */
async function prepareBundledBuildOutput(options: {
projectRoot: string;
Expand All @@ -339,6 +458,10 @@ async function prepareBundledBuildOutput(options: {
root: options.projectRoot,
publicDir: false,
logLevel: 'error',
plugins: [additionalModulesPlugin()],
// Like @cloudflare/vite-plugin: Worker conditions plus the build mode. A deploy
// bundle is always a production build, whatever NODE_ENV says.
resolve: { conditions: [...WORKER_CONDITIONS, 'production'] },
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
157 changes: 157 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,163 @@ 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]));
// Same bytes, different module types; the quote must survive the module id.
// Windows does not allow `"` in file names.
const quoted = process.platform === 'win32' ? "sa'me.bin" : 'sa"me.bin';
writeFileSync(join(output, 'same.txt'), 'same');
writeFileSync(join(output, quoted), 'same');
writeFileSync(
join(output, '_worker.js'),
"import plain from './plain.wasm'; import message from './message.txt'; import data from './data.bin';\n" +
`import sameText from './same.txt'; import sameData from ${JSON.stringify(`./${quoted}`)};\n` +
"import { render } from './chunks/render.js';\n" +
// App text that looks like the old module id stays as it is.
"export const looksLikeId = '__VOID_MODULE__0__VOID_MODULE__';\n" +
'export default { async fetch() { return new Response(String([plain, message, data, sameText, sameData, 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(6);
expect(readFileSync(join(bundle, 'index.js'), 'utf8')).toContain(
'__VOID_MODULE__0__VOID_MODULE__',
);
expect(additional).toEqual(
expect.arrayContaining([
['wasm', bindgenWasm],
['wasm', plainWasm],
['text', Buffer.from('text module')],
['data', Buffer.from([0, 255])],
['text', Buffer.from('same')],
['data', Buffer.from('same')],
]),
);
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' },
}),
);
const modes = join(root, 'node_modules/modes');
mkdirSync(modes, { recursive: true });
writeFileSync(
join(modes, 'package.json'),
JSON.stringify({
name: 'modes',
type: 'module',
exports: { development: './dev.js', production: './prod.js', default: './dev.js' },
}),
);
writeFileSync(join(modes, 'dev.js'), "export const mode = 'modes-development';\n");
writeFileSync(join(modes, 'prod.js'), "export const mode = 'modes-production';\n");
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'; import { mode } from 'modes';\n" +
'export default { fetch() { return new Response(target + mode) } };\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');
expect(bundle).toContain('modes-production');
expect(bundle).not.toContain('modes-development');
},
);
},
CF_BUILD_TIMEOUT_MS,
);

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