diff --git a/packages/void/src/cli/cf-build-output.ts b/packages/void/src/cli/cf-build-output.ts index e67ee17e4..4f2b805c7 100644 --- a/packages/void/src/cli/cf-build-output.ts +++ b/packages/void/src/cli/cf-build-output.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { cpSync, copyFileSync, @@ -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; @@ -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 = []; + const keys = new Map(); + // 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(); + const usedFileNames = new Set(); + 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; + 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; @@ -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, diff --git a/packages/void/src/cli/worker-conditions.ts b/packages/void/src/cli/worker-conditions.ts new file mode 100644 index 000000000..05bd85c15 --- /dev/null +++ b/packages/void/src/cli/worker-conditions.ts @@ -0,0 +1,9 @@ +// workerd's conditions, then a browser build's, so packages pick their Worker builds. +export const WORKER_CONDITIONS: Array = [ + 'workerd', + 'worker', + 'browser', + 'module', + 'import', + 'default', +]; diff --git a/packages/void/src/cli/wrapper.ts b/packages/void/src/cli/wrapper.ts index a302c686b..4ca710ce4 100644 --- a/packages/void/src/cli/wrapper.ts +++ b/packages/void/src/cli/wrapper.ts @@ -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 = { @@ -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. */ diff --git a/packages/void/test/unit/cf-build-output.test.ts b/packages/void/test/unit/cf-build-output.test.ts index fd3b4e4f3..effaa1b94 100644 --- a/packages/void/test/unit/cf-build-output.test.ts +++ b/packages/void/test/unit/cf-build-output.test.ts @@ -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 () => {