Skip to content

Commit

Permalink
cloudflare wasm: add more complex test scenarios. Support wasm in sta…
Browse files Browse the repository at this point in the history
…tic site generation as well
  • Loading branch information
adrianlyjak committed Sep 21, 2023
1 parent dc19b0d commit 92c9e7c
Show file tree
Hide file tree
Showing 13 changed files with 133 additions and 50 deletions.
4 changes: 3 additions & 1 deletion packages/integrations/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
1 change: 0 additions & 1 deletion packages/integrations/cloudflare/src/env.d.ts

This file was deleted.

26 changes: 19 additions & 7 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
],
},
});
},
Expand Down Expand Up @@ -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
Expand All @@ -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];
Expand Down Expand Up @@ -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 })],
});
}

Expand Down Expand Up @@ -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
Expand Down
75 changes: 57 additions & 18 deletions packages/integrations/cloudflare/src/wasm-module-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 } },
};
},

Expand All @@ -39,42 +46,74 @@ 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;
`;
}
},

// output original wasm file relative to the chunk
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}`;
});

return { code: final };
},
};
}

/**
* 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export default defineConfig({
functionPerRoute: true,
wasmModuleImports: true
}),
output: 'server'
output: 'server',
vite: { build: { minify: false } }
});
Original file line number Diff line number Diff line change
@@ -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<EndpointOutput | Response> {

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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EndpointOutput | Response> {

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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EndpointOutput | Response> {
return new Response(JSON.stringify({ answer: addModule.exports.add(20, 1) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}
10 changes: 5 additions & 5 deletions packages/integrations/cloudflare/test/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
Expand Down Expand Up @@ -148,7 +148,7 @@ const isPortOpen = async (port) => {
resolve(true);
s.close();
});
s.listen(port);
s.listen(port, "0.0.0.0");
});
};

Expand Down
16 changes: 11 additions & 5 deletions packages/integrations/cloudflare/test/wasm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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} */
Expand Down Expand Up @@ -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');
});
});
});

0 comments on commit 92c9e7c

Please sign in to comment.