Skip to content

Commit

Permalink
feat(cloudflare): Add support for wasm module imports for withastro#8541
Browse files Browse the repository at this point in the history
  • Loading branch information
adrianlyjak committed Sep 13, 2023
1 parent d939878 commit 094a474
Show file tree
Hide file tree
Showing 28 changed files with 561 additions and 114 deletions.
5 changes: 5 additions & 0 deletions .changeset/shy-cycles-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': minor
---

Add support for loading wasm modules in the cloudflare adapter
24 changes: 24 additions & 0 deletions packages/integrations/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,30 @@ 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.

```typescript
import { type APIContext, type EndpointOutput } from 'astro';
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) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}
```

## 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
4 changes: 2 additions & 2 deletions packages/integrations/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"dotenv": "^16.3.1",
"esbuild": "^0.19.2",
"find-up": "^6.3.0",
"tiny-glob": "^0.2.9"
"tiny-glob": "^0.2.9",
"vite": "^4.4.9"
},
"peerDependencies": {
"astro": "workspace:^3.0.13"
Expand All @@ -59,7 +60,6 @@
"astro-scripts": "workspace:*",
"chai": "^4.3.7",
"cheerio": "1.0.0-rc.12",
"kill-port": "^2.0.1",
"mocha": "^10.2.0",
"wrangler": "^3.5.1"
}
Expand Down
99 changes: 80 additions & 19 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { AstroError } from 'astro/errors';
import esbuild from 'esbuild';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { sep } from 'node:path';
import { basename, dirname, relative, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import glob from 'tiny-glob';
import { getEnvVars } from './parser.js';
import { wasmModuleLoader } from './wasm-module-loader.js';

export type { AdvancedRuntime } from './server.advanced.js';
export type { DirectoryRuntime } from './server.directory.js';
Expand All @@ -31,6 +32,7 @@ type Options = {
interface BuildConfig {
server: URL;
client: URL;
assets: string;
serverEntry: string;
split?: boolean;
}
Expand Down Expand Up @@ -189,6 +191,10 @@ export default function createIntegration(args?: Options): AstroIntegration {
serverEntry: '_worker.mjs',
redirects: false,
},
vite: {
// load .wasm files as WebAssembly modules
plugins: [wasmModuleLoader()],
}
});
},
'astro:config:done': ({ setAdapter, config }) => {
Expand Down Expand Up @@ -280,6 +286,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
},
'astro:build:done': async ({ pages, routes, dir }) => {
const functionsUrl = new URL('functions/', _config.root);
const assetsUrl = new URL(_buildConfig.assets, _buildConfig.client);

if (isModeDirectory) {
await fs.promises.mkdir(functionsUrl, { recursive: true });
Expand All @@ -291,24 +298,47 @@ export default function createIntegration(args?: Options): AstroIntegration {
const entryPaths = entryPointsURL.map((entry) => fileURLToPath(entry));
const outputUrl = new URL('$astro', _buildConfig.server);
const outputDir = fileURLToPath(outputUrl);

await esbuild.build({
target: 'es2020',
platform: 'browser',
conditions: ['workerd', 'worker', 'browser'],
entryPoints: entryPaths,
outdir: outputDir,
allowOverwrite: true,
format: 'esm',
bundle: true,
minify: _config.vite?.build?.minify !== false,
banner: {
js: SHIM,
},
logOverride: {
'ignored-bare-import': 'silent',
},
});
//
// 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()) {
const urlWithinFunctions = new URL(
relative(fileURLToPath(new URL('pages', _buildConfig.server)), pathsGroup[0]),
functionsUrl
);
const relativePathToAssets = relative(
dirname(fileURLToPath(urlWithinFunctions)),
fileURLToPath(assetsUrl)
);
await esbuild.build({
target: 'es2020',
platform: 'browser',
conditions: ['workerd', 'worker', 'browser'],
entryPoints: pathsGroup,
outbase: fileURLToPath(new URL('pages', _buildConfig.server)),
outdir: outputDir,
allowOverwrite: true,
format: 'esm',
bundle: true,
minify: _config.vite?.build?.minify !== false,
banner: {
js: SHIM,
},
logOverride: {
'ignored-bare-import': 'silent',
},
plugins: [rewriteWasmImportPath({ relativePathToAssets })],
});
}

const outputFiles: Array<string> = await glob(`**/*`, {
cwd: outputDir,
Expand Down Expand Up @@ -369,6 +399,13 @@ 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)),
}),
],
});

