-
Notifications
You must be signed in to change notification settings - Fork 561
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
BREAKING: Synchronously initialise WASM modules (#2024)
When the `experimental.wasm` flag in the Snaps CLI is enabled, the WASM module will now be synchronously initialised, rather than being inlined as a `Uint8Array`. For example, if the WASM module exports a `fibonacci` function, the following is now possible: ```ts import { fibonacci } from './path/to/file.wasm'; fibonacci(...); ``` This is a breaking change, because previously the WASM module would be inlined as `Uint8Array` instead.
- Loading branch information
Showing
10 changed files
with
246 additions
and
67 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
/** | ||
* Add two numbers together. This function is not used in the Snap, but is | ||
* imported from the WebAssembly module, to demonstrate how to import functions | ||
* from the WebAssembly module. | ||
* | ||
* @param a - The first number. | ||
* @param b - The second number. | ||
* @returns The sum of `a` and `b`. | ||
*/ | ||
export function add(a: number, b: number) { | ||
return a + b; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,93 @@ | ||
import loader from './wasm'; | ||
import { readFile } from 'fs/promises'; | ||
import { join } from 'path'; | ||
|
||
describe('loader', () => { | ||
it('inlines the WASM module as a `Uint8Array`', async () => { | ||
const source = 'WASM module'; | ||
const result = loader(source); | ||
import loader, { getExports, getImports, getModuleImports, raw } from './wasm'; | ||
|
||
describe('getImports', () => { | ||
it('returns the imports code for the WASM module', () => { | ||
const result = getImports({ | ||
'../src/bindings.ts': ['add', 'subtract'], | ||
'./foo.ts': ['bar'], | ||
}); | ||
|
||
expect(result).toMatchInlineSnapshot(` | ||
"import { add, subtract } from "../src/bindings.ts"; | ||
import { bar } from "./foo.ts";" | ||
`); | ||
}); | ||
}); | ||
|
||
describe('getModuleImports', () => { | ||
it('returns the imports code for the WASM module', () => { | ||
const result = getModuleImports({ | ||
'../src/bindings.ts': ['add', 'subtract'], | ||
'./foo.ts': ['bar'], | ||
}); | ||
|
||
expect(result).toMatchInlineSnapshot(` | ||
""../src/bindings.ts": { add, subtract }, | ||
"./foo.ts": { bar }," | ||
`); | ||
}); | ||
}); | ||
|
||
describe('getExports', () => { | ||
it('returns the exports code for the WASM module', () => { | ||
const result = getExports([ | ||
{ | ||
kind: 'function', | ||
name: 'fibonacci', | ||
}, | ||
{ | ||
kind: 'memory', | ||
name: 'memory', | ||
}, | ||
{ | ||
kind: 'function', | ||
name: 'default', | ||
}, | ||
]); | ||
|
||
expect(result).toMatchInlineSnapshot( | ||
`"export default new Uint8Array([87,65,83,77,32,109,111,100,117,108,101]);"`, | ||
expect(result).toMatchInlineSnapshot(` | ||
"export const fibonacci = exports["fibonacci"]; | ||
export const memory = exports["memory"]; | ||
export default exports["default"];" | ||
`); | ||
}); | ||
}); | ||
|
||
describe('loader', () => { | ||
it('synchronously initialises the WASM module', async () => { | ||
const source = await readFile( | ||
join(__dirname, '__fixtures__', 'program.wasm'), | ||
); | ||
|
||
// @ts-expect-error - We don't need to mock the entire `this` object. | ||
const result = await loader.bind({ | ||
addDependency: jest.fn(), | ||
// @ts-expect-error - The type of this function seems to be incorrect. | ||
})(source); | ||
|
||
expect(result).toMatchInlineSnapshot(` | ||
" | ||
import { add } from "../src/bindings.ts"; | ||
const bytes = new Uint8Array([0,97,115,109,1,0,0,0,1,12,2,96,2,127,127,1,127,96,1,127,1,127,2,26,1,18,46,46,47,115,114,99,47,98,105,110,100,105,110,103,115,46,116,115,3,97,100,100,0,0,3,2,1,1,5,3,1,0,0,7,22,2,9,102,105,98,111,110,97,99,99,105,0,1,6,109,101,109,111,114,121,2,0,10,54,1,52,1,3,127,65,1,33,1,32,0,65,0,74,4,64,3,64,32,0,65,1,107,34,0,4,64,32,2,32,1,16,0,33,3,32,1,33,2,32,3,33,1,12,1,11,11,32,1,15,11,65,0,11]); | ||
const module = new WebAssembly.Module(bytes); | ||
const instance = new WebAssembly.Instance(module, { | ||
"../src/bindings.ts": { add }, | ||
}); | ||
const exports = instance.exports; | ||
export const fibonacci = exports["fibonacci"]; | ||
export const memory = exports["memory"]; | ||
" | ||
`); | ||
}); | ||
|
||
describe('raw', () => { | ||
it('is `true`', () => { | ||
expect(raw).toBe(true); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,27 +1,121 @@ | ||
import { assert, stringToBytes } from '@metamask/utils'; | ||
/* eslint-disable no-restricted-globals */ | ||
|
||
import { assert } from '@metamask/utils'; | ||
import type { LoaderDefinitionFunction } from 'webpack'; | ||
|
||
/** | ||
* A Webpack loader that inlines the WASM module as a `Uint8Array`. This makes | ||
* it possible to import the WASM module directly, and use it with the | ||
* `WebAssembly.instantiate` function. | ||
* Get the imports code for the WASM module. This code imports each of the | ||
* imports from the WASM module. | ||
* | ||
* This is useful, because snaps are not allowed to import assets from outside | ||
* of their package. This loader allows you to inline the WASM module as a | ||
* `Uint8Array`, which can then be passed to `WebAssembly.instantiate`. | ||
* @param importMap - The import map for the WASM module. | ||
* @returns The imports code for the WASM module. | ||
*/ | ||
export function getImports(importMap: Record<string, string[]>) { | ||
return Object.entries(importMap) | ||
.map( | ||
([moduleName, exportNames]) => | ||
`import { ${exportNames.join(', ')} } from ${JSON.stringify( | ||
moduleName, | ||
)};`, | ||
) | ||
.join('\n'); | ||
} | ||
|
||
/** | ||
* Get the imports code to use in `WebAssembly.Instance`. This code adds each of | ||
* the imports to the `imports` object. | ||
* | ||
* @param source - The WASM module as a string. | ||
* @returns A string that exports the WASM module as a `Uint8Array`. | ||
* @param importMap - The import map for the WASM module. | ||
* @returns The imports code for the WASM module. | ||
*/ | ||
export function getModuleImports(importMap: Record<string, string[]>) { | ||
return Object.entries(importMap) | ||
.map( | ||
([moduleName, exportNames]) => | ||
`${JSON.stringify(moduleName)}: { ${exportNames.join(', ')} },`, | ||
) | ||
.join('\n'); | ||
} | ||
|
||
/** | ||
* Get the exports code for the WASM module. This code exports each of the | ||
* exports from the WASM module as a variable. This function assumes that the | ||
* exports are available in a variable named `exports`. | ||
* | ||
* @param descriptors - The export descriptors from the WASM module. | ||
* @returns The exports code for the WASM module. | ||
*/ | ||
export function getExports(descriptors: WebAssembly.ModuleExportDescriptor[]) { | ||
return descriptors | ||
.map((descriptor) => { | ||
if (descriptor.name === 'default') { | ||
return `export default exports[${JSON.stringify(descriptor.name)}];`; | ||
} | ||
|
||
return `export const ${descriptor.name} = exports[${JSON.stringify( | ||
descriptor.name, | ||
)}];`; | ||
}) | ||
.join('\n'); | ||
} | ||
|
||
/** | ||
* A Webpack loader that synchronously loads the WASM module. This makes it | ||
* possible to import the WASM module directly. | ||
* | ||
* @param source - The WASM module as `Uint8Array`. | ||
* @returns The WASM module as a JavaScript string. | ||
* @example | ||
* ```ts | ||
* import wasm from './program.wasm'; | ||
* import * as wasm from './program.wasm'; | ||
* | ||
* const { instance } = await WebAssembly.instantiate(wasm, {}); | ||
* // Do something with the WASM module... | ||
* ``` | ||
*/ | ||
export default function loader(source: unknown) { | ||
assert(typeof source === 'string', 'Expected source to be a string.'); | ||
// Note: This function needs to be defined like this, so that Webpack can bind | ||
// `this` to the loader context, and TypeScript can infer the type of `this`. | ||
const loader: LoaderDefinitionFunction = async function loader( | ||
source: unknown, | ||
) { | ||
assert(source instanceof Uint8Array, 'Expected source to be a Uint8Array.'); | ||
|
||
const bytes = stringToBytes(source); | ||
return `export default new Uint8Array(${JSON.stringify(Array.from(bytes))});`; | ||
} | ||
const bytes = new Uint8Array(source); | ||
const wasmModule = await WebAssembly.compile(bytes); | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-shadow | ||
const exports = WebAssembly.Module.exports(wasmModule); | ||
const imports = WebAssembly.Module.imports(wasmModule).reduce< | ||
Record<string, string[]> | ||
>((target, descriptor) => { | ||
target[descriptor.module] ??= []; | ||
target[descriptor.module].push(descriptor.name); | ||
|
||
return target; | ||
}, {}); | ||
|
||
// Add the WASM import as a dependency so that Webpack will watch it for | ||
// changes. | ||
for (const name of Object.keys(imports)) { | ||
this.addDependency(name); | ||
} | ||
|
||
return ` | ||
${getImports(imports)} | ||
const bytes = new Uint8Array(${JSON.stringify(Array.from(source))}); | ||
const module = new WebAssembly.Module(bytes); | ||
const instance = new WebAssembly.Instance(module, { | ||
${getModuleImports(imports)} | ||
}); | ||
const exports = instance.exports; | ||
${getExports(exports)} | ||
`; | ||
}; | ||
|
||
export default loader; | ||
|
||
// By setting `raw` to `true`, we are telling Webpack to provide the source as a | ||
// `Uint8Array` instead of converting it to a string. This allows us to avoid | ||
// having to convert the source back to a `Uint8Array` in the loader. | ||
export const raw = true; |