diff --git a/.changeset/shy-cycles-obey.md b/.changeset/shy-cycles-obey.md new file mode 100644 index 000000000..2c79702ae --- /dev/null +++ b/.changeset/shy-cycles-obey.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': minor +--- + +Add support for loading wasm modules in the cloudflare adapter diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index fafd3046c..e71daa9ba 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -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} pathExists * @property {(url: string, opts: Parameters[1]) => Promise} fetch * @property {(path: string) => Promise} readFile * @property {(path: string, updater: (content: string) => string) => Promise} writeFile diff --git a/packages/integrations/cloudflare/README.md b/packages/integrations/cloudflare/README.md index 719a5688c..1a1419cff 100644 --- a/packages/integrations/cloudflare/README.md +++ b/packages/integrations/cloudflare/README.md @@ -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. diff --git a/packages/integrations/cloudflare/package.json b/packages/integrations/cloudflare/package.json index 644b36bc7..ea175e438 100644 --- a/packages/integrations/cloudflare/package.json +++ b/packages/integrations/cloudflare/package.json @@ -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" @@ -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" } diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 24c22d8f1..4ae43a110 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -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'; @@ -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; } @@ -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 }) => { @@ -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 }); @@ -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); + // + // 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()) + .values(); - 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', - }, - }); + 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 = await glob(`**/*`, { cwd: outputDir, @@ -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 @@ -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 + }; + }); + }, + }; +} diff --git a/packages/integrations/cloudflare/src/wasm-module-loader.ts b/packages/integrations/cloudflare/src/wasm-module-loader.ts new file mode 100644 index 000000000..7d34d48c3 --- /dev/null +++ b/packages/integrations/cloudflare/src/wasm-module-loader.ts @@ -0,0 +1,119 @@ +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/ + * @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, + assetsDirectory, +}: { + disabled: boolean; + assetsDirectory: string; +}): 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\.mjs$/i } }, + }; + }, + + load(id, _) { + 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); + + 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) { + // no need to wire up the assets in dev mode, just rewrite + return base64Module; + } else { + // 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', + // 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), + }); + + // 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 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__/g.test(code)) return; + + 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) + .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); +} diff --git a/packages/integrations/cloudflare/test/basics.test.js b/packages/integrations/cloudflare/test/basics.test.js index 726a19fc6..eb4509da1 100644 --- a/packages/integrations/cloudflare/test/basics.test.js +++ b/packages/integrations/cloudflare/test/basics.test.js @@ -14,20 +14,22 @@ 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, + onTimeout: (ex) => { + console.log(ex); + // if fail to start, skip for now as it's very flaky + this.skip(); + }, }); }); 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); diff --git a/packages/integrations/cloudflare/test/cf.test.js b/packages/integrations/cloudflare/test/cf.test.js index 53b1bbf2c..78a18dcdf 100644 --- a/packages/integrations/cloudflare/test/cf.test.js +++ b/packages/integrations/cloudflare/test/cf.test.js @@ -17,20 +17,22 @@ 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, + onTimeout: (ex) => { + console.log(ex); + // if fail to start, skip for now as it's very flaky + this.skip(); + }, }); }); 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); @@ -63,7 +65,7 @@ describe('Astro Cloudflare Runtime', () => { }); after(async () => { - await devServer.stop(); + await devServer?.stop(); }); it('Populates CF, Vars & Bindings', async () => { diff --git a/packages/integrations/cloudflare/test/directory.test.js b/packages/integrations/cloudflare/test/directory.test.js index a252b03e9..8390699e3 100644 --- a/packages/integrations/cloudflare/test/directory.test.js +++ b/packages/integrations/cloudflare/test/directory.test.js @@ -4,6 +4,7 @@ import cloudflare from '../dist/index.js'; /** @type {import('./test-utils').Fixture} */ describe('mode: "directory"', () => { + /** @type {import('./test-utils').Fixture} */ let fixture; before(async () => { diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-directory/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/wasm-directory/astro.config.mjs new file mode 100644 index 000000000..a30cd2086 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-directory/astro.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from 'astro/config'; +import cloudflare from '@astrojs/cloudflare'; + +export default defineConfig({ + adapter: cloudflare({ + mode: 'directory', + wasmModuleImports: true + }), + output: 'server' +}); diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-directory/package.json b/packages/integrations/cloudflare/test/fixtures/wasm-directory/package.json new file mode 100644 index 000000000..859aa4f40 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-directory/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/astro-cloudflare-wasm-function-per-route", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-directory/src/pages/index.ts b/packages/integrations/cloudflare/test/fixtures/wasm-directory/src/pages/index.ts new file mode 100644 index 000000000..2c9ff6d44 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-directory/src/pages/index.ts @@ -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 { + + return new Response(JSON.stringify({ answer: addModule.exports.add(40, 2) }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-directory/src/util/add.wasm b/packages/integrations/cloudflare/test/fixtures/wasm-directory/src/util/add.wasm new file mode 100644 index 000000000..357f72da7 Binary files /dev/null and b/packages/integrations/cloudflare/test/fixtures/wasm-directory/src/util/add.wasm differ diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/astro.config.mjs new file mode 100644 index 000000000..7f741d884 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/astro.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from 'astro/config'; +import cloudflare from '@astrojs/cloudflare'; + +export default defineConfig({ + adapter: cloudflare({ + mode: 'directory', + functionPerRoute: true, + wasmModuleImports: true + }), + output: 'server', + vite: { build: { minify: false } } +}); diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/package.json b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/package.json new file mode 100644 index 000000000..238c1e313 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/astro-cloudflare-wasm-directory", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/deeply/nested/route.ts b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/deeply/nested/route.ts new file mode 100644 index 000000000..20797c0c6 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/deeply/nested/route.ts @@ -0,0 +1,14 @@ +import { type APIContext, type EndpointOutput } from 'astro'; +import { add } from '../../../util/add'; + +export async function GET( + context: APIContext +): Promise { + + return new Response(JSON.stringify({ answer: add(80, 4) }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/index.ts b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/index.ts new file mode 100644 index 000000000..b6417dde9 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/pages/index.ts @@ -0,0 +1,14 @@ +import { type APIContext, type EndpointOutput } from 'astro'; +import { add } from '../util/add'; + +export async function GET( + context: APIContext +): Promise { + + return new Response(JSON.stringify({ answer: add(40, 2) }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/add.ts b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/add.ts new file mode 100644 index 000000000..ee336277b --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/add.ts @@ -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); +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/add.wasm b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/add.wasm new file mode 100644 index 000000000..357f72da7 Binary files /dev/null and b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/add.wasm differ diff --git a/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/indirection.ts b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/indirection.ts new file mode 100644 index 000000000..6fbb04c49 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm-function-per-route/src/util/indirection.ts @@ -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); +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/wasm/astro.config.mjs new file mode 100644 index 000000000..b5e68667e --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm/astro.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'astro/config'; +import cloudflare from '@astrojs/cloudflare'; + +export default defineConfig({ + adapter: cloudflare({ + wasmModuleImports: true + }), + output: 'server' +}); diff --git a/packages/integrations/cloudflare/test/fixtures/wasm/package.json b/packages/integrations/cloudflare/test/fixtures/wasm/package.json new file mode 100644 index 000000000..4abd8513c --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/astro-cloudflare-wasm", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "astro": "workspace:*" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/add/[a]/[b].ts b/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/add/[a]/[b].ts new file mode 100644 index 000000000..130b2b2a4 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/add/[a]/[b].ts @@ -0,0 +1,20 @@ +import { type APIContext, type EndpointOutput } from 'astro'; +// @ts-ignore +import mod from '../../../util/add.wasm?module'; + +const addModule: any = new WebAssembly.Instance(mod); + +export const prerender = false; + +export async function GET( + context: APIContext +): Promise { + 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', + }, + }); +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/hybrid.ts b/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/hybrid.ts new file mode 100644 index 000000000..7bb470dff --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/wasm/src/pages/hybrid.ts @@ -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 { + return new Response(JSON.stringify({ answer: addModule.exports.add(20, 1) }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/packages/integrations/cloudflare/test/fixtures/wasm/src/util/add.wasm b/packages/integrations/cloudflare/test/fixtures/wasm/src/util/add.wasm new file mode 100644 index 000000000..357f72da7 Binary files /dev/null and b/packages/integrations/cloudflare/test/fixtures/wasm/src/util/add.wasm differ diff --git a/packages/integrations/cloudflare/test/function-per-route.test.js b/packages/integrations/cloudflare/test/function-per-route.test.js index d20b0fa7c..f0287c717 100644 --- a/packages/integrations/cloudflare/test/function-per-route.test.js +++ b/packages/integrations/cloudflare/test/function-per-route.test.js @@ -3,6 +3,7 @@ import { expect } from 'chai'; /** @type {import('./test-utils.js').Fixture} */ describe('Cloudflare SSR functionPerRoute', () => { + /** @type {import('./test-utils').Fixture} */ let fixture; before(async () => { @@ -13,7 +14,7 @@ describe('Cloudflare SSR functionPerRoute', () => { }); after(() => { - fixture.clean(); + fixture?.clean(); }); it('generates functions folders inside the project root, and checks that each page is emitted by astro', async () => { diff --git a/packages/integrations/cloudflare/test/runtime.test.js b/packages/integrations/cloudflare/test/runtime.test.js index 8bb38d7e5..e0d77d5c6 100644 --- a/packages/integrations/cloudflare/test/runtime.test.js +++ b/packages/integrations/cloudflare/test/runtime.test.js @@ -17,20 +17,22 @@ describe('Runtime Locals', () => { }); await fixture.build(); - cli = await runCLI('./fixtures/runtime/', { silent: true, port: 8793 }); - 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/runtime/', { + silent: true, + onTimeout: (ex) => { + console.log(ex); + // if fail to start, skip for now as it's very flaky + this.skip(); + }, }); }); after(async () => { - await cli.stop(); + await cli?.stop(); }); it('has CF and Caches', async () => { - let res = await fetch(`http://127.0.0.1:8793/`); + 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); diff --git a/packages/integrations/cloudflare/test/test-utils.js b/packages/integrations/cloudflare/test/test-utils.js index 36515f831..50226c0c1 100644 --- a/packages/integrations/cloudflare/test/test-utils.js +++ b/packages/integrations/cloudflare/test/test-utils.js @@ -1,12 +1,10 @@ import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import kill from 'kill-port'; import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; - +import * as net from 'node:net'; export { fixLineEndings } from '../../../astro/test/test-utils.js'; - /** - * @typedef {{ ready: Promise, stop: Promise }} WranglerCLI + * @typedef {{ stop: Promise, port: number }} WranglerCLI * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture */ @@ -21,70 +19,147 @@ const wranglerPath = fileURLToPath( new URL('../node_modules/wrangler/bin/wrangler.js', import.meta.url) ); +let lastPort = 8788; + /** * @returns {Promise} */ -export async function runCLI(basePath, { silent, port }) { - // Hack: force existing process on port to be killed - try { - await kill(port, 'tcp'); - } catch { - // Will throw if port is not in use, but that's fine +export async function runCLI( + basePath, + { + silent, + 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 ${maxAttempts} tries.`, { cause: ex }); + }, } +) { + let triesRemaining = maxAttempts; + let timeout = timeoutMillis; + let cli; + let lastErr; + while (triesRemaining > 0) { + cli = await tryRunCLI(basePath, { silent, timeout, forceRotatePort: triesRemaining !== maxAttempts }); + try { + await cli.ready; + return cli; + } catch (err) { + lastErr = err; + console.error((err.message || err.name || err) + ' after ' + timeout + 'ms'); + cli.stop(); + triesRemaining -= 1; + timeout *= backoffFactor; + } + } + onTimeout(lastErr); + return cli; +} - const script = fileURLToPath(new URL(`${basePath}/dist/_worker.js`, import.meta.url)); - const p = spawn('node', [ - wranglerPath, - 'dev', - script, - '--port', - port, - '--log-level', - 'info', - '--persist-to', - `${basePath}/.wrangler/state`, - ]); +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)); + const p = spawn( + 'node', + [ + wranglerPath, + 'pages', + 'dev', + 'dist', + '--port', + port, + '--log-level', + 'info', + '--persist-to', + '.wrangler/state', + ], + { + cwd: fixtureDir, + } + ); p.stderr.setEncoding('utf-8'); p.stdout.setEncoding('utf-8'); - const timeout = 20_000; - const ready = new Promise(async (resolve, reject) => { const failed = setTimeout(() => { - p.kill(); + p.kill('SIGKILL'); reject(new Error(`Timed out starting the wrangler CLI`)); }, timeout); - (async function () { - for (const msg of p.stderr) { - if (!silent) { - console.error(msg); - } - } - })(); + const success = () => { + clearTimeout(failed); + resolve(); + }; - for await (const msg of p.stdout) { + p.on('exit', (code) => reject(`wrangler terminated unexpectedly with exit code ${code}`)); + + p.stderr.on('data', (data) => { if (!silent) { - console.log(msg); + process.stdout.write(data); } - if (msg.includes(`[mf:inf] Ready on`)) { - break; + }); + let allData = ''; + p.stdout.on('data', (data) => { + if (!silent) { + process.stdout.write(data); } - } - - clearTimeout(failed); - resolve(); + allData += data; + if (allData.includes(`[mf:inf] Ready on`)) { + success(); + } + }); }); return { + port, ready, stop() { return new Promise((resolve, reject) => { - p.on('close', () => resolve()); + const timer = setTimeout(() => { + p.kill('SIGKILL'); + }, 1000); + p.on('close', () => { + clearTimeout(timer); + resolve(); + }); p.on('error', (err) => reject(err)); p.kill(); }); }, }; } + +const isPortOpen = async (port) => { + return new Promise((resolve, reject) => { + let s = net.createServer(); + s.once('error', (err) => { + s.close(); + if (err['code'] == 'EADDRINUSE') { + resolve(false); + } else { + reject(err); + } + }); + s.once('listening', () => { + resolve(true); + s.close(); + }); + s.listen(port, "0.0.0.0"); + }); +}; + +const getNextOpenPort = async (startFrom) => { + let openPort = null; + while (startFrom < 65535 || !!openPort) { + if (await isPortOpen(startFrom)) { + openPort = startFrom; + break; + } + startFrom++; + } + return openPort; +}; diff --git a/packages/integrations/cloudflare/test/wasm-directory.test.js b/packages/integrations/cloudflare/test/wasm-directory.test.js new file mode 100644 index 000000000..0f387a660 --- /dev/null +++ b/packages/integrations/cloudflare/test/wasm-directory.test.js @@ -0,0 +1,36 @@ +import { loadFixture, runCLI } from './test-utils.js'; +import { expect } from 'chai'; + +describe('Wasm directory mode import', () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + /** @type {import('./test-utils.js').WranglerCLI} */ + let cli; + + before(async function () { + fixture = await loadFixture({ + root: './fixtures/wasm-directory/', + }); + await fixture.build(); + + cli = await runCLI('./fixtures/wasm-directory/', { + silent: true, + onTimeout: (ex) => { + console.log(ex); + // if fail to start, skip for now as it's very flaky + this.skip(); + }, + }); + }); + + after(async () => { + await cli?.stop(); + }); + + it('can render', async () => { + let res = await fetch(`http://127.0.0.1:${cli.port}/`); + expect(res.status).to.equal(200); + const json = await res.json(); + expect(json).to.deep.equal({ answer: 42 }); + }); +}); diff --git a/packages/integrations/cloudflare/test/wasm-function-per-route.test.js b/packages/integrations/cloudflare/test/wasm-function-per-route.test.js new file mode 100644 index 000000000..f751f1ff4 --- /dev/null +++ b/packages/integrations/cloudflare/test/wasm-function-per-route.test.js @@ -0,0 +1,41 @@ +import { loadFixture, runCLI } from './test-utils.js'; +import { expect } from 'chai'; + +describe('Wasm function per route import', () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + /** @type {import('./test-utils.js').WranglerCLI} */ + let cli; + + before(async function () { + fixture = await loadFixture({ + root: './fixtures/wasm-function-per-route/', + }); + await fixture.build(); + + cli = await runCLI('./fixtures/wasm-function-per-route/', { + silent: true, + onTimeout: (ex) => { + console.log(ex); + // if fail to start, skip for now as it's very flaky + this.skip(); + }, + }); + }); + + after(async () => { + await cli?.stop(); + }); + + it('can render', async () => { + let res = await fetch(`http://127.0.0.1:${cli.port}/`); + expect(res.status).to.equal(200); + let json = await res.json(); + expect(json).to.deep.equal({ answer: 42 }); + + res = await fetch(`http://127.0.0.1:${cli.port}/deeply/nested/route`); + expect(res.status).to.equal(200); + json = await res.json(); + expect(json).to.deep.equal({ answer: 84 }); + }); +}); diff --git a/packages/integrations/cloudflare/test/wasm.test.js b/packages/integrations/cloudflare/test/wasm.test.js new file mode 100644 index 000000000..279a00cd1 --- /dev/null +++ b/packages/integrations/cloudflare/test/wasm.test.js @@ -0,0 +1,85 @@ +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} */ + let fixture; + /** @type {import('./test-utils.js').WranglerCLI} */ + let cli; + + before(async function () { + fixture = await loadFixture({ + root: './fixtures/wasm/', + }); + await fixture.build(); + + cli = await runCLI('./fixtures/wasm/', { + silent: true, + onTimeout: (ex) => { + console.log(ex); + // if fail to start, skip for now as it's very flaky + this.skip(); + }, + }); + }); + + after(async () => { + await cli?.stop(); + }); + + it('can render', async () => { + 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 }); + }); + }); + describe('astro dev server', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/wasm/', + }); + devServer = undefined; + }); + + after(async () => { + await devServer?.stop(); + }); + + it('can serve wasm', async () => { + 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: 63 }); + }); + + it('fails to build intelligently when wasm is disabled', async () => { + let ex; + try { + await fixture.build({ + adapter: cloudflare({ + wasmModuleImports: false, + }), + }); + } catch (err) { + ex = err; + } + 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'); + }); + }); +}); diff --git a/packages/integrations/cloudflare/test/with-solid-js.test.js b/packages/integrations/cloudflare/test/with-solid-js.test.js index c644163b0..501a947d0 100644 --- a/packages/integrations/cloudflare/test/with-solid-js.test.js +++ b/packages/integrations/cloudflare/test/with-solid-js.test.js @@ -14,20 +14,22 @@ describe('With SolidJS', () => { }); await fixture.build(); - cli = await runCLI('./fixtures/with-solid-js/', { silent: true, port: 8790 }); - 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/with-solid-js/', { + silent: true, + onTimeout: (ex) => { + console.log(ex); + // if fail to start, skip for now as it's very flaky + this.skip(); + }, }); }); after(async () => { - await cli.stop(); + await cli?.stop(); }); it('renders the solid component', async () => { - let res = await fetch(`http://127.0.0.1:8790/`); + 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); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dceb985fa..0e2fbd876 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3649,6 +3649,9 @@ importers: tiny-glob: specifier: ^0.2.9 version: 0.2.9 + vite: + specifier: ^4.4.9 + version: 4.4.9(@types/node@18.17.8)(sass@1.66.1) devDependencies: '@types/iarna__toml': specifier: ^2.0.2 @@ -3665,9 +3668,6 @@ importers: cheerio: specifier: 1.0.0-rc.12 version: 1.0.0-rc.12 - kill-port: - specifier: ^2.0.1 - version: 2.0.1 mocha: specifier: ^10.2.0 version: 10.2.0 @@ -3738,6 +3738,33 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/wasm: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + + packages/integrations/cloudflare/test/fixtures/wasm-directory: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + + packages/integrations/cloudflare/test/fixtures/wasm-function-per-route: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + astro: + specifier: workspace:* + version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/with-solid-js: dependencies: '@astrojs/cloudflare': @@ -12043,10 +12070,6 @@ packages: call-bind: 1.0.2 get-intrinsic: 1.2.1 - /get-them-args@1.3.2: - resolution: {integrity: sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw==} - dev: true - /giget@1.1.2: resolution: {integrity: sha512-HsLoS07HiQ5oqvObOI+Qb2tyZH4Gj5nYGfF9qQcZNrPw+uEFhdXtgJr01aO2pWadGHucajYDLxxbtQkm97ON2A==} hasBin: true @@ -13165,14 +13188,6 @@ packages: commander: 8.3.0 dev: true - /kill-port@2.0.1: - resolution: {integrity: sha512-e0SVOV5jFo0mx8r7bS29maVWp17qGqLBZ5ricNSajON6//kmb7qqqNnml4twNE8Dtj97UQD+gNFOaipS/q1zzQ==} - hasBin: true - dependencies: - get-them-args: 1.3.2 - shell-exec: 1.0.2 - dev: true - /kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -16320,10 +16335,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - /shell-exec@1.0.2: - resolution: {integrity: sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==} - dev: true - /shell-quote@1.8.1: resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} dev: true