@bytecodealliance/jco
Version:
JavaScript tooling for working with WebAssembly Components
690 lines (635 loc) • 20.9 kB
JavaScript
/* global Buffer */
import { extname, basename, resolve } from 'node:path';
import { minify } from 'terser';
import { fileURLToPath } from 'node:url';
import { optimizeComponent } from './opt.js';
import { $init, generate } from '../../obj/js-component-bindgen-component.js';
import {
readFile,
spawnIOTmp,
setShowSpinner,
getShowSpinner,
writeFiles,
styleText,
ASYNC_WASI_IMPORTS,
ASYNC_WASI_EXPORTS,
DEFAULT_ASYNC_MODE,
} from '../common.js';
import { $init as wasmToolsInit, tools } from '../../obj/wasm-tools.js';
const { componentEmbed, componentNew } = tools;
import ora from '#ora';
import { isWindows } from '../common.js';
// These re-exports exist to avoid breaking backwards compatibility
export { types, guestTypes, typesComponent } from './types.js';
export async function transpile(witPath, opts, program) {
const varIdx = program?.parent.rawArgs.indexOf('--');
if (varIdx !== undefined && varIdx !== -1) {
opts.optArgs = program.parent.rawArgs.slice(varIdx + 1);
}
let component;
if (!opts.stub) {
component = await readFile(witPath);
} else {
await wasmToolsInit;
component = componentNew(
componentEmbed({
dummy: true,
witPath: (isWindows ? '//?/' : '') + resolve(witPath),
}),
[]
);
}
if (!opts.quiet) {
setShowSpinner(true);
}
if (!opts.name) {
opts.name = basename(
witPath.slice(0, -extname(witPath).length || Infinity)
);
}
if (opts.map) {
opts.map = Object.fromEntries(
opts.map.map((mapping) => mapping.split('='))
);
}
if (opts.asyncWasiImports) {
opts.asyncImports = ASYNC_WASI_IMPORTS.concat(opts.asyncImports || []);
}
if (opts.asyncWasiExports) {
opts.asyncExports = ASYNC_WASI_EXPORTS.concat(opts.asyncExports || []);
}
const { files } = await transpileComponent(component, opts);
await writeFiles(
files,
opts.quiet ? false : 'Transpiled JS Component Files'
);
}
/**
* @param {Uint8Array} source
* @returns {Promise<Uint8Array>}
*/
async function wasm2Js(source) {
const wasm2jsPath = fileURLToPath(
import.meta.resolve('binaryen/bin/wasm2js')
);
try {
return await spawnIOTmp(wasm2jsPath, source, ['-Oz', '-o']);
} catch (e) {
if (e.toString().includes('BasicBlock requested')) {
return wasm2Js(source);
}
throw e;
}
}
/**
* Execute the bundled pre-transpiled component that can perform component transpilation,
* for the given component.
*
* @param {Uint8Array} component
* @param {{
* name: string,
* instantiation?: 'async' | 'sync',
* importBindings?: 'js' | 'optimized' | 'hybrid' | 'direct-optimized',
* map?: Record<string, string>,
* asyncMode?: string,
* asyncImports?: string[],
* asyncExports?: string[],
* validLiftingOptimization?: bool,
* tracing?: bool,
* nodejsCompat?: bool,
* tlaCompat?: bool,
* base64Cutoff?: bool,
* js?: bool,
* minify?: bool,
* optimize?: bool,
* namespacedExports?: bool,
* outDir?: string,
* multiMemory?: bool,
* experimentalIdlImports?: bool,
* optArgs?: string[],
* wasmOptBin?: string[],
* }} opts
* @returns {Promise<{ files: { [filename: string]: Uint8Array }, imports: string[], exports: [string, 'function' | 'instance'][] }>}
*/
export async function transpileComponent(component, opts = {}) {
await $init;
if (opts.instantiation) {
opts.wasiShim = false;
}
let spinner;
const showSpinner = getShowSpinner();
if (opts.optimize) {
if (showSpinner) {
setShowSpinner(true);
}
({ component } = await optimizeComponent(component, opts));
}
if (opts.wasiShim !== false) {
opts.map = Object.assign(
{
'wasi:cli/*': '@bytecodealliance/preview2-shim/cli#*',
'wasi:clocks/*': '@bytecodealliance/preview2-shim/clocks#*',
'wasi:filesystem/*':
'@bytecodealliance/preview2-shim/filesystem#*',
'wasi:http/*': '@bytecodealliance/preview2-shim/http#*',
'wasi:io/*': '@bytecodealliance/preview2-shim/io#*',
'wasi:random/*': '@bytecodealliance/preview2-shim/random#*',
'wasi:sockets/*': '@bytecodealliance/preview2-shim/sockets#*',
},
opts.map || {}
);
}
let instantiation = null;
// Let's define `instantiation` from `--instantiation` if it's present.
if (opts.instantiation) {
instantiation = { tag: opts.instantiation };
}
// Otherwise, if `--js` is present, an `instantiate` function is required.
else if (opts.js) {
instantiation = { tag: 'async' };
}
// Get the configured async mode then transform it into what the types component expects
// Build list of async imports/exports
let asyncImports = new Set([...(opts.asyncImports ?? [])]);
let asyncExports = new Set([...(opts.asyncExports ?? [])]);
let asyncMode = opts.asyncMode ?? DEFAULT_ASYNC_MODE;
if (asyncMode === 'sync' && asyncExports.size > 0) {
throw new Error(
'async exports cannot be specified in sync mode (consider adding --async-mode=jspi)'
);
}
let asyncModeObj;
if (asyncMode === 'sync') {
asyncModeObj = null;
} else if (asyncMode === 'jspi') {
asyncModeObj = {
tag: 'jspi',
val: {
imports: [...asyncImports],
exports: [...asyncExports],
},
};
} else {
throw new Error(`invalid/unrecognized async mode [${asyncMode}]`);
}
let { files, imports, exports } = generate(component, {
name: opts.name ?? 'component',
map: Object.entries(opts.map ?? {}),
instantiation,
asyncMode: asyncModeObj,
importBindings: opts.importBindings
? { tag: opts.importBindings }
: null,
validLiftingOptimization: opts.validLiftingOptimization ?? false,
tracing: opts.tracing ?? false,
noNodejsCompat: opts.nodejsCompat === false,
noTypescript: opts.typescript === false,
tlaCompat: opts.tlaCompat ?? false,
base64Cutoff: opts.js ? 0 : (opts.base64Cutoff ?? 5000),
noNamespacedExports: opts.namespacedExports === false,
multiMemory: opts.multiMemory === true,
idlImports: opts.experimentalIdlImports === true,
});
let outDir = (opts.outDir ?? '').replace(/\\/g, '/');
if (!outDir.endsWith('/') && outDir !== '') {
outDir += '/';
}
files = files.map(([name, source]) => [`${outDir}${name}`, source]);
const jsFile = files.find(([name]) => name.endsWith('.js'));
// Generate code for the `--js` option.
//
// `--js` can be called with or without `--instantiation`. The generated code
// isn't exactly the same!
//
// `--js` needs an `instantiate` function to work, so it might look like
// `--instantiation` is always implied, but actually no. It is correct
// that when `--js` is present, an `instantiate` function _is_ generated,
// but it doesn't mean that we expect the function to be used, it's simply
// not exported, plus `instantiate` is automatically called (if `--tla-compat`
// is `false`). When `--instantiation` is missing, functions are exported
// with the `export` directive, and imports are imported with the `import`
// directive. When `--instantiation` is present, there is no `export` and no
// `import`: only a single exported `instantiate` function.
//
// Basically, we get this:
//
// * `--js` only:
// * `instantiate` is renamed to `_instantiate`,
// * A new `instantiate` function is created, that calls `_instantiate` with
// the correct imports (which are ASM.js code) and returns the exports,
// * A new `$init` function is created, that calls `instantiate` and maps
// the returned exports to their respective trampolines,
// * Trampolines are exported,
// * `$init` is called automatically.
//
// * `--js` with `--tla-compat`:
// * Same as with `--js` only, except that `$init` is exported instead of
// being called immediately.
//
// * `--js` with `--instantiation[=async]`:
// * `instantiate` is renamed to `_instantiate`,
// * A new `instantiate` function is created, that calls `_instantiate` with
// the correct imports (which are ASM.js code) and returns the exports,
// * `instantiate` is exported.
//
// * `--js` with `--instantiation=sync`:
// * Same as `--js` with `--instantiation[=async]`, except that
// `_instantiate` and `instantiate` are non-async.
//
// Be careful with the variables: `opts.instantiation` reflects the presence
// or the absence of the `--instantiation` flag, whilst `instantiation`
// reflects how the `instantiate` function must be generated. We also use
// `instantiation` to know whether the generated code must be async or
// non-async.
if (opts.js) {
const withInstantiation = opts.instantiation !== undefined;
const async_ = instantiation.tag == 'async' ? 'async ' : '';
const await_ = instantiation.tag == 'async' ? 'await ' : '';
// Format the previously generated code.
const source = Buffer.from(jsFile[1])
.toString('utf8')
// update imports manging to match emscripten asm
.replace(
/exports(\d+)\['([^']+)']/g,
(_, i, s) => `exports${i}['${asmMangle(s)}']`
)
.replace(
/export (async )?function instantiate/,
'$1function _instantiate'
);
// Collect all Wasm files.
const wasmFiles = files.filter(([name]) => name.endsWith('.wasm'));
files = files.filter(([name]) => !name.endsWith('.wasm'));
// Configure the spinner.
let completed = 0;
const spinnerText = () =>
`${styleText("cyan", `${completed} / ${wasmFiles.length}`)} Running Binaryen wasm2js on Wasm core modules (this takes a while)...\n`;
if (showSpinner) {
spinner = ora({
color: 'cyan',
spinner: 'bouncingBar',
}).start();
spinner.text = spinnerText();
}
// Compile all Wasm modules into ASM.js codes.
try {
const asmFiles = await Promise.all(
wasmFiles.map(async ([, source]) => {
const output = (await wasm2Js(source)).toString('utf8');
if (spinner) {
completed++;
spinner.text = spinnerText();
}
return output;
})
);
const asms = asmFiles
.map(
(asm, nth) => `function asm${nth}(imports) {
${
// strip and replace the asm instantiation wrapper
asm
.replace(/import \* as [^ ]+ from '[^']*';/g, '')
.replace('function asmFunc(imports) {', '')
.replace(/export var ([^ ]+) = ([^. ]+)\.([^ ]+);/g, '')
.replace(/var retasmFunc = [\s\S]*$/, '')
.replace(/var memasmFunc = new ArrayBuffer\(0\);/g, '')
.replace('memory.grow = __wasm_memory_grow;', '')
.trim()
}`
)
.join(',\n');
// The `instantiate` function.
const instantiateFunction = `${
withInstantiation ? 'export ' : ''
}${async_}function instantiate(imports) {
const wasm_file_to_asm_index = {
${wasmFiles
.map(([path], nth) => `'${basename(path)}': ${nth}`)
.join(',\n ')}
};
return ${await_}_instantiate(
module_name => wasm_file_to_asm_index[module_name],
imports,
(module_index, imports) => ({ exports: asmInit[module_index](imports) })
);
}`;
// If `--js` is used without `--instantiation`.
let importDirectives = '';
let exportDirectives = '';
let exportTrampolines = '';
let autoInstantiate = '';
if (!withInstantiation) {
importDirectives = imports
.map(
(import_file, nth) =>
`import * as import${nth} from '${import_file}';`
)
.join('\n');
if (exports.length > 0 || opts.tlaCompat) {
exportDirectives = `export {
${
// Exporting `$init` must come first to not break the transpiling tests.
opts.tlaCompat ? ' $init,\n' : ''
}${exports
.map(([name]) => {
if (name === asmMangle(name)) {
return ` ${name},`;
} else {
return ` ${asmMangle(name)} as '${name}',`;
}
})
.join('\n')}
}`;
}
exportTrampolines = `let ${exports
.filter(([, ty]) => ty === 'function')
.map(([name]) => `_${asmMangle(name)}`)
.join(', ')};
${exports
.map(([name, ty]) => {
if (ty === 'function') {
return `\nfunction ${asmMangle(name)} () {
return _${asmMangle(name)}.apply(this, arguments);
}`;
} else {
return `\nlet ${asmMangle(name)};`;
}
})
.join('\n')}`;
autoInstantiate = `${async_}function $init() {
( {
${exports
.map(([name, ty]) => {
if (ty === 'function') {
return ` '${name}': _${asmMangle(name)},`;
} else if (asmMangle(name) === name) {
return ` ${name},`;
} else {
return ` '${name}': ${asmMangle(name)},`;
}
})
.join('\n')}
} = ${await_}instantiate(
{
${imports
.map((import_file, nth) => ` '${import_file}': import${nth},`)
.join('\n')}
}
) )
}
${opts.tlaCompat ? '' : `${await_}$init();`}`;
}
// Prepare the final generated code.
const outSource = `${importDirectives}
${source}
const asmInit = [${asms}];
${exportTrampolines}
${instantiateFunction}
${exportDirectives}
${autoInstantiate}`;
// Save the final generated code.
jsFile[1] = Buffer.from(outSource);
} finally {
if (spinner) {
spinner.stop();
}
}
}
if (opts.minify) {
try {
({ code: jsFile[1] } = await minify(
Buffer.from(jsFile[1]).toString('utf8'),
{
module: true,
compress: {
ecma: 9,
unsafe: true,
},
mangle: {
keep_classnames: true,
},
}
));
} catch (err) {
console.error(`error while minifying JS: ${err}`);
throw err;
}
}
return { files: Object.fromEntries(files), imports, exports };
}
// emscripten asm mangles specifiers to be valid identifiers
// for imports to match up we must do the same
// See https://github.com/WebAssembly/binaryen/blob/main/src/asmjs/asmangle.cpp
function asmMangle(name) {
if (name === '') {
return '$';
}
let mightBeKeyword = true;
let i = 1;
// Names must start with a character, $ or _
switch (name[0]) {
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9': {
name = '$' + name;
i = 2;
// fallthrough
}
case '$':
case '_': {
mightBeKeyword = false;
break;
}
default: {
let chNum = name.charCodeAt(0);
if (
!(chNum >= 97 && chNum <= 122) &&
!(chNum >= 65 && chNum <= 90)
) {
name = '$' + name.substr(1);
mightBeKeyword = false;
}
}
}
// Names must contain only characters, digits, $ or _
let len = name.length;
for (; i < len; ++i) {
switch (name[i]) {
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
case '$':
case '_': {
mightBeKeyword = false;
break;
}
default: {
let chNum = name.charCodeAt(i);
if (
!(chNum >= 97 && chNum <= 122) &&
!(chNum >= 65 && chNum <= 90)
) {
name = name.substr(0, i) + '_' + name.substr(i + 1);
mightBeKeyword = false;
}
}
}
}
// Names must not collide with keywords
if (mightBeKeyword && len >= 2 && len <= 10) {
switch (name[0]) {
case 'a': {
if (name == 'arguments') {
return name + '_';
}
break;
}
case 'b': {
if (name == 'break') {
return name + '_';
}
break;
}
case 'c': {
if (
name == 'case' ||
name == 'continue' ||
name == 'catch' ||
name == 'const' ||
name == 'class'
) {
return name + '_';
}
break;
}
case 'd': {
if (name == 'do' || name == 'default' || name == 'debugger') {
return name + '_';
}
break;
}
case 'e': {
if (
name == 'else' ||
name == 'enum' ||
name == 'eval' || // to be sure
name == 'export' ||
name == 'extends'
) {
return name + '_';
}
break;
}
case 'f': {
if (
name == 'for' ||
name == 'false' ||
name == 'finally' ||
name == 'function'
) {
return name + '_';
}
break;
}
case 'i': {
if (
name == 'if' ||
name == 'in' ||
name == 'import' ||
name == 'interface' ||
name == 'implements' ||
name == 'instanceof'
) {
return name + '_';
}
break;
}
case 'l': {
if (name == 'let') {
return name + '_';
}
break;
}
case 'n': {
if (name == 'new' || name == 'null') {
return name + '_';
}
break;
}
case 'p': {
if (
name == 'public' ||
name == 'package' ||
name == 'private' ||
name == 'protected'
) {
return name + '_';
}
break;
}
case 'r': {
if (name == 'return') {
return name + '_';
}
break;
}
case 's': {
if (name == 'super' || name == 'static' || name == 'switch') {
return name + '_';
}
break;
}
case 't': {
if (
name == 'try' ||
name == 'this' ||
name == 'true' ||
name == 'throw' ||
name == 'typeof'
) {
return name + '_';
}
break;
}
case 'v': {
if (name == 'var' || name == 'void') {
return name + '_';
}
break;
}
case 'w': {
if (name == 'with' || name == 'while') {
return name + '_';
}
break;
}
case 'y': {
if (name == 'yield') {
return name + '_';
}
break;
}
}
}
return name;
}
// see: https://github.com/vitest-dev/vitest/issues/6953#issuecomment-2505310022
if (typeof __vite_ssr_import_meta__ !== 'undefined') {
__vite_ssr_import_meta__.resolve = (path) =>
'file://' + globalCreateRequire(import.meta.url).resolve(path);
}