Skip to content

Commit

Permalink
feat(cloudflare): Make wasm module imports conditional
Browse files Browse the repository at this point in the history
  • Loading branch information
adrianlyjak committed Sep 15, 2023
1 parent e3b8db6 commit 84d1925
Show file tree
Hide file tree
Showing 10 changed files with 98 additions and 49 deletions.
34 changes: 24 additions & 10 deletions packages/integrations/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,21 +185,35 @@ export default defineConfig({

## WASM module imports

Cloudflare has native support for importing `.wasm` files [directly as ES modules](https://github.com/WebAssembly/esm-integration/tree/main/proposals/esm-integration). You can import a web assembly module in astro with `.wasm?module` syntax. This is in order to differentiate from the built in `.wasm?url` and `.wasm?init` bindings that won't work with cloudflare.
`wasmModuleImports: boolean`

```typescript
import { type APIContext, type EndpointOutput } from 'astro';
Cloudflare has native support for importing `.wasm` files [directly as ES modules](https://github.com/WebAssembly/esm-integration/tree/main/proposals/esm-integration). To enable importing them as modules in the cloudflare build and astro dev server, add `wasmModuleImports: true` to your config.

```diff
import {defineConfig} from "astro/config";
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
adapter: cloudflare({
+ wasmModuleImports: true
}),
output: 'server'
})
```

Once enabled, you can import a web assembly module in astro with a `.wasm?module` import

```javascript
// pages/add/[a]/[b].js
import mod from '../util/add.wasm?module';

// instantiate ahead of time to share 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(40, 2) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
export async function GET(context) {
const a = Number.parseInt(context.params.a!);
const b = Number.parseInt(context.params.b!);
return new Response(`${a} + ${b} = ${addModule.exports.add(a, b)}`);
}
```

Expand Down
1 change: 1 addition & 0 deletions packages/integrations/cloudflare/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="astro/client" />
48 changes: 26 additions & 22 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Options = {
* 'remote': use a dynamic real-live req.cf object, and env vars defined in wrangler.toml & .dev.vars (astro dev is enough)
*/
runtime?: 'off' | 'local' | 'remote';
wasmModuleImports?: boolean;
};

interface BuildConfig {
Expand Down Expand Up @@ -193,7 +194,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
},
vite: {
// load .wasm files as WebAssembly modules
plugins: [wasmModuleLoader()],
plugins: [wasmModuleLoader(!args?.wasmModuleImports)],
},
});
},
Expand Down Expand Up @@ -299,19 +300,20 @@ export default function createIntegration(args?: Options): AstroIntegration {
const outputUrl = new URL('$astro', _buildConfig.server);
const outputDir = fileURLToPath(outputUrl);
//
// Sadly, this needs to build esbuild for each depth of routes/entrypoints independently so that relative
// import paths to the assets are correct.
// This is inefficient:
// - is there any ways to import from the root to keep these consistent?
// - if not, would be nice to not group like this if there's no wasm... Could we determine that ahead of time?
// - or perhaps wasm should be entirely opt-in?
const entryPathsGroupedByDepth = entryPaths.reduce((sum, thisPath) => {
const depthFromRoot = thisPath.split(sep).length;
sum.set(depthFromRoot, (sum.get(depthFromRoot) || []).concat(thisPath));
return sum;
}, new Map<number, string[]>());

for (const pathsGroup of entryPathsGroupedByDepth.values()) {
// 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
const entryPathsGroupedByDepth = !args.wasmModuleImports
? [entryPaths]
: entryPaths
.reduce((sum, thisPath) => {
const depthFromRoot = thisPath.split(sep).length;
sum.set(depthFromRoot, (sum.get(depthFromRoot) || []).concat(thisPath));
return sum;
}, new Map<number, string[]>())
.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
const pagesDirname = relative(fileURLToPath(_buildConfig.server), pathsGroup[0]).split(
Expand Down Expand Up @@ -343,7 +345,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
logOverride: {
'ignored-bare-import': 'silent',
},
plugins: [rewriteWasmImportPath({ relativePathToAssets })],
plugins: !args?.wasmModuleImports ? [] : [rewriteWasmImportPath({ relativePathToAssets })],
});
}

Expand Down Expand Up @@ -406,13 +408,15 @@ export default function createIntegration(args?: Options): AstroIntegration {
logOverride: {
'ignored-bare-import': 'silent',
},
plugins: [
rewriteWasmImportPath({
relativePathToAssets: isModeDirectory
? relative(fileURLToPath(functionsUrl), fileURLToPath(assetsUrl))
: relative(fileURLToPath(_buildConfig.client), fileURLToPath(assetsUrl)),
}),
],
plugins: !args?.wasmModuleImports
? []
: [
rewriteWasmImportPath({
relativePathToAssets: isModeDirectory
? relative(fileURLToPath(functionsUrl), fileURLToPath(assetsUrl))
: relative(fileURLToPath(_buildConfig.client), fileURLToPath(assetsUrl)),
}),
],
});

// Rename to worker.js
Expand Down
9 changes: 8 additions & 1 deletion packages/integrations/cloudflare/src/wasm-module-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { type Plugin } from 'vite';
* 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
* @returns Vite plugin to load WASM tagged with '?module' as a WASM modules
*/
export function wasmModuleLoader(): Plugin {
export function wasmModuleLoader(disabled: boolean): Plugin {
const postfix = '.wasm?module';
let isDev = false;

Expand All @@ -30,6 +32,11 @@ export function wasmModuleLoader(): Plugin {
if (!id.endsWith(postfix)) {
return;
}
if (disabled) {
throw new Error(
`WASM module's cannot be loaded unless you add \`wasmModuleImports: true\` to your astro config.`
);
}

