@bytecodealliance/jco
Version:
JavaScript tooling for working with WebAssembly Components
632 lines (577 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)");
}
if (asyncMode === "sync" && asyncImports.size > 0) {
throw new Error("async imports 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);
}