-
-
Notifications
You must be signed in to change notification settings - Fork 52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(cloudflare) "shared" wasm module imports #249
Changes from 3 commits
e9d6bcd
077170e
ff0644f
2516c00
4aa4241
b9e541c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@astrojs/cloudflare': patch | ||
--- | ||
|
||
Fixes build errors when wasm modules are imported from a file that is shared in both prerendered static pages and server side rendered pages |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,23 +1,35 @@ | ||
import * as fs from 'node:fs'; | ||
import * as fs from 'node:fs/promises'; | ||
import * as path from 'node:path'; | ||
import * as url from 'node:url'; | ||
import type { AstroConfig } from 'astro'; | ||
import type { PluginOption } from 'vite'; | ||
|
||
export interface CloudflareModulePluginExtra { | ||
afterBuildCompleted(config: AstroConfig): Promise<void>; | ||
} | ||
/** | ||
* Enables support for various non-standard extensions in module imports within cloudflare workers. | ||
* | ||
* See https://developers.cloudflare.com/workers/wrangler/bundling/ for reference | ||
* | ||
* This adds supports for imports in the following formats: | ||
* - .wasm?module | ||
* - .bin | ||
* | ||
* Loads '*.wasm?module' imports as WebAssembly modules, which is the only way to load WASM in cloudflare workers. | ||
* Current proposal for WASM modules: https://github.com/WebAssembly/esm-integration/tree/main/proposals/esm-integration | ||
* Cloudflare worker WASM from javascript support: https://developers.cloudflare.com/workers/runtime-apis/webassembly/javascript/ | ||
* @param disabled - if true throws a helpful error message if wasm is encountered and wasm imports are not enabled, | ||
* otherwise it will error obscurely in the esbuild and vite builds | ||
* @param assetsDirectory - the folder name for the assets directory in the build directory. Usually '_astro' | ||
* @param bin - if true, will load '.bin' imports as Uint8Arrays, otherwise will throw errors when encountered to clarify that it must be enabled | ||
* @param wasm - if true, will load '.wasm?module' imports as Uint8Arrays, otherwise will throw errors when encountered to clarify that it must be enabled | ||
* @returns Vite plugin to load WASM tagged with '?module' as a WASM modules | ||
*/ | ||
export function wasmModuleLoader({ | ||
disabled, | ||
}: { | ||
disabled: boolean; | ||
}): NonNullable<AstroConfig['vite']['plugins']>[number] { | ||
const postfix = '.wasm?module'; | ||
export function cloudflareModuleLoader( | ||
enabled: Record<ImportType, boolean> | ||
): PluginOption & CloudflareModulePluginExtra { | ||
const enabledAdapters = cloudflareImportAdapters.filter((x) => enabled[x.extension]); | ||
let isDev = false; | ||
const MAGIC_STRING = '__CLOUDFLARE_ASSET__'; | ||
const replacements: Replacement[] = []; | ||
|
||
return { | ||
name: 'vite:wasm-module-loader', | ||
|
@@ -28,46 +40,54 @@ export function wasmModuleLoader({ | |
config(_, __) { | ||
// let vite know that file format and the magic import string is intentional, and will be handled in this plugin | ||
return { | ||
assetsInclude: ['**/*.wasm?module'], | ||
assetsInclude: enabledAdapters.map((x) => `**/*.${x.qualifiedExtension}`), | ||
build: { | ||
rollupOptions: { | ||
// mark the wasm files as external so that they are not bundled and instead are loaded from the files | ||
external: [/^__WASM_ASSET__.+\.wasm$/i, /^__WASM_ASSET__.+\.wasm.mjs$/i], | ||
external: enabledAdapters.map( | ||
(x) => new RegExp(`^${MAGIC_STRING}.+\\.${x.extension}.mjs$`, 'i') | ||
), | ||
}, | ||
}, | ||
}; | ||
}, | ||
|
||
load(id, _) { | ||
if (!id.endsWith(postfix)) { | ||
async load(id, _) { | ||
const suffix = id.split('.').at(-1); | ||
const importAdapter = cloudflareImportAdapters.find((x) => x.qualifiedExtension === suffix); | ||
if (!importAdapter) { | ||
return; | ||
} | ||
if (disabled) { | ||
const suffixType: ImportType = importAdapter.extension; | ||
const adapterEnabled = enabled[suffixType]; | ||
if (!adapterEnabled) { | ||
throw new Error( | ||
`WASM module's cannot be loaded unless you add \`wasmModuleImports: true\` to your astro config.` | ||
`Cloudflare module loading is experimental. The ${suffix} module cannot be loaded unless you add \`wasmModuleImports: true\` to your astro config.` | ||
); | ||
} | ||
|
||
const filePath = id.slice(0, -1 * '?module'.length); | ||
const filePath = id.replace(/\?module$/, ''); | ||
|
||
const data = fs.readFileSync(filePath); | ||
const data = await fs.readFile(filePath); | ||
const base64 = data.toString('base64'); | ||
|
||
const base64Module = `const wasmModule = new WebAssembly.Module(Uint8Array.from(atob("${base64}"), c => c.charCodeAt(0)));export default wasmModule;`; | ||
const inlineModule = importAdapter.asNodeModule(data); | ||
|
||
if (isDev) { | ||
// no need to wire up the assets in dev mode, just rewrite | ||
return base64Module; | ||
return inlineModule; | ||
} | ||
// just some shared ID | ||
const hash = hashString(base64); | ||
// emit the wasm binary as an asset file, to be picked up later by the esbuild bundle for the worker. | ||
// give it a shared deterministic name to make things easy for esbuild to switch on later | ||
const assetName = `${path.basename(filePath).split('.')[0]}.${hash}.wasm`; | ||
const assetName = `${path.basename(filePath).split('.')[0]}.${hash}.${ | ||
importAdapter.extension | ||
}`; | ||
this.emitFile({ | ||
type: 'asset', | ||
// put it explicitly in the _astro assets directory with `fileName` rather than `name` so that | ||
// vite doesn't give it a random id in its name. We need to be able to easily rewrite from | ||
// emit the data explicitly as an esset with `fileName` rather than `name` so that | ||
// vite doesn't give it a random hash-id in its name--We need to be able to easily rewrite from | ||
// the .mjs loader and the actual wasm asset later in the ESbuild for the worker | ||
fileName: assetName, | ||
source: data, | ||
|
@@ -77,51 +97,110 @@ export function wasmModuleLoader({ | |
const chunkId = this.emitFile({ | ||
type: 'prebuilt-chunk', | ||
fileName: `${assetName}.mjs`, | ||
code: base64Module, | ||
code: inlineModule, | ||
}); | ||
|
||
return `import wasmModule from "__WASM_ASSET__${chunkId}.wasm.mjs";export default wasmModule;`; | ||
return `import module from "${MAGIC_STRING}${chunkId}.${importAdapter.extension}.mjs";export default module;`; | ||
}, | ||
|
||
// output original wasm file relative to the chunk | ||
// output original wasm file relative to the chunk now that chunking has been achieved | ||
renderChunk(code, chunk, _) { | ||
if (isDev) return; | ||
|
||
if (!/__WASM_ASSET__/g.test(code)) return; | ||
|
||
const isPrerendered = Object.keys(chunk.modules).some( | ||
(moduleId) => this.getModuleInfo(moduleId)?.meta?.astro?.pageOptions?.prerender === true | ||
); | ||
|
||
let final = code; | ||
|
||
// SSR | ||
if (!isPrerendered) { | ||
final = code.replaceAll(/__WASM_ASSET__([A-Za-z\d]+).wasm.mjs/g, (s, assetId) => { | ||
const fileName = this.getFileName(assetId).replace(/\.mjs$/, ''); | ||
const relativePath = path | ||
.relative(path.dirname(chunk.fileName), fileName) | ||
.replaceAll('\\', '/'); // fix windows paths for import | ||
return `./${relativePath}`; | ||
}); | ||
if (!code.includes(MAGIC_STRING)) return; | ||
|
||
// SSR will need the .mjs suffix removed from the import before this works in cloudflare, but this is done as a final step | ||
// so as to support prerendering from nodejs runtime | ||
let replaced = code; | ||
for (const loader of enabledAdapters) { | ||
replaced = replaced.replaceAll( | ||
new RegExp(`${MAGIC_STRING}([A-Za-z\\d]+)\\.${loader.extension}\\.mjs`, 'g'), | ||
(s, assetId) => { | ||
const fileName = this.getFileName(assetId); | ||
const relativePath = path | ||
.relative(path.dirname(chunk.fileName), fileName) | ||
.replaceAll('\\', '/'); // fix windows paths for import | ||
|
||
// record this replacement for later, to adjust it to import the unbundled asset | ||
replacements.push({ | ||
cloudflareImport: relativePath.replace(/\.mjs$/, ''), | ||
nodejsImport: relativePath, | ||
}); | ||
return `./${relativePath}`; | ||
} | ||
); | ||
} | ||
if (replaced.includes(MAGIC_STRING)) { | ||
console.error('failed to replace', replaced); | ||
} | ||
|
||
// SSG | ||
if (isPrerendered) { | ||
final = code.replaceAll(/__WASM_ASSET__([A-Za-z\d]+).wasm.mjs/g, (s, assetId) => { | ||
const fileName = this.getFileName(assetId); | ||
const relativePath = path | ||
.relative(path.dirname(chunk.fileName), fileName) | ||
.replaceAll('\\', '/'); // fix windows paths for import | ||
return `./${relativePath}`; | ||
}); | ||
return { code: replaced }; | ||
}, | ||
|
||
/** | ||
* Once prerendering is complete, restore the imports in the _worker.js to cloudflare compatible ones, removing the .mjs suffix. | ||
* Walks the complete _worker.js/ directory and reads all files. | ||
*/ | ||
async afterBuildCompleted(config: AstroConfig) { | ||
async function doReplacement(dir: string) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is the ugliest part of the change. A quick final string replace file fixup so that the import path is what's needed for cloudflare. Its pretty well targeted based on the prebuilt replacements array, so at least its not a overly permissive string replace. This unfortunately does walk the entire tree of generated files though. I'm not familiar enough with deeper internals to know whether there's a method for switching rendering out more appropriately (I noticed that a bunch of built code end up being replaced with 'noop' after the prerender stages is complete. Is there perhaps a better way to do this? |
||
const files = await fs.readdir(dir, { withFileTypes: true }); | ||
for (const entry of files) { | ||
if (entry.isDirectory()) { | ||
await doReplacement(path.join(dir, entry.name)); | ||
} else if (entry.isFile() && entry.name.endsWith('.mjs')) { | ||
const filepath = path.join(dir, entry.name); | ||
let contents = await fs.readFile(filepath, 'utf-8'); | ||
for (const replacement of replacements) { | ||
contents = contents.replaceAll( | ||
replacement.nodejsImport, | ||
replacement.cloudflareImport | ||
); | ||
} | ||
await fs.writeFile(filepath, contents, 'utf-8'); | ||
} | ||
} | ||
} | ||
|
||
return { code: final }; | ||
await doReplacement(url.fileURLToPath(new URL('_worker.js', config.outDir))); | ||
}, | ||
}; | ||
} | ||
|
||
export type ImportType = 'wasm' | 'bin'; | ||
|
||
interface Replacement { | ||
// desired import for cloudflare | ||
cloudflareImport: string; | ||
// nodejs import that simulates a wasm/bin module | ||
nodejsImport: string; | ||
} | ||
|
||
interface ModuleImportAdapter { | ||
extension: ImportType; | ||
qualifiedExtension: string; | ||
asNodeModule(fileContents: Buffer): string; | ||
} | ||
|
||
const wasmImportAdapter: ModuleImportAdapter = { | ||
extension: 'wasm', | ||
qualifiedExtension: 'wasm?module', | ||
asNodeModule(fileContents: Buffer) { | ||
const base64 = fileContents.toString('base64'); | ||
return `const wasmModule = new WebAssembly.Module(Uint8Array.from(atob("${base64}"), c => c.charCodeAt(0)));export default wasmModule;`; | ||
}, | ||
}; | ||
|
||
const binImportAdapter: ModuleImportAdapter = { | ||
extension: 'bin', | ||
qualifiedExtension: 'bin', | ||
asNodeModule(fileContents: Buffer) { | ||
const base64 = fileContents.toString('base64'); | ||
return `const binModule = Uint8Array.from(atob("${base64}"), c => c.charCodeAt(0)).buffer;export default binModule;`; | ||
}, | ||
}; | ||
|
||
const cloudflareImportAdapters = [binImportAdapter, wasmImportAdapter]; | ||
|
||
/** | ||
* Returns a deterministic 32 bit hash code from a string | ||
*/ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import { type APIContext } from 'astro'; | ||
|
||
import {add} from '../util/add'; | ||
|
||
export const prerender = true | ||
|
||
export async function GET( | ||
context: APIContext | ||
): Promise<Response> { | ||
return new Response(JSON.stringify({ answer: add(20, 1) }), { | ||
status: 200, | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import { type APIContext } from 'astro'; | ||
import { add } from '../../../util/add'; | ||
|
||
|
||
export const prerender = false; | ||
|
||
export async function GET( | ||
context: APIContext | ||
): Promise<Response> { | ||
const a = Number.parseInt(context.params.a ?? "0"); | ||
const b = Number.parseInt(context.params.b ?? "0"); | ||
return new Response(JSON.stringify({ answer: add(a, b) }), { | ||
status: 200, | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import mod from './add.wasm?module'; | ||
|
||
|
||
const addModule: any = new WebAssembly.Instance(mod); | ||
|
||
export function add(a, b) { | ||
return addModule.exports.add(a, b); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This JSDoc is not correct, there is only one param which is an object.