UNPKG

@bytecodealliance/jco

Version:

JavaScript tooling for working with WebAssembly Components

632 lines (577 loc) 20.9 kB
/* 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); }