// Rename to worker.js
Expand Down Expand Up @@ -578,3 +615,27 @@ function deduplicatePatterns(patterns: string[]) {
return true;
});
}

/**
*
* @param relativePathToAssets - relative path from the final location for the current esbuild output bundle, to the assets directory.
*/
function rewriteWasmImportPath({
relativePathToAssets,
}: {
relativePathToAssets: string;
}): esbuild.Plugin {
return {
name: 'wasm-loader',
setup(build) {
build.onResolve({ filter: /.*\.wasm$/ }, (args) => {
const updatedPath = [relativePathToAssets, basename(args.path)].join('/');

return {
path: updatedPath, // change the reference to the changed module
external: true, // mark it as external in the bundle
};
});
},
};
}
73 changes: 73 additions & 0 deletions packages/integrations/cloudflare/src/wasm-module-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
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/
* @returns Vite plugin to load WASM tagged with '?module' as a WASM modules
*/
export function wasmModuleLoader(): Plugin {
const postfix = '.wasm?module';
let isDev = false;

return {
name: 'vite:wasm-module-loader',
enforce: 'pre',
configResolved(config) {
isDev = config.command === 'serve';
},
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'],
build: { rollupOptions: { external: /^__WASM_ASSET__.+\.wasm$/i } },
};
},

load(id, _) {
if (!id.endsWith(postfix)) {
return;
}

const filePath = id.slice(0, -1 * '?module'.length);
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;
`;
} else {
// build to just a re-export of the original asset contents
const assetId = this.emitFile({
type: 'asset',
name: path.basename(filePath),
source: fs.readFileSync(filePath),
});

// import from magic asset string to be replaced later
return `
import init from "__WASM_ASSET__${assetId}.wasm"
export default init
`;
}
},

// 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;

const final = code.replaceAll(/__WASM_ASSET__([a-z\d]+)\.wasm/g, (s, assetId) => {
const fileName = this.getFileName(assetId);
const relativePath = path.relative(path.dirname(chunk.fileName), fileName);
return `./${relativePath}`;
});

return { code: final };
},
};
}
12 changes: 4 additions & 8 deletions packages/integrations/cloudflare/test/basics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,16 @@ describe('Basic app', () => {
});
await fixture.build();

cli = await runCLI('./fixtures/basics/', { silent: true, port: 8789 });
await cli.ready.catch((e) => {
console.log(e);
// if fail to start, skip for now as it's very flaky
this.skip();
});
cli = await runCLI('./fixtures/basics/', { silent: true });
await cli.ready;
});

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

it('can render', async () => {
let res = await fetch(`http://127.0.0.1:8789/`);
let res = await fetch(`http://127.0.0.1:${cli.port}/`);
expect(res.status).to.equal(200);
let html = await res.text();
let $ = cheerio.load(html);
Expand Down
14 changes: 5 additions & 9 deletions packages/integrations/cloudflare/test/cf.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,16 @@ describe('Wrangler Cloudflare Runtime', () => {
});
await fixture.build();

cli = await runCLI('./fixtures/cf/', { silent: true, port: 8786 });
await cli.ready.catch((e) => {
console.log(e);
// if fail to start, skip for now as it's very flaky
this.skip();
});
cli = await runCLI('./fixtures/cf/', { silent: true });
await cli.ready;
});

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

it('Load cf and caches API', async () => {
let res = await fetch(`http://127.0.0.1:8786/`);
let res = await fetch(`http://127.0.0.1:${cli.port}/`);
expect(res.status).to.equal(200);
let html = await res.text();
let $ = cheerio.load(html);
Expand Down Expand Up @@ -63,7 +59,7 @@ describe('Astro Cloudflare Runtime', () => {
});

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

it('Populates CF, Vars & Bindings', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
adapter: cloudflare({
mode: 'directory'
}),
output: 'server'
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-wasm-function-per-route",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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(40, 2) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
adapter: cloudflare({
mode: 'directory',
functionPerRoute: true
}),
output: 'server'
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-wasm-directory",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "workspace:*"
}
}
Loading

0 comments on commit 094a474

Please sign in to comment.