From 3b8b3613ce38e92fcb18efce189954a13da3936c Mon Sep 17 00:00:00 2001 From: Etienne Samson Date: Tue, 20 Feb 2024 23:44:20 +0100 Subject: [PATCH] feat(wasm): allow using a custom loader This deprecates the `targetEnv` option and replaces it with `loader` which performs the same thing while also accepting an actual function. When that happens, that function's body will be inserted in lieu of the default one, giving complete control over the way WASM is loaded into the runtime. --- packages/wasm/src/helper.ts | 21 +++++++++--- packages/wasm/src/index.ts | 19 +++++++---- packages/wasm/test/test.mjs | 59 +++++++++++++++++++++++++++------- packages/wasm/types/index.d.ts | 31 ++++++++++++++++++ 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/packages/wasm/src/helper.ts b/packages/wasm/src/helper.ts index ea35455ef..0acdc43b4 100644 --- a/packages/wasm/src/helper.ts +++ b/packages/wasm/src/helper.ts @@ -1,7 +1,9 @@ -import type { TargetEnv } from '../types'; +import type { TargetEnv, WasmLoaderFunction } from '../types'; export const HELPERS_ID = '\0wasmHelpers.js'; +export const LOADER_FUNC_NAME = '_loadWasmModule'; + const nodeFilePath = ` var fs = require("fs") var path = require("path") @@ -94,8 +96,8 @@ const envModule = (env: TargetEnv) => { } }; -export const getHelpersModule = (env: TargetEnv) => ` -function _loadWasmModule (sync, filepath, src, imports) { +const defaultLoader = (env: TargetEnv) => ` +function ${LOADER_FUNC_NAME} (sync, filepath, src, imports) { function _instantiateOrCompile(source, imports, stream) { var instantiateFunc = stream ? WebAssembly.instantiateStreaming : WebAssembly.instantiate; var compileFunc = stream ? WebAssembly.compileStreaming : WebAssembly.compile; @@ -116,5 +118,16 @@ function _loadWasmModule (sync, filepath, src, imports) { return _instantiateOrCompile(buf, imports, false) } } -export { _loadWasmModule }; `; + +export const getHelpersModule = (loader: WasmLoaderFunction | TargetEnv) => { + let code = ''; + if (loader instanceof Function) { + code += loader.toString().replace(loader.name, LOADER_FUNC_NAME); + } else { + code += defaultLoader(loader); + } + + code += `export { ${LOADER_FUNC_NAME} };`; + return code; +}; diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 28517dbc2..d431ba366 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -7,15 +7,17 @@ import { createFilter } from '@rollup/pluginutils'; import type { RollupWasmOptions } from '../types'; -import { getHelpersModule, HELPERS_ID } from './helper'; +import { getHelpersModule, HELPERS_ID, LOADER_FUNC_NAME } from './helper'; export function wasm(options: RollupWasmOptions = {}): Plugin { + // eslint-disable-next-line no-param-reassign + options.loader ??= options.targetEnv; const { sync = [], maxFileSize = 14 * 1024, publicPath = '', - targetEnv = 'auto', - fileName = '[hash][extname]' + fileName = '[hash][extname]', + loader = 'auto' } = options; const syncFiles = sync.map((x) => path.resolve(x)); @@ -35,7 +37,7 @@ export function wasm(options: RollupWasmOptions = {}): Plugin { load(id) { if (id === HELPERS_ID) { - return getHelpersModule(targetEnv); + return getHelpersModule(loader); } if (!filter(id)) { @@ -49,7 +51,7 @@ export function wasm(options: RollupWasmOptions = {}): Plugin { return Promise.all([fs.promises.stat(id), fs.promises.readFile(id)]).then( ([stats, buffer]) => { - if (targetEnv === 'auto-inline') { + if (loader === 'auto-inline') { return buffer.toString('binary'); } @@ -88,6 +90,7 @@ export function wasm(options: RollupWasmOptions = {}): Plugin { if (code && /\.wasm$/.test(id)) { const isSync = syncFiles.indexOf(id) !== -1; const publicFilepath = copies[id] ? `'${copies[id].publicFilepath}'` : null; + let out = ''; let src; if (publicFilepath === null) { @@ -100,12 +103,14 @@ export function wasm(options: RollupWasmOptions = {}): Plugin { src = null; } + out = `import { ${LOADER_FUNC_NAME} } from ${JSON.stringify(HELPERS_ID)}; +export default function (imports) { return ${LOADER_FUNC_NAME}(${+isSync}, ${publicFilepath}, ${src}, imports) }`; + return { map: { mappings: '' }, - code: `import { _loadWasmModule } from ${JSON.stringify(HELPERS_ID)}; -export default function(imports){return _loadWasmModule(${+isSync}, ${publicFilepath}, ${src}, imports)}` + code: out }; } return null; diff --git a/packages/wasm/test/test.mjs b/packages/wasm/test/test.mjs index 2714c87b7..634856460 100755 --- a/packages/wasm/test/test.mjs +++ b/packages/wasm/test/test.mjs @@ -26,6 +26,11 @@ const testBundle = async (t, bundle) => { return func(t); }; +const setup = (t) => { + global.result = null; + global.t = t; +}; + test('async compiling', async (t) => { t.plan(2); @@ -51,8 +56,7 @@ test('fetching WASM from separate file', async (t) => { await bundle.write({ format: 'cjs', file: outputFile }); const glob = join(outputDir, `**/*.wasm`).split(sep).join(posix.sep); - global.result = null; - global.t = t; + setup(t); await import(outputFile); await global.result; t.snapshot(await globby(glob)); @@ -61,6 +65,7 @@ test('fetching WASM from separate file', async (t) => { test('complex module decoding', async (t) => { t.plan(2); + setup(t); const bundle = await rollup({ input: 'fixtures/complex.js', @@ -71,6 +76,7 @@ test('complex module decoding', async (t) => { test('sync compiling', async (t) => { t.plan(2); + setup(t); const bundle = await rollup({ input: 'fixtures/sync.js', @@ -85,6 +91,7 @@ test('sync compiling', async (t) => { test('imports', async (t) => { t.plan(1); + setup(t); const bundle = await rollup({ input: 'fixtures/imports.js', @@ -99,6 +106,7 @@ test('imports', async (t) => { test('worker', async (t) => { t.plan(2); + setup(t); const bundle = await rollup({ input: 'fixtures/worker.js', @@ -120,6 +128,7 @@ test('worker', async (t) => { test('injectHelper', async (t) => { t.plan(4); + setup(t); const injectImport = `import { _loadWasmModule } from ${JSON.stringify('\0wasmHelpers.js')};`; @@ -146,12 +155,13 @@ test('injectHelper', async (t) => { await testBundle(t, bundle); }); -test('target environment auto', async (t) => { +test('loader auto', async (t) => { t.plan(5); + setup(t); const bundle = await rollup({ input: 'fixtures/async.js', - plugins: [wasmPlugin({ targetEnv: 'auto' })] + plugins: [wasmPlugin({ loader: 'auto' })] }); const code = await getCode(bundle); await testBundle(t, bundle); @@ -160,12 +170,13 @@ test('target environment auto', async (t) => { t.true(code.includes(`fetch`)); }); -test('target environment auto-inline', async (t) => { +test('loader auto-inline', async (t) => { t.plan(6); + setup(t); const bundle = await rollup({ input: 'fixtures/async.js', - plugins: [wasmPlugin({ targetEnv: 'auto-inline' })] + plugins: [wasmPlugin({ loader: 'auto-inline' })] }); const code = await getCode(bundle); await testBundle(t, bundle); @@ -175,12 +186,13 @@ test('target environment auto-inline', async (t) => { t.true(code.includes(`if (isNode)`)); }); -test('target environment browser', async (t) => { +test('loader browser', async (t) => { t.plan(4); + setup(t); const bundle = await rollup({ input: 'fixtures/async.js', - plugins: [wasmPlugin({ targetEnv: 'browser' })] + plugins: [wasmPlugin({ loader: 'browser' })] }); const code = await getCode(bundle); await testBundle(t, bundle); @@ -188,12 +200,13 @@ test('target environment browser', async (t) => { t.true(code.includes(`fetch`)); }); -test('target environment node', async (t) => { +test('loader node', async (t) => { t.plan(4); + setup(t); const bundle = await rollup({ input: 'fixtures/async.js', - plugins: [wasmPlugin({ targetEnv: 'node' })] + plugins: [wasmPlugin({ loader: 'node' })] }); const code = await getCode(bundle); await testBundle(t, bundle); @@ -201,13 +214,33 @@ test('target environment node', async (t) => { t.true(!code.includes(`fetch`)); }); +test('loader custom', async (t) => { + t.plan(1); + setup(t); + + function custom(sync, path, base64, imports) { + // eslint-disable-next-line no-console + console.log(`custom load: ${sync}, ${path}, ${base64}, ${imports}`); + } + + const bundle = await rollup({ + input: 'fixtures/async.js', + plugins: [wasmPlugin({ loader: custom })] + }); + + const code = await getCode(bundle); + + t.true(code.includes('custom load')); +}); + test('filename override', async (t) => { t.plan(1); + setup(t); const bundle = await rollup({ input: 'fixtures/async.js', plugins: [ - wasmPlugin({ maxFileSize: 0, targetEnv: 'node', fileName: 'start-[name]-suffix[extname]' }) + wasmPlugin({ maxFileSize: 0, loader: 'node', fileName: 'start-[name]-suffix[extname]' }) ] }); @@ -220,6 +253,7 @@ test('filename override', async (t) => { test('works as CJS plugin', async (t) => { t.plan(2); + setup(t); const require = createRequire(import.meta.url); const wasmPluginCjs = require('current-package'); const bundle = await rollup({ @@ -233,10 +267,11 @@ test('works as CJS plugin', async (t) => { if (!process.version.startsWith('v14')) { test('avoid uncaught exception on file read', async (t) => { t.plan(2); + setup(t); const bundle = await rollup({ input: 'fixtures/complex.js', - plugins: [wasmPlugin({ maxFileSize: 0, targetEnv: 'node' })] + plugins: [wasmPlugin({ maxFileSize: 0, loader: 'node' })] }); const raw = await getCode(bundle); diff --git a/packages/wasm/types/index.d.ts b/packages/wasm/types/index.d.ts index b846f3689..f30ec4bc8 100644 --- a/packages/wasm/types/index.d.ts +++ b/packages/wasm/types/index.d.ts @@ -9,6 +9,23 @@ import type { FilterPattern } from '@rollup/pluginutils'; */ export type TargetEnv = 'auto' | 'auto-inline' | 'browser' | 'node'; +/** + * The type for the plugin's loader function + * + * This is the function that ends up called when encountering a WASM import to load it and turn it into an usable object at runtime. + * + * @param {boolean} sync Whether the load should happen synchronously or not + * @param {string | null} filepath The path to the module. + * @param {string | null} src The base64-encoded source of the module + * @param {any} imports An object containing the module's imports + */ +export type WasmLoaderFunction = ( + sync: boolean, + filepath: string | null, + src: string, + imports: any +) => void; + export interface RollupWasmOptions { /** * A picomatch pattern, or array of patterns, which specifies the files in the build the plugin @@ -40,8 +57,22 @@ export interface RollupWasmOptions { * A string which will be added in front of filenames when they are not inlined but are copied. */ publicPath?: string; + /** + * The loader used to process WASM modules. + * + * This plugin provides 4 default loaders: + * - `"auto"` will determine the environment at runtime and invoke the correct methods accordingly + * - `"auto-inline"` always inlines the Wasm and will decode it according to the environment + * - `"browser"` omits emitting code that requires node.js builtin modules that may play havoc on downstream bundlers + * - `"node"` omits emitting code that requires `fetch` + * + * Additionally, you can pass your own loader function if you need better control. The plugin expects a + * function with the following signature: `_loadWasmModule(sync: boolean, filepath: string, src: string, imports: any)`. + */ + loader?: TargetEnv | WasmLoaderFunction; /** * Configures what code is emitted to instantiate the Wasm (both inline and separate) + * @deprecated Use {@link RollupWasmOptions.loader} */ targetEnv?: TargetEnv; }