Skip to content
This repository has been archived by the owner on Feb 10, 2025. It is now read-only.

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(cloudflare): handle wasm imports shared between ssg and ssr
Browse files Browse the repository at this point in the history
adrianlyjak committed Apr 25, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 38c32c9 commit 4d6ce4a
Showing 7 changed files with 169 additions and 70 deletions.
5 changes: 5 additions & 0 deletions .changeset/lovely-mails-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': patch
---

Fixes build errors when wasm modules are imported from a file that is shared in both prerendered static pages and server side rendered pages
23 changes: 18 additions & 5 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
@@ -13,12 +13,16 @@ import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
import { AstroError } from 'astro/errors';
import { walk } from 'estree-walker';
import MagicString from 'magic-string';
import type { PluginOption } from 'vite';
import { getPlatformProxy } from 'wrangler';
import { createRoutesFile, getParts } from './utils/generate-routes-json.js';
import { setImageConfig } from './utils/image-config.js';
import { mutateDynamicPageImportsInPlace, mutatePageMapInPlace } from './utils/index.js';
import { NonServerChunkDetector } from './utils/non-server-chunk-detector.js';
import { wasmModuleLoader } from './utils/wasm-module-loader.js';
import {
type CloudflareModulePluginExtra,
cloudflareModuleLoader,
} from './utils/wasm-module-loader.js';

export type { Runtime } from './entrypoints/server.js';

@@ -62,13 +66,23 @@ export type Options = {
/** Configuration persistence settings. Default '.wrangler/state/v3' */
persist?: boolean | { path: string };
};
/** Enable WebAssembly support */
/**
* Allow bundling cloudflare worker specific file types
* https://developers.cloudflare.com/workers/wrangler/bundling/
*/
wasmModuleImports?: boolean;
};

