feat(@astrojs/cloudflare): Add support for wasm module imports (#8542)

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Adrian Lyjak 2023-09-22 10:58:00 -04:00 committed by GitHub
parent b1310e6f13
commit faeead4232
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 787 additions and 122 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': minor
---
Add support for loading wasm modules in the cloudflare adapter

View file

@ -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

View file

@ -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 projects `public/` directory.

View file

@ -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"
}

View file

@ -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<number, string[]>())
.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<string> = 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
};
});
},
};
}

View file

@ -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);
}

View file

@ -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);

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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'
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-wasm-function-per-route",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -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',
},
});
}

View file

@ -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 } }
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-wasm-directory",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,14 @@
import { type APIContext, type EndpointOutput } from 'astro';
import { add } from '../../../util/add';
export async function GET(
context: APIContext
): Promise<EndpointOutput | Response> {
return new Response(JSON.stringify({ answer: add(80, 4) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}

View file

@ -0,0 +1,14 @@
import { type APIContext, type EndpointOutput } from 'astro';
import { add } from '../util/add';
export async function GET(
context: APIContext
): Promise<EndpointOutput | Response> {
return new Response(JSON.stringify({ answer: add(40, 2) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
adapter: cloudflare({
wasmModuleImports: true
}),
output: 'server'
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-wasm",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "workspace:*"
}
}

View file

@ -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<EndpointOutput | 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) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}

View file

@ -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<EndpointOutput | Response> {
return new Response(JSON.stringify({ answer: addModule.exports.add(20, 1) }), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}

View file

@ -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 () => {

View file

@ -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);

View file

@ -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<void>, stop: Promise<void> }} WranglerCLI
* @typedef {{ stop: Promise<void>, 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<WranglerCLI>}
*/
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;
};

View file

@ -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 });
});
});

View file

@ -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 });
});
});

View file

@ -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');
});
});
});

View file

@ -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);

View file

@ -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