diff --git a/packages/integrations/cloudflare/README.md b/packages/integrations/cloudflare/README.md index 91537134bc7e..5ae5271cd08d 100644 --- a/packages/integrations/cloudflare/README.md +++ b/packages/integrations/cloudflare/README.md @@ -209,7 +209,7 @@ export default defineConfig({ }) ``` -Once enabled, you can import a web assembly module in astro with a `.wasm?module` import +Once enabled, you can import a web assembly module in astro with a `.wasm?module` import. The integration supports WASM module imports in both server and hybrid mode for a consistent development experience. ```javascript // pages/add/[a]/[b].js @@ -225,6 +225,8 @@ export async function GET(context) { } ``` + + ## Headers, Redirects and function invocation routes Cloudflare has support for adding custom [headers](https://developers.cloudflare.com/pages/platform/headers/), configuring static [redirects](https://developers.cloudflare.com/pages/platform/redirects/) and defining which routes should [invoke functions](https://developers.cloudflare.com/pages/platform/functions/routing/#function-invocation-routes). Cloudflare looks for `_headers`, `_redirects`, and `_routes.json` files in your build output directory to configure these features. This means they should be placed in your Astro project’s `public/` directory. diff --git a/packages/integrations/cloudflare/src/env.d.ts b/packages/integrations/cloudflare/src/env.d.ts deleted file mode 100644 index 8c34fb45e7cf..000000000000 --- a/packages/integrations/cloudflare/src/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// \ No newline at end of file diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 89a308da26c8..4ae43a110010 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -194,7 +194,12 @@ export default function createIntegration(args?: Options): AstroIntegration { }, vite: { // load .wasm files as WebAssembly modules - plugins: [wasmModuleLoader(!args?.wasmModuleImports)], + plugins: [ + wasmModuleLoader({ + disabled: !args?.wasmModuleImports, + assetsDirectory: config.build.assets, + }), + ], }, }); }, @@ -302,7 +307,9 @@ export default function createIntegration(args?: Options): AstroIntegration { // // Sadly, when wasmModuleImports is enabled, this needs to build esbuild for each depth of routes/entrypoints // independently so that relative import paths to the assets are the correct depth of '../' traversals - // This is inefficient, so wasmModuleImports is opt-in + // This is inefficient, so wasmModuleImports is opt-in. This could potentially be improved in the future by + // taking advantage of the esbuild "onEnd" hook to rewrite import code per entry point relative to where the final + // destination of the entrypoint is const entryPathsGroupedByDepth = !args.wasmModuleImports ? [entryPaths] : entryPaths @@ -314,8 +321,8 @@ export default function createIntegration(args?: Options): AstroIntegration { .values(); for (const pathsGroup of entryPathsGroupedByDepth) { - // for some reason this exports to "entry.pages" instead of "pages" on windows. - // look up the pages with relative logic + // for some reason this exports to "entry.pages" on windows instead of "pages" on unix environments. + // This deduces the name of the "pages" build directory const pagesDirname = relative(fileURLToPath(_buildConfig.server), pathsGroup[0]).split( sep )[0]; @@ -357,7 +364,9 @@ export default function createIntegration(args?: Options): AstroIntegration { logOverride: { 'ignored-bare-import': 'silent', }, - plugins: !args?.wasmModuleImports ? [] : [rewriteWasmImportPath({ relativePathToAssets })], + plugins: !args?.wasmModuleImports + ? [] + : [rewriteWasmImportPath({ relativePathToAssets })], }); } @@ -663,8 +672,11 @@ function rewriteWasmImportPath({ return { name: 'wasm-loader', setup(build) { - build.onResolve({ filter: /.*\.wasm$/ }, (args) => { - const updatedPath = [relativePathToAssets, basename(args.path)].join('/'); + build.onResolve({ filter: /.*\.wasm.mjs$/ }, (args) => { + const updatedPath = [ + relativePathToAssets.replaceAll('\\', '/'), + basename(args.path).replace(/\.mjs$/, ''), + ].join('/'); return { path: updatedPath, // change the reference to the changed module diff --git a/packages/integrations/cloudflare/src/wasm-module-loader.ts b/packages/integrations/cloudflare/src/wasm-module-loader.ts index e203b8c63dc2..7d34d48c3df7 100644 --- a/packages/integrations/cloudflare/src/wasm-module-loader.ts +++ b/packages/integrations/cloudflare/src/wasm-module-loader.ts @@ -8,9 +8,16 @@ import { type Plugin } from 'vite'; * 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' * @returns Vite plugin to load WASM tagged with '?module' as a WASM modules */ -export function wasmModuleLoader(disabled: boolean): Plugin { +export function wasmModuleLoader({ + disabled, + assetsDirectory, +}: { + disabled: boolean; + assetsDirectory: string; +}): Plugin { const postfix = '.wasm?module'; let isDev = false; @@ -24,7 +31,7 @@ export function wasmModuleLoader(disabled: boolean): Plugin { // let vite know that file format and the magic import string is intentional, and will be handled in this plugin return { assetsInclude: ['**/*.wasm?module'], - build: { rollupOptions: { external: /^__WASM_ASSET__.+\.wasm$/i } }, + build: { rollupOptions: { external: /^__WASM_ASSET__.+\.wasm\.mjs$/i } }, }; }, @@ -39,26 +46,43 @@ export function wasmModuleLoader(disabled: boolean): Plugin { } const filePath = id.slice(0, -1 * '?module'.length); + + const data = fs.readFileSync(filePath); + const base64 = data.toString('base64'); + + const base64Module = ` +const wasmModule = new WebAssembly.Module(Uint8Array.from(atob("${base64}"), c => c.charCodeAt(0))); +export default wasmModule +`; if (isDev) { - // when running in vite serve, do the file system reading dance - return ` - import fs from "node:fs" - const wasmModule = new WebAssembly.Module(fs.readFileSync("${filePath}")); - export default wasmModule; - `; + // no need to wire up the assets in dev mode, just rewrite + return base64Module; } else { - // build to just a re-export of the original asset contents - const assetId = this.emitFile({ + // just some shared ID + let 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'; + this.emitFile({ type: 'asset', - name: path.basename(filePath), + // 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 + // the .mjs loader and the actual wasm asset later in the ESbuild for the worker + fileName: path.join(assetsDirectory, assetName), source: fs.readFileSync(filePath), }); - // import from magic asset string to be replaced later + // however, by default, the SSG generator cannot import the .wasm as a module, so embed as a base64 string + const chunkId = this.emitFile({ + type: 'prebuilt-chunk', + fileName: assetName + '.mjs', + code: base64Module, + }); + return ` - import init from "__WASM_ASSET__${assetId}.wasm" - export default init - `; +import wasmModule from "__WASM_ASSET__${chunkId}.wasm.mjs"; +export default wasmModule; + `; } }, @@ -66,11 +90,13 @@ export function wasmModuleLoader(disabled: boolean): Plugin { renderChunk(code, chunk, _) { if (isDev) return; - if (!/__WASM_ASSET__([a-z\d]+)\.wasm/g.test(code)) return; + if (!/__WASM_ASSET__/g.test(code)) return; - const final = code.replaceAll(/__WASM_ASSET__([a-z\d]+)\.wasm/g, (s, assetId) => { + const final = code.replaceAll(/__WASM_ASSET__([a-z\d]+).wasm.mjs/g, (s, assetId) => { const fileName = this.getFileName(assetId); - const relativePath = path.relative(path.dirname(chunk.fileName), fileName); + const relativePath = path + .relative(path.dirname(chunk.fileName), fileName) + .replaceAll('\\', '/'); // fix windows paths for import return `./${relativePath}`; }); @@ -78,3 +104,16 @@ export function wasmModuleLoader(disabled: boolean): Plugin { }, }; } + +/** + * Returns a deterministic 32 bit hash code from a string + */ +function hashString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash &= hash; // Convert to 32bit integer + } + return new Uint32Array([hash])[0].toString(36); +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/astro.config.mjs index e3acfd188bb9..7f741d884a1d 100644 --- a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/astro.config.mjs +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/astro.config.mjs @@ -7,5 +7,6 @@ export default defineConfig({ functionPerRoute: true, wasmModuleImports: true }), - output: 'server' + output: 'server', + vite: { build: { minify: false } } }); diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/deeply/nested/route.ts b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/deeply/nested/route.ts index c80b6dbca9b8..20797c0c6423 100644 --- a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/deeply/nested/route.ts +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/deeply/nested/route.ts @@ -1,15 +1,11 @@ import { type APIContext, type EndpointOutput } from 'astro'; -// @ts-ignore -import mod from '../../../util/add.wasm?module'; - -const addModule: any = new WebAssembly.Instance(mod); - +import { add } from '../../../util/add'; export async function GET( context: APIContext ): Promise { - return new Response(JSON.stringify({ answer: addModule.exports.add(80, 4) }), { + return new Response(JSON.stringify({ answer: add(80, 4) }), { status: 200, headers: { 'Content-Type': 'application/json', diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/index.ts b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/index.ts index 2c9ff6d4470d..b6417dde999e 100644 --- a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/index.ts +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/index.ts @@ -1,15 +1,11 @@ import { type APIContext, type EndpointOutput } from 'astro'; -// @ts-ignore -import mod from '../util/add.wasm?module'; - -const addModule: any = new WebAssembly.Instance(mod); - +import { add } from '../util/add'; export async function GET( context: APIContext ): Promise { - return new Response(JSON.stringify({ answer: addModule.exports.add(40, 2) }), { + return new Response(JSON.stringify({ answer: add(40, 2) }), { status: 200, headers: { 'Content-Type': 'application/json', diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/add.ts b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/add.ts new file mode 100644 index 000000000000..ee336277b761 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/add.ts @@ -0,0 +1,6 @@ +// extra layer of indirection to stress the esbuild +import { addImpl } from "./indirection"; + +export function add(a: number, b: number): number { + return addImpl(a, b); +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/indirection.ts b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/indirection.ts new file mode 100644 index 000000000000..6fbb04c4949d --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/indirection.ts @@ -0,0 +1,9 @@ +// extra layer of indirection to stress the esbuild +// @ts-ignore +import mod from './add.wasm?module'; + +const addModule: any = new WebAssembly.Instance(mod); + +export function addImpl(a: number, b: number): number { + return addModule.exports.add(a, b); +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/add/[a]/[b].ts b/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/add/[a]/[b].ts index ddc4c5ce4fd9..130b2b2a4b7b 100644 --- a/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/add/[a]/[b].ts +++ b/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/add/[a]/[b].ts @@ -4,6 +4,7 @@ import mod from '../../../util/add.wasm?module'; const addModule: any = new WebAssembly.Instance(mod); +export const prerender = false; export async function GET( context: APIContext diff --git a/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/hybrid.ts b/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/hybrid.ts new file mode 100644 index 000000000000..7bb470dffd8e --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/hybrid.ts @@ -0,0 +1,16 @@ +import { type APIContext, type EndpointOutput } from 'astro'; +// @ts-ignore +import mod from '../util/add.wasm?module'; + +const addModule: any = new WebAssembly.Instance(mod); + +export async function GET( + context: APIContext +): Promise { + return new Response(JSON.stringify({ answer: addModule.exports.add(20, 1) }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/packages/integrations/cloudflare/test/test-utils.js b/packages/integrations/cloudflare/test/test-utils.js index d55e0f78ba57..50226c0c1e47 100644 --- a/packages/integrations/cloudflare/test/test-utils.js +++ b/packages/integrations/cloudflare/test/test-utils.js @@ -41,13 +41,13 @@ export async function runCLI( let cli; let lastErr; while (triesRemaining > 0) { - cli = await tryRunCLI(basePath, { silent, timeout }); + cli = await tryRunCLI(basePath, { silent, timeout, forceRotatePort: triesRemaining !== maxAttempts }); try { await cli.ready; return cli; } catch (err) { lastErr = err; - console.error(err.message + ' after ' + timeout + 'ms'); + console.error((err.message || err.name || err) + ' after ' + timeout + 'ms'); cli.stop(); triesRemaining -= 1; timeout *= backoffFactor; @@ -57,8 +57,8 @@ export async function runCLI( return cli; } -async function tryRunCLI(basePath, { silent, timeout }) { - const port = await getNextOpenPort(lastPort); +async function tryRunCLI(basePath, { silent, timeout, forceRotatePort = false }) { + const port = await getNextOpenPort(lastPort + (forceRotatePort ? 1 : 0)); lastPort = port; const fixtureDir = fileURLToPath(new URL(`${basePath}`, import.meta.url)); @@ -148,7 +148,7 @@ const isPortOpen = async (port) => { resolve(true); s.close(); }); - s.listen(port); + s.listen(port, "0.0.0.0"); }); }; diff --git a/packages/integrations/cloudflare/test/wasm.test.js b/packages/integrations/cloudflare/test/wasm.test.js index 8cb5fd718686..279a00cd1bac 100644 --- a/packages/integrations/cloudflare/test/wasm.test.js +++ b/packages/integrations/cloudflare/test/wasm.test.js @@ -2,7 +2,6 @@ import { loadFixture, runCLI } from './test-utils.js'; import { expect } from 'chai'; import cloudflare from '../dist/index.js'; - describe('Wasm import', () => { describe('in cloudflare workerd', () => { /** @type {import('./test-utils.js').Fixture} */ @@ -64,16 +63,23 @@ describe('Wasm import', () => { it('fails to build intelligently when wasm is disabled', async () => { let ex; try { - devServer = await fixture.build({ + await fixture.build({ adapter: cloudflare({ - wasmModuleImports: false + wasmModuleImports: false, }), }); } catch (err) { - ex = err + ex = err; } - expect(ex?.message).to.have.string('add `wasmModuleImports: true` to your astro config') + expect(ex?.message).to.have.string('add `wasmModuleImports: true` to your astro config'); }); + it('can import wasm in both SSR and SSG pages', async () => { + await fixture.build({ output: 'hybrid' }); + const staticContents = await fixture.readFile('./hybrid'); + expect(staticContents).to.be.equal('{"answer":21}'); + const assets = await fixture.readdir('./_astro'); + expect(assets.map((x) => x.slice(x.lastIndexOf('.')))).to.contain('.wasm'); + }); }); });