export default function createIntegration(args?: Options): AstroIntegration {
let _config: AstroConfig;

const cloudflareModulePlugin: PluginOption & CloudflareModulePluginExtra = cloudflareModuleLoader(
{
wasm: args?.wasmModuleImports ?? false,
bin: false,
}
);

// Initialize the unused chunk analyzer as a shared state between hooks.
// The analyzer is used on earlier hooks to collect information about used hooks on a Vite plugin
// and then later after the full build to clean up unused chunks, so it has to be shared between them.
@@ -91,9 +105,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
vite: {
// load .wasm files as WebAssembly modules
plugins: [
wasmModuleLoader({
disabled: !args?.wasmModuleImports,
}),
cloudflareModulePlugin,
chunkAnalyzer.getPlugin(),
{
name: 'dynamic-imports-analyzer',
@@ -274,6 +286,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
}
},
'astro:build:done': async ({ pages, routes, dir, logger }) => {
await cloudflareModulePlugin.afterBuildCompleted(_config);
const PLATFORM_FILES = ['_headers', '_redirects', '_routes.json'];
if (_config.base !== '/') {
for (const file of PLATFORM_FILES) {
182 changes: 128 additions & 54 deletions packages/cloudflare/src/utils/wasm-module-loader.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
import * as fs from 'node:fs';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as url from 'node:url';
import type { AstroConfig } from 'astro';
import type { PluginOption } from 'vite';

export interface CloudflareModulePluginExtra {
afterBuildCompleted(config: AstroConfig): Promise<void>;
}
/**
* Enables support for various non-standard extensions in module imports within cloudflare workers.
*
* See https://developers.cloudflare.com/workers/wrangler/bundling/ for reference
*
* This adds supports for imports in the following formats:
* - .wasm?module
* - .bin
*
* 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
* @param assetsDirectory - the folder name for the assets directory in the build directory. Usually '_astro'
* @param bin - if true, will load '.bin' imports as Uint8Arrays, otherwise will throw errors when encountered to clarify that it must be enabled
* @param wasm - if true, will load '.wasm?module' imports as Uint8Arrays, otherwise will throw errors when encountered to clarify that it must be enabled
* @returns Vite plugin to load WASM tagged with '?module' as a WASM modules
*/
export function wasmModuleLoader({
disabled,
}: {
disabled: boolean;
}): NonNullable<AstroConfig['vite']['plugins']>[number] {
const postfix = '.wasm?module';
export function cloudflareModuleLoader(
enabled: Record<ImportType, boolean>
): PluginOption & CloudflareModulePluginExtra {
const enabledAdapters = cloudflareImportAdapters.filter((x) => enabled[x.extension]);
let isDev = false;
const MAGIC_STRING = '__CLOUDFLARE_ASSET__';
const replacements: Replacement[] = [];

return {
name: 'vite:wasm-module-loader',
@@ -28,46 +40,54 @@ export function wasmModuleLoader({
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'],
assetsInclude: enabledAdapters.map((x) => `**/*.${x.qualifiedExtension}`),
build: {
rollupOptions: {
// mark the wasm files as external so that they are not bundled and instead are loaded from the files
external: [/^__WASM_ASSET__.+\.wasm$/i, /^__WASM_ASSET__.+\.wasm.mjs$/i],
external: enabledAdapters.map(
(x) => new RegExp(`^${MAGIC_STRING}.+\\.${x.extension}.mjs$`, 'i')
),
},
},
};
},

load(id, _) {
if (!id.endsWith(postfix)) {
async load(id, _) {
const suffix = id.split('.').at(-1);
const importAdapter = cloudflareImportAdapters.find((x) => x.qualifiedExtension === suffix);
if (!importAdapter) {
return;
}
if (disabled) {
const suffixType: ImportType = importAdapter.extension;
const adapterEnabled = enabled[suffixType];
if (!adapterEnabled) {
throw new Error(
`WASM module's cannot be loaded unless you add \`wasmModuleImports: true\` to your astro config.`
`Cloudflare module loading is experimental. The ${suffix} module cannot be loaded unless you add a corresponding \`import {${suffixType}: true}\` to your astro config.`
);
}

const filePath = id.slice(0, -1 * '?module'.length);
const filePath = id.replace(/\?module$/, '');

const data = fs.readFileSync(filePath);
const data = await fs.readFile(filePath);
const base64 = data.toString('base64');

const base64Module = `const wasmModule = new WebAssembly.Module(Uint8Array.from(atob("${base64}"), c => c.charCodeAt(0)));export default wasmModule;`;
const inlineModule = importAdapter.asNodeModule(data);

if (isDev) {
// no need to wire up the assets in dev mode, just rewrite
return base64Module;
return inlineModule;
}
// just some shared ID
const 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`;
const assetName = `${path.basename(filePath).split('.')[0]}.${hash}.${
importAdapter.extension
}`;
this.emitFile({
type: 'asset',
// 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
// emit the data explicitly as an esset with `fileName` rather than `name` so that
// vite doesn't give it a random hash-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: assetName,
source: data,
@@ -77,51 +97,105 @@ export function wasmModuleLoader({
const chunkId = this.emitFile({
type: 'prebuilt-chunk',
fileName: `${assetName}.mjs`,
code: base64Module,
code: inlineModule,
});

return `import wasmModule from "__WASM_ASSET__${chunkId}.wasm.mjs";export default wasmModule;`;
return `import module from "${MAGIC_STRING}${chunkId}.${importAdapter.extension}.mjs";export default module;`;
},

// output original wasm file relative to the chunk
// output original wasm file relative to the chunk now that chunking has been achieved
renderChunk(code, chunk, _) {
if (isDev) return;

if (!/__WASM_ASSET__/g.test(code)) return;

const isPrerendered = Object.keys(chunk.modules).some(
(moduleId) => this.getModuleInfo(moduleId)?.meta?.astro?.pageOptions?.prerender === true
);

let final = code;

// SSR
if (!isPrerendered) {
final = code.replaceAll(/__WASM_ASSET__([A-Za-z\d]+).wasm.mjs/g, (s, assetId) => {
const fileName = this.getFileName(assetId).replace(/\.mjs$/, '');
const relativePath = path
.relative(path.dirname(chunk.fileName), fileName)
.replaceAll('\\', '/'); // fix windows paths for import
return `./${relativePath}`;
});
if (!code.includes(MAGIC_STRING)) return;

// SSR will need the .mjs suffix removed from the import before this works in cloudflare, but this is done as a final step
// so as to support prerendering from nodejs runtime
let replaced = code;
for (const loader of enabledAdapters) {
replaced = replaced.replaceAll(
new RegExp(`${MAGIC_STRING}([A-Za-z\\d]+)\\.${loader.extension}\\.mjs`, 'g'),
(s, assetId) => {
const fileName = this.getFileName(assetId);
const relativePath = path
.relative(path.dirname(chunk.fileName), fileName)
.replaceAll('\\', '/'); // fix windows paths for import

// record this replacement for later, to adjust it to import the unbundled asset
replacements.push({
fileName,
cloudflareImport: relativePath.replace(/\.mjs$/, ''),
nodejsImport: relativePath,
});
return `./${relativePath}`;
}
);
}

// SSG
if (isPrerendered) {
final = code.replaceAll(/__WASM_ASSET__([A-Za-z\d]+).wasm.mjs/g, (s, assetId) => {
const fileName = this.getFileName(assetId);
const relativePath = path
.relative(path.dirname(chunk.fileName), fileName)
.replaceAll('\\', '/'); // fix windows paths for import
return `./${relativePath}`;
});
if (replaced.includes(MAGIC_STRING)) {
console.error('failed to replace', replaced);
}

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

/**
* Once prerendering is complete, restore the imports in the _worker.js to cloudflare compatible ones, removing the .mjs suffix
*/
async afterBuildCompleted(config: AstroConfig) {
for (const replacement of replacements) {
const filepath = path.join(
url.fileURLToPath(config.outDir),
'_worker.js',
replacement.fileName
);
const contents = await fs.readFile(filepath, 'utf-8');
const newContents = contents.replaceAll(
replacement.cloudflareImport,
replacement.nodejsImport
);
await fs.writeFile(filepath, newContents, 'utf-8');
}
},
};
}

export type ImportType = 'wasm' | 'bin';

interface Replacement {
// path relative to the build root (_workers.js/ in this case)
fileName: string;
// desired import for cloudflare
cloudflareImport: string;
// nodejs import that simulates a wasm/bin module
nodejsImport: string;
}

interface ModuleImportAdapter {
extension: ImportType;
qualifiedExtension: string;
asNodeModule(fileContents: Buffer): string;
}

const wasmImportAdapter: ModuleImportAdapter = {
extension: 'wasm',
qualifiedExtension: 'wasm?module',
asNodeModule(fileContents: Buffer) {
const base64 = fileContents.toString('base64');
return `const wasmModule = new WebAssembly.Module(Uint8Array.from(atob("${base64}"), c => c.charCodeAt(0)));export default wasmModule;`;
},
};

const binImportAdapter: ModuleImportAdapter = {
extension: 'bin',
qualifiedExtension: 'bin',
asNodeModule(fileContents: Buffer) {
const base64 = fileContents.toString('base64');
return `const binModule = Uint8Array.from(atob("${base64}"), c => c.charCodeAt(0)).buffer;export default binModule;`;
},
};

const cloudflareImportAdapters = [binImportAdapter, wasmImportAdapter];

/**
* Returns a deterministic 32 bit hash code from a string
*/
5 changes: 4 additions & 1 deletion packages/cloudflare/test/fixtures/wasm/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -3,7 +3,10 @@ import { defineConfig } from 'astro/config';

export default defineConfig({
adapter: cloudflare({
wasmModuleImports: true
bundling: {
wasm: true,
bin: true
}
}),
output: 'hybrid'
});
10 changes: 4 additions & 6 deletions packages/cloudflare/test/fixtures/wasm/src/pages/add/[a]/[b].ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { type APIContext } from 'astro';
// @ts-ignore
import mod from '../../../util/add.wasm?module';
import { add } from '../../../util/add';

const addModule: any = new WebAssembly.Instance(mod);

export const prerender = false;

export async function GET(
context: APIContext
): Promise<Response> {
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) }), {
const a = Number.parseInt(context.params.a ?? "0");
const b = Number.parseInt(context.params.b ?? "0");
return new Response(JSON.stringify({ answer: add(a, b) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
6 changes: 2 additions & 4 deletions packages/cloudflare/test/fixtures/wasm/src/pages/hybrid.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { type APIContext } 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<Response> {
return new Response(JSON.stringify({ answer: addModule.exports.add(20, 1) }), {
return new Response(JSON.stringify({ answer: add(20, 1) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
8 changes: 8 additions & 0 deletions packages/cloudflare/test/fixtures/wasm/src/util/add.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import mod from './add.wasm?module';


const addModule: any = new WebAssembly.Instance(mod);

export function add(a, b) {
return addModule.exports.add(a, b);
}

0 comments on commit 4d6ce4a

Please sign in to comment.