Skip to content

Commit

Permalink
feat(@astrojs/cloudflare): Add support for wasm module imports (#8542)
Browse files Browse the repository at this point in the history
Co-authored-by: Sarah Rainsberger <[email protected]>
  • Loading branch information
adrianlyjak and sarah11918 authored Sep 22, 2023
1 parent b1310e6 commit faeead4
Show file tree
Hide file tree
Showing 33 changed files with 788 additions and 123 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
1 change: 1 addition & 0 deletions packages/astro/test/test-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ process.env.ASTRO_TELEMETRY_DISABLED = true;
* @typedef {Object} Fixture
* @property {typeof build} build
* @property {(url: string) => string} resolveUrl
* @property {(path: string) => Promise<boolean>} pathExists
* @property {(url: string, opts: Parameters<typeof fetch>[1]) => Promise<Response>} fetch
* @property {(path: string) => Promise<string>} readFile
* @property {(path: string, updater: (content: string) => string) => Promise<void>} writeFile
Expand Down
43 changes: 43 additions & 0 deletions packages/integrations/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,49 @@ export default defineConfig({
});
```

## Wasm module imports

`wasmModuleImports: boolean`

default: `false`

Whether or not to import `.wasm` files [directly as ES modules](https://github.com/WebAssembly/esm-integration/tree/main/proposals/esm-integration).

Add `wasmModuleImports: true` to `astro.config.mjs` to enable in both the Cloudflare build and the Astro dev server.

```diff
// astro.config.mjs
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.

The following is an example of importing a Wasm module that then responds to requests by adding the request's number parameters together.

```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) {
const a = Number.parseInt(context.params.a);
const b = Number.parseInt(context.params.b);
return new Response(`${addModule.exports.add(a, b)}`);
}
```

While this example is trivial, Wasm can be used to accelerate computationally intensive operations which do not involve significant I/O such as embedding an image processing library.

## 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.1.2"
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
146 changes: 115 additions & 31 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 @@ -26,11 +27,13 @@ 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 {
server: URL;
client: URL;
assets: string;
serverEntry: string;
split?: boolean;
}
Expand Down Expand Up @@ -189,6 +192,15 @@ export default function createIntegration(args?: Options): AstroIntegration {
serverEntry: '_worker.mjs',
redirects: false,
},
vite: {
// load .wasm files as WebAssembly modules
plugins: [
wasmModuleLoader({
disabled: !args?.wasmModuleImports,
assetsDirectory: config.build.assets,
}),
],
},
});
},
'astro:config:done': ({ setAdapter, config }) => {
Expand Down Expand Up @@ -280,6 +292,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,36 +304,71 @@ 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'],
external: [
'node:assert',
'node:async_hooks',
'node:buffer',
'node:diagnostics_channel',
'node:events',
'node:path',
'node:process',
'node:stream',
'node:string_decoder',
'node:util',
],
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, 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 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
.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" 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];
const absolutePagesDirname = fileURLToPath(new URL(pagesDirname, _buildConfig.server));
const urlWithinFunctions = new URL(
relative(absolutePagesDirname, pathsGroup[0]),
functionsUrl
);
const relativePathToAssets = relative(
dirname(fileURLToPath(urlWithinFunctions)),
fileURLToPath(assetsUrl)
);
await esbuild.build({
target: 'es2020',
platform: 'browser',
conditions: ['workerd', 'worker', 'browser'],
external: [
'node:assert',
'node:async_hooks',
'node:buffer',
'node:diagnostics_channel',
'node:events',
'node:path',
'node:process',
'node:stream',
'node:string_decoder',
'node:util',
],
entryPoints: pathsGroup,
outbase: absolutePagesDirname,
outdir: outputDir,
allowOverwrite: true,
format: 'esm',
bundle: true,
minify: _config.vite?.build?.minify !== false,
banner: {
js: SHIM,
},
logOverride: {
'ignored-bare-import': 'silent',
},
plugins: !args?.wasmModuleImports
? []
: [rewriteWasmImportPath({ relativePathToAssets })],
});
}

const outputFiles: Array<string> = await glob(`**/*`, {
cwd: outputDir,
Expand Down Expand Up @@ -393,6 +441,15 @@ export default function createIntegration(args?: Options): AstroIntegration {
logOverride: {
'ignored-bare-import': 'silent',
},
plugins: !args?.wasmModuleImports
? []
: [
rewriteWasmImportPath({
relativePathToAssets: isModeDirectory
? relative(fileURLToPath(functionsUrl), fileURLToPath(assetsUrl))
: relative(fileURLToPath(_buildConfig.client), fileURLToPath(assetsUrl)),
}),
],
});

// Rename to worker.js
Expand Down Expand Up @@ -602,3 +659,30 @@ 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.mjs$/ }, (args) => {
const updatedPath = [
relativePathToAssets.replaceAll('\\', '/'),
basename(args.path).replace(/\.mjs$/, ''),
].join('/');

return {
path: updatedPath, // change the reference to the changed module
external: true, // mark it as external in the bundle
};
});
},
};
}
Loading

0 comments on commit faeead4

Please sign in to comment.