const filePath = id.slice(0, -1 * '?module'.length);
if (isDev) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
adapter: cloudflare({
mode: 'directory'
mode: 'directory',
wasmModuleImports: true
}),
output: 'server'
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
adapter: cloudflare({
mode: 'directory',
functionPerRoute: true
functionPerRoute: true,
wasmModuleImports: true
}),
output: 'server'
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
adapter: cloudflare(),
adapter: cloudflare({
wasmModuleImports: true
}),
output: 'server'
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { type APIContext, type EndpointOutput } from 'astro';
// @ts-ignore
import mod from '../util/add.wasm?module';
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(40, 2) }), {
const a = Number.parseInt(context.params.a!);
const b = Number.parseInt(context.params.b!);
return new Response(JSON.stringify({ answer: addModule.exports.add(a, b) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
Expand Down
12 changes: 6 additions & 6 deletions packages/integrations/cloudflare/test/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ export async function runCLI(
basePath,
{
silent,
retryTimeout = 3,
timeoutMillis = 1000,
backoffFactor = 3,
maxAttempts = 3,
timeoutMillis = 2500, // really short because it often seems to just hang on the first try, but work subsequently, no matter the wait
backoffFactor = 2, // | - 2.5s -- 5s ---- 10s -> onTimeout
onTimeout = (ex) => {
new Error(`Timed out starting the wrangler CLI after ${retryTimeout} tries.`, { cause: ex });
new Error(`Timed out starting the wrangler CLI after ${maxAttempts} tries.`, { cause: ex });
},
}
) {
let triesRemaining = retryTimeout;
let triesRemaining = maxAttempts;
let timeout = timeoutMillis;
let cli;
let lastErr;
Expand All @@ -58,7 +58,7 @@ export async function runCLI(
}

async function tryRunCLI(basePath, { silent, timeout }) {
const port = await getNextOpenPort(lastPort + 1);
const port = await getNextOpenPort(lastPort);
lastPort = port;

const fixtureDir = fileURLToPath(new URL(`${basePath}`, import.meta.url));
Expand Down
26 changes: 22 additions & 4 deletions packages/integrations/cloudflare/test/wasm.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { loadFixture, runCLI } from './test-utils.js';
import { expect } from 'chai';
import cloudflare from '../dist/index.js';


describe('Wasm import', () => {
describe('in cloudflare workerd', () => {
Expand Down Expand Up @@ -29,7 +31,7 @@ describe('Wasm import', () => {
});

it('can render', async () => {
let res = await fetch(`http://127.0.0.1:${cli.port}/`);
let res = await fetch(`http://127.0.0.1:${cli.port}/add/40/2`);
expect(res.status).to.equal(200);
const json = await res.json();
expect(json).to.deep.equal({ answer: 42 });
Expand All @@ -44,18 +46,34 @@ describe('Wasm import', () => {
fixture = await loadFixture({
root: './fixtures/wasm/',
});
devServer = await fixture.startDevServer();
devServer = undefined;
});

after(async () => {
await devServer?.stop();
});

it('can serve wasm', async () => {
let res = await fetch(`http://localhost:${devServer.address.port}/`);
devServer = await fixture.startDevServer();
let res = await fetch(`http://localhost:${devServer.address.port}/add/60/3`);
expect(res.status).to.equal(200);
const json = await res.json();
expect(json).to.deep.equal({ answer: 42 });
expect(json).to.deep.equal({ answer: 63 });
});

it('fails to build intelligently when wasm is disabled', async () => {
let ex;
try {
devServer = await fixture.build({
adapter: cloudflare({
wasmModuleImports: false
}),
});
} catch (err) {
ex = err
}
expect(ex?.message).to.have.string('add `wasmModuleImports: true` to your astro config')
});

});
});

0 comments on commit 84d1925

Please sign in to comment.