UNPKG

@bytecodealliance/jco

Version:

JavaScript tooling for working with WebAssembly Components

675 lines (620 loc) 21 kB
/* global Buffer */ import { platform } from 'node:process'; import { extname, basename, resolve } from 'node:path'; import c from 'chalk-template'; import { minify } from 'terser'; import { fileURLToPath } from 'url'; import { optimizeComponent } from './opt.js'; import { $init, generate } from '../../obj/js-component-bindgen-component.js'; import { readFile, spawnIOTmp, setShowSpinner, getShowSpinner, writeFiles, ASYNC_WASI_IMPORTS, ASYNC_WASI_EXPORTS, } from '../common.js'; import { $init as wasmToolsInit, tools } from '../../obj/wasm-tools.js'; const { componentEmbed, componentNew } = tools; import ora from '#ora'; const isWindows = platform === 'win32'; // 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[], * }} 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' }; } const asyncMode = !opts.asyncMode || opts.asyncMode === 'sync' ? null : { tag: opts.asyncMode, val: { imports: opts.asyncImports || [], exports: opts.asyncExports || [], }, }; let { files, imports, exports } = generate(component, { name: opts.name ?? 'component', map: Object.entries(opts.map ?? {}), instantiation, asyncMode, 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 = () => c`{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); }