-
-
Notifications
You must be signed in to change notification settings - Fork 6.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: support WebAssembly (Wasm) imports in ESM modules #13505
Merged
Merged
Changes from all commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
95ef976
Add failing test
kachkaev 645db88
Update `jest-runtime` to fix tests
kachkaev 9985179
Support data:application/wasm imports
kachkaev 5d73f9a
Run tests twice (with and without --experimental-wasm-modules)
kachkaev bc6d38c
Merge remote-tracking branch 'u/main' into native-esm-wasm
kachkaev 734bbc5
Cover more rows
kachkaev 612d480
Add CHANGELOG entry
kachkaev dcff11e
Simplify tests
kachkaev e524e40
Improve `path.endsWith`
kachkaev 08c8906
Delete unused file
kachkaev 66c8312
Make changelog phrase more explicit
kachkaev 6586a24
Fix Wasm casing
kachkaev 01f8d78
Update CHANGELOG.md
kachkaev 27d73b6
Mention Wasm in docs
kachkaev 2aea674
Merge remote-tracking branch 'origin/main' into native-esm-wasm
kachkaev 79e0f40
Use Wasm file from mdn examples
kachkaev 0d4945e
Implement `readFileBuffer`
kachkaev 26db3fd
Call loadEsmModule instead of linkAndEvaluateModule in _importWasmModule
kachkaev 1e6d75b
Update e2e/native-esm/__tests__/native-esm-wasm.test.js
kachkaev d4e9b73
Merge remote-tracking branch 'origin/main' into native-esm-wasm
kachkaev ad32edd
Add another test case for dynamic import
kachkaev 397bcd1
Extract `isWasm` function
kachkaev 976ef61
Merge remote-tracking branch 'origin/main' into native-esm-wasm
kachkaev 66a2312
Revert "Implement `readFileBuffer`"
SimenB 7188086
separate buffer cache
SimenB 26e7022
tweak
SimenB 607fc0f
clear new cache as well
SimenB File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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,56 @@ | ||
/** | ||
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. | ||
* | ||
* This source code is licensed under the MIT license found in the | ||
* LICENSE file in the root directory of this source tree. | ||
*/ | ||
|
||
import {readFileSync} from 'node:fs'; | ||
// file origin: https://github.com/mdn/webassembly-examples/blob/2f2163287f86fe29deb162335bccca7d5d95ca4f/understanding-text-format/add.wasm | ||
// source code: https://github.com/mdn/webassembly-examples/blob/2f2163287f86fe29deb162335bccca7d5d95ca4f/understanding-text-format/add.was | ||
import {add} from '../add.wasm'; | ||
|
||
const wasmFileBuffer = readFileSync('add.wasm'); | ||
|
||
test('supports native wasm imports', () => { | ||
expect(add(1, 2)).toBe(3); | ||
|
||
// because arguments are i32 (signed), fractional part is truncated | ||
expect(add(0.99, 1.01)).toBe(1); | ||
|
||
// because return value is i32 (signed), (2^31 - 1) + 1 overflows and becomes -2^31 | ||
expect(add(Math.pow(2, 31) - 1, 1)).toBe(-Math.pow(2, 31)); | ||
|
||
// invalid or missing arguments are treated as 0 | ||
expect(add('hello', 'world')).toBe(0); | ||
expect(add()).toBe(0); | ||
expect(add(null)).toBe(0); | ||
expect(add({}, [])).toBe(0); | ||
|
||
// redundant arguments are silently ignored | ||
expect(add(1, 2, 3)).toBe(3); | ||
}); | ||
|
||
test('supports dynamic wasm imports', async () => { | ||
const {add: dynamicAdd} = await import('../add.wasm'); | ||
expect(dynamicAdd(1, 2)).toBe(3); | ||
}); | ||
|
||
test('supports imports from "data:application/wasm" URI with base64 encoding', async () => { | ||
const importedWasmModule = await import( | ||
`data:application/wasm;base64,${wasmFileBuffer.toString('base64')}` | ||
); | ||
expect(importedWasmModule.add(0, 42)).toBe(42); | ||
}); | ||
|
||
test('imports from "data:application/wasm" URI without explicit encoding fail', async () => { | ||
await expect(() => | ||
import(`data:application/wasm,${wasmFileBuffer.toString('base64')}`), | ||
).rejects.toThrow('Missing data URI encoding'); | ||
}); | ||
|
||
test('imports from "data:application/wasm" URI with invalid encoding fail', async () => { | ||
await expect(() => | ||
import('data:application/wasm;charset=utf-8,oops'), | ||
).rejects.toThrow('Invalid data URI encoding: charset=utf-8'); | ||
}); |
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 |
---|---|---|
|
@@ -134,6 +134,8 @@ const getModuleNameMapper = (config: Config.ProjectConfig) => { | |
return null; | ||
}; | ||
|
||
const isWasm = (modulePath: string): boolean => modulePath.endsWith('.wasm'); | ||
|
||
const unmockRegExpCache = new WeakMap(); | ||
|
||
const EVAL_RESULT_VARIABLE = 'Object.<anonymous>'; | ||
|
@@ -154,6 +156,7 @@ const supportsNodeColonModulePrefixInRequire = (() => { | |
|
||
export default class Runtime { | ||
private readonly _cacheFS: Map<string, string>; | ||
private readonly _cacheFSBuffer = new Map<string, Buffer>(); | ||
private readonly _config: Config.ProjectConfig; | ||
private readonly _globalConfig?: Config.GlobalConfig; | ||
private readonly _coverageOptions: ShouldInstrumentOptions; | ||
|
@@ -397,10 +400,13 @@ export default class Runtime { | |
} | ||
|
||
// unstable as it should be replaced by https://github.com/nodejs/modules/issues/393, and we don't want people to use it | ||
unstable_shouldLoadAsEsm(path: string): boolean { | ||
return Resolver.unstable_shouldLoadAsEsm( | ||
path, | ||
this._config.extensionsToTreatAsEsm, | ||
unstable_shouldLoadAsEsm(modulePath: string): boolean { | ||
return ( | ||
isWasm(modulePath) || | ||
Resolver.unstable_shouldLoadAsEsm( | ||
modulePath, | ||
this._config.extensionsToTreatAsEsm, | ||
) | ||
); | ||
} | ||
|
||
|
@@ -441,6 +447,19 @@ export default class Runtime { | |
'Promise initialization should be sync - please report this bug to Jest!', | ||
); | ||
|
||
if (isWasm(modulePath)) { | ||
const wasm = this._importWasmModule( | ||
this.readFileBuffer(modulePath), | ||
modulePath, | ||
context, | ||
); | ||
|
||
this._esmoduleRegistry.set(cacheKey, wasm); | ||
|
||
transformResolve(); | ||
return wasm; | ||
} | ||
|
||
if (this._resolver.isCoreModule(modulePath)) { | ||
const core = this._importCoreModule(modulePath, context); | ||
this._esmoduleRegistry.set(cacheKey, core); | ||
|
@@ -567,56 +586,67 @@ export default class Runtime { | |
} | ||
|
||
const mime = match.groups.mime; | ||
if (mime === 'application/wasm') { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Quite a few lines below are marked as changed because of extra indent. Hiding whitespace in diff viewer makes it a bit easier to see the real difference. |
||
throw new Error('WASM is currently not supported'); | ||
} | ||
|
||
const encoding = match.groups.encoding; | ||
let code = match.groups.code; | ||
if (!encoding || encoding === 'charset=utf-8') { | ||
code = decodeURIComponent(code); | ||
} else if (encoding === 'base64') { | ||
code = Buffer.from(code, 'base64').toString(); | ||
} else { | ||
throw new Error(`Invalid data URI encoding: ${encoding}`); | ||
} | ||
|
||
let module; | ||
if (mime === 'application/json') { | ||
module = new SyntheticModule( | ||
['default'], | ||
function () { | ||
const obj = JSON.parse(code); | ||
// @ts-expect-error: TS doesn't know what `this` is | ||
this.setExport('default', obj); | ||
}, | ||
{context, identifier: specifier}, | ||
|
||
if (mime === 'application/wasm') { | ||
if (!encoding) { | ||
throw new Error('Missing data URI encoding'); | ||
} | ||
if (encoding !== 'base64') { | ||
throw new Error(`Invalid data URI encoding: ${encoding}`); | ||
} | ||
module = await this._importWasmModule( | ||
Buffer.from(match.groups.code, 'base64'), | ||
specifier, | ||
context, | ||
); | ||
} else { | ||
module = new SourceTextModule(code, { | ||
context, | ||
identifier: specifier, | ||
importModuleDynamically: async ( | ||
specifier: string, | ||
referencingModule: VMModule, | ||
) => { | ||
invariant( | ||
runtimeSupportsVmModules, | ||
'You need to run with a version of node that supports ES Modules in the VM API. See https://jestjs.io/docs/ecmascript-modules', | ||
); | ||
const module = await this.resolveModule( | ||
specifier, | ||
referencingModule.identifier, | ||
referencingModule.context, | ||
); | ||
let code = match.groups.code; | ||
if (!encoding || encoding === 'charset=utf-8') { | ||
code = decodeURIComponent(code); | ||
} else if (encoding === 'base64') { | ||
code = Buffer.from(code, 'base64').toString(); | ||
} else { | ||
throw new Error(`Invalid data URI encoding: ${encoding}`); | ||
} | ||
|
||
return this.linkAndEvaluateModule(module); | ||
}, | ||
initializeImportMeta(meta: ImportMeta) { | ||
// no `jest` here as it's not loaded in a file | ||
meta.url = specifier; | ||
}, | ||
}); | ||
if (mime === 'application/json') { | ||
module = new SyntheticModule( | ||
['default'], | ||
function () { | ||
const obj = JSON.parse(code); | ||
// @ts-expect-error: TS doesn't know what `this` is | ||
this.setExport('default', obj); | ||
}, | ||
{context, identifier: specifier}, | ||
); | ||
} else { | ||
module = new SourceTextModule(code, { | ||
context, | ||
identifier: specifier, | ||
importModuleDynamically: async ( | ||
specifier: string, | ||
referencingModule: VMModule, | ||
) => { | ||
invariant( | ||
runtimeSupportsVmModules, | ||
'You need to run with a version of node that supports ES Modules in the VM API. See https://jestjs.io/docs/ecmascript-modules', | ||
); | ||
const module = await this.resolveModule( | ||
specifier, | ||
referencingModule.identifier, | ||
referencingModule.context, | ||
); | ||
|
||
return this.linkAndEvaluateModule(module); | ||
}, | ||
initializeImportMeta(meta: ImportMeta) { | ||
// no `jest` here as it's not loaded in a file | ||
meta.url = specifier; | ||
}, | ||
}); | ||
} | ||
} | ||
|
||
this._esmoduleRegistry.set(specifier, module); | ||
|
@@ -1117,6 +1147,7 @@ export default class Runtime { | |
this._cjsNamedExports.clear(); | ||
this._moduleMockRegistry.clear(); | ||
this._cacheFS.clear(); | ||
this._cacheFSBuffer.clear(); | ||
|
||
if ( | ||
this._coverageOptions.collectCoverage && | ||
|
@@ -1640,6 +1671,50 @@ export default class Runtime { | |
return evaluateSyntheticModule(module); | ||
} | ||
|
||
private async _importWasmModule( | ||
source: Buffer, | ||
identifier: string, | ||
context: VMContext, | ||
) { | ||
const wasmModule = await WebAssembly.compile(source); | ||
|
||
const exports = WebAssembly.Module.exports(wasmModule); | ||
const imports = WebAssembly.Module.imports(wasmModule); | ||
|
||
const moduleLookup: Record<string, VMModule> = {}; | ||
for (const {module} of imports) { | ||
if (moduleLookup[module] === undefined) { | ||
moduleLookup[module] = await this.loadEsmModule( | ||
await this.resolveModule(module, identifier, context), | ||
); | ||
} | ||
} | ||
|
||
const syntheticModule = new SyntheticModule( | ||
exports.map(({name}) => name), | ||
function () { | ||
const importsObject: WebAssembly.Imports = {}; | ||
for (const {module, name} of imports) { | ||
if (!importsObject[module]) { | ||
importsObject[module] = {}; | ||
} | ||
importsObject[module][name] = moduleLookup[module].namespace[name]; | ||
} | ||
const wasmInstance = new WebAssembly.Instance( | ||
wasmModule, | ||
importsObject, | ||
); | ||
for (const {name} of exports) { | ||
// @ts-expect-error: TS doesn't know what `this` is | ||
this.setExport(name, wasmInstance.exports[name]); | ||
} | ||
}, | ||
{context, identifier}, | ||
); | ||
|
||
return syntheticModule; | ||
} | ||
|
||
private _getMockedNativeModule(): typeof nativeModule.Module { | ||
if (this._moduleImplementation) { | ||
return this._moduleImplementation; | ||
|
@@ -2305,11 +2380,24 @@ export default class Runtime { | |
}; | ||
} | ||
|
||
private readFileBuffer(filename: string): Buffer { | ||
let source = this._cacheFSBuffer.get(filename); | ||
|
||
if (!source) { | ||
source = fs.readFileSync(filename); | ||
|
||
this._cacheFSBuffer.set(filename, source); | ||
} | ||
|
||
return source; | ||
} | ||
|
||
private readFile(filename: string): string { | ||
let source = this._cacheFS.get(filename); | ||
|
||
if (!source) { | ||
source = fs.readFileSync(filename, 'utf8'); | ||
const buffer = this.readFileBuffer(filename); | ||
source = buffer.toString('utf8'); | ||
|
||
this._cacheFS.set(filename, source); | ||
} | ||
|
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@SimenB aren’t we doubling memory footprint by having these two caches? Most opened files will end up with an entry in both
_cacheFSBuffer
and_cacheFS
, while all reads will be from_cacheFS
except the first one.The impact can be insignificant, but there is a small chance that some tests may now fail with
JavaScript heap out of memory
in a new version. WDYT?