UNPKG

@wasm-tool/rollup-plugin-rust

Version:

Rollup plugin for bundling and importing Rust crates.

792 lines (595 loc) 23.3 kB
import * as $path from "node:path"; import * as $toml from "@iarna/toml"; import { createFilter } from "@rollup/pluginutils"; import { glob, rm, read, readString, debug, getEnv, isObject, eachObject } from "./utils.js"; import * as $wasmBindgen from "./wasm-bindgen.js"; import * as $cargo from "./cargo.js"; import * as $wasmOpt from "./wasm-opt.js"; import * as $typescript from "./typescript.js"; const PREFIX = "./.__rollup-plugin-rust__"; const INLINE_ID = "\0__rollup-plugin-rust-inlineWasm__"; function stripPath(path) { return path.replace(/\?[^\?]*$/, ""); } class State { constructor(options) { // Whether the plugin is running in Vite or not this.vite = false; // Whether we're in watch mode or not this.watch = false; // Whether to optimize in release mode this.release = true; // Whether the options have been processed or not this.processed = false; this.fileIds = new Set(); this.options = options; this.defaults = { watchPatterns: ["src/**"], inlineWasm: false, verbose: false, nodejs: false, optimize: { release: null, rustc: true, }, extraArgs: { cargo: [], wasmBindgen: [], // TODO figure out better optimization options ? wasmOpt: ["-O"], }, experimental: { synchronous: false, typescriptDeclarationDir: null, }, }; this.deprecations = { debug: (cx, value) => { cx.warn("The `debug` option has been changed to `optimize.release`"); this.options.optimize.release = !value; }, cargoArgs: (cx, value) => { cx.warn("The `cargoArgs` option has been changed to `extraArgs.cargo`"); this.options.extraArgs.cargo = value; }, wasmBindgenArgs: (cx, value) => { cx.warn("The `wasmBindgenArgs` option has been changed to `extraArgs.wasmBindgen`"); this.options.extraArgs.wasmBindgen = value; }, wasmOptArgs: (cx, value) => { cx.warn("The `wasmOptArgs` option has been changed to `extraArgs.wasmOpt`"); this.options.extraArgs.wasmOpt = value; }, serverPath: (cx, value) => { cx.warn("The `serverPath` option is deprecated and no longer works"); }, importHook: (cx, value) => { cx.warn("The `importHook` option is deprecated and no longer works"); }, experimental: { directExports: (cx, value) => { cx.warn("The `experimental.directExports` option is deprecated and no longer works"); }, }, }; this.cache = { nightly: {}, targetDir: {}, wasmBindgen: {}, build: {}, }; } reset() { this.fileIds.clear(); this.cache.nightly = {}; this.cache.targetDir = {}; this.cache.wasmBindgen = {}; this.cache.build = {}; } processOptions(cx) { function copyDefaults(defaults) { const options = {}; eachObject(defaults, (key, value) => { if (isObject(value)) { options[key] = copyDefaults(value); } else { options[key] = value; } }); return options; } if (!this.processed) { this.processed = true; const oldOptions = this.options; // Make a copy of the default settings this.options = copyDefaults(this.defaults); // Overwrite the default settings with the user-provided settings this.setOptions(cx, [], oldOptions, this.options, this.defaults, this.deprecations); } } setOptions(cx, path, oldOptions, options, defaults, deprecations) { if (oldOptions != null) { if (isObject(oldOptions)) { eachObject(oldOptions, (key, value) => { const newPath = path.concat([key]); // If the option is deprecated, call the function if (deprecations != null && key in deprecations) { const deprecation = deprecations[key]; if (isObject(deprecation)) { this.setOptions(cx, newPath, value, options?.[key], defaults?.[key], deprecation); } else { deprecation(cx, value); } // If the option has a default, apply it } else if (defaults != null && key in defaults) { const def = defaults[key]; if (isObject(def)) { this.setOptions(cx, newPath, value, options?.[key], def, deprecations?.[key]); } else if (value != null) { if (isObject(options)) { options[key] = value; } else { throw new Error("Invalid options state, please report this"); } } // The option doesn't exist } else { throw new Error(`The \`${newPath.join(".")}\` option does not exist`); } }); } else if (path.length > 0) { throw new Error(`The \`${path.join(".")}\` option must be an object`); } else { throw new Error(`Options must be an object`); } } } async watchFiles(cx, dir) { if (this.watch) { const matches = await Promise.all(this.options.watchPatterns.map((pattern) => glob(pattern, dir))); // TODO deduplicate matches ? matches.forEach(function (files) { files.forEach(function (file) { cx.addWatchFile(file); }); }); } } async getNightly(dir) { let nightly = this.cache.nightly[dir]; if (nightly == null) { nightly = this.cache.nightly[dir] = $cargo.getNightly(dir); } return await nightly; } async getTargetDir(dir) { let targetDir = this.cache.targetDir[dir]; if (targetDir == null) { targetDir = this.cache.targetDir[dir] = $cargo.getTargetDir(dir); } return await targetDir; } async getWasmBindgen(dir) { let bin = getEnv("WASM_BINDGEN_BIN", null); if (bin == null) { bin = this.cache.wasmBindgen[dir]; if (bin == null) { bin = this.cache.wasmBindgen[dir] = $wasmBindgen.download(dir, this.options.verbose); } return await bin; } else { return bin; } } async loadWasm(outDir) { const wasmPath = $path.join(outDir, "index_bg.wasm"); if (this.options.verbose) { debug(`Looking for wasm at ${wasmPath}`); } return await read(wasmPath); } async compileTypescript(name, outDir) { if (this.options.experimental.typescriptDeclarationDir != null) { await $typescript.write( name, this.options.experimental.typescriptDeclarationDir, outDir, ); } } async compileTypescriptCustom(name, isCustom) { if (isCustom && this.options.experimental.typescriptDeclarationDir != null) { await $typescript.writeCustom( name, this.options.experimental.typescriptDeclarationDir, this.options.inlineWasm, this.options.experimental.synchronous, ); } } async wasmOpt(cx, outDir) { if (this.release) { const result = await $wasmOpt.run({ dir: outDir, input: "index_bg.wasm", output: "wasm_opt.wasm", extraArgs: this.options.extraArgs.wasmOpt, verbose: this.options.verbose, }); if (result !== null) { cx.warn("wasm-opt failed: " + result.message); } } } compileInlineWasm(build) { const wasmString = JSON.stringify(build.wasm.toString("base64")); const code = ` const base64codes = [62,0,0,0,63,52,53,54,55,56,57,58,59,60,61,0,0,0,0,0,0,0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,0,0,0,0,0,0,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51]; function getBase64Code(charCode) { return base64codes[charCode - 43]; } function base64Decode(str) { let missingOctets = str.endsWith("==") ? 2 : str.endsWith("=") ? 1 : 0; let n = str.length; let result = new Uint8Array(3 * (n / 4)); let buffer; for (let i = 0, j = 0; i < n; i += 4, j += 3) { buffer = getBase64Code(str.charCodeAt(i)) << 18 | getBase64Code(str.charCodeAt(i + 1)) << 12 | getBase64Code(str.charCodeAt(i + 2)) << 6 | getBase64Code(str.charCodeAt(i + 3)); result[j] = buffer >> 16; result[j + 1] = (buffer >> 8) & 0xFF; result[j + 2] = buffer & 0xFF; } return result.subarray(0, result.length - missingOctets); } export default base64Decode(${wasmString}); `; return { code, map: { mappings: '' }, moduleSideEffects: false, }; } compileJsInline(build, isCustom) { let mainCode; let sideEffects; if (this.options.experimental.synchronous) { if (isCustom) { sideEffects = false; mainCode = `export { module }; export function init(options) { exports.initSync({ module: options.module, memory: options.memory, }); return exports; }` } else { sideEffects = true; mainCode = ` exports.initSync({ module }); export * from ${build.importPath}; `; } } else { if (isCustom) { sideEffects = false; mainCode = `export { module }; export async function init(options) { await exports.default({ module_or_path: await options.module, memory: options.memory, }); return exports; }`; } else { sideEffects = true; mainCode = ` await exports.default({ module_or_path: module }); export * from ${build.importPath}; `; } } const wasmString = JSON.stringify(build.wasm.toString("base64")); const code = ` import * as exports from ${build.importPath}; import module from "${INLINE_ID}"; ${mainCode} `; return { code, map: { mappings: '' }, moduleSideEffects: sideEffects, meta: { "rollup-plugin-rust": { root: false, realPath: build.realPath } }, }; } compileJsNormal(build, isCustom) { let wasmPath = `import.meta.ROLLUP_FILE_URL_${build.fileId}`; let prelude; if (this.options.nodejs) { prelude = `function loadFile(url) { return new Promise((resolve, reject) => { require("node:fs").readFile(url, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); } const module = loadFile(${wasmPath});`; } else { prelude = `const module = ${wasmPath};`; } let mainCode; let sideEffects; if (this.options.experimental.synchronous) { throw new Error("synchronous option can only be used with inlineWasm: true"); } else { if (isCustom) { sideEffects = false; mainCode = `export { module }; export async function init(options) { await exports.default({ module_or_path: await options.module, memory: options.memory, }); return exports; }`; } else { sideEffects = true; mainCode = ` await exports.default({ module_or_path: module }); export * from ${build.importPath}; `; } } return { code: ` import * as exports from ${build.importPath}; ${prelude} ${mainCode} `, map: { mappings: '' }, moduleSideEffects: sideEffects, meta: { "rollup-plugin-rust": { root: false, realPath: build.realPath } }, }; } compileJs(build, isCustom) { if (this.options.inlineWasm) { return this.compileJsInline(build, isCustom); } else { return this.compileJsNormal(build, isCustom); } } async getInfo(dir, id) { const [targetDir, source] = await Promise.all([ this.getTargetDir(dir), readString(id), ]); const toml = $toml.parse(source); // TODO make this faster somehow // TODO does it need to do more transformations on the name ? const name = toml.package.name.replace(/\-/g, "_"); const wasmPath = $path.resolve($path.join( targetDir, "wasm32-unknown-unknown", (this.release ? "release" : "debug"), name + ".wasm" )); const outDir = $path.resolve($path.join(targetDir, "rollup-plugin-rust", name)); if (this.options.verbose) { debug(`Using target directory ${targetDir}`); debug(`Using rustc output ${wasmPath}`); debug(`Using output directory ${outDir}`); } await rm(outDir); return { name, wasmPath, outDir }; } async buildCargo(dir) { const nightly = await this.getNightly(dir); await $cargo.run({ dir, nightly, verbose: this.options.verbose, extraArgs: this.options.extraArgs.cargo, release: this.release, optimize: this.options.optimize.rustc, }); } async buildWasm(cx, dir, bin, name, wasmPath, outDir) { await $wasmBindgen.run({ bin, dir, wasmPath, outDir, typescript: this.options.experimental.typescriptDeclarationDir != null, extraArgs: this.options.extraArgs.wasmBindgen, verbose: this.options.verbose, }); const [wasm] = await Promise.all([ this.wasmOpt(cx, outDir).then(() => { return this.loadWasm(outDir); }), this.compileTypescript(name, outDir), ]); let fileId; if (!this.options.inlineWasm) { fileId = cx.emitFile({ type: "asset", source: wasm, name: name + ".wasm" }); this.fileIds.add(fileId); } const realPath = $path.join(outDir, "index.js"); // This returns a fake file path, this ensures that the directory is the // same as the Cargo.toml file, which is necessary in order to make npm // package imports work correctly. const importPath = `"${PREFIX}${name}/index.js"`; return { name, outDir, importPath, realPath, wasm, fileId }; } async build(cx, dir, id) { try { if (this.options.verbose) { debug(`Compiling ${id}`); } const [bin, { name, wasmPath, outDir }] = await Promise.all([ this.getWasmBindgen(dir), this.getInfo(dir, id), this.buildCargo(dir), ]); return await this.buildWasm(cx, dir, bin, name, wasmPath, outDir); } catch (e) { if (this.options.verbose) { throw e; } else { const e = new Error("Rust compilation failed"); e.stack = null; throw e; } } } async load(cx, oldId) { const id = stripPath(oldId); let promise = this.cache.build[id]; if (promise == null) { const dir = $path.dirname(id); promise = this.cache.build[id] = Promise.all([ this.build(cx, dir, id), this.watchFiles(cx, dir), ]); } const [build] = await promise; if (oldId.endsWith("?inline")) { return this.compileInlineWasm(build); } else { const isCustom = oldId.endsWith("?custom"); const [result] = await Promise.all([ this.compileJs(build, isCustom), this.compileTypescriptCustom(build.name, isCustom), ]); return result; } } } export default function rust(options = {}) { // TODO should the filter affect the watching ? // TODO should the filter affect the Rust compilation ? const filter = createFilter(options.include, options.exclude); const state = new State(options); return { name: "rust", // Vite-specific hook configResolved(config) { state.vite = true; if (config.command !== "build") { // We have to force inlineWasm during dev because Vite doesn't support emitFile // https://github.com/vitejs/vite/issues/7029 state.options.inlineWasm = true; } }, buildStart(rollup) { state.reset(); state.processOptions(this); state.watch = this.meta.watchMode || rollup.watch; state.release = (state.options.optimize.release == null ? !state.watch : state.options.optimize.release); }, // This is only compatible with Rollup 2.78.0 and higher resolveId: { order: "pre", handler(id, importer, info) { if (id === INLINE_ID) { return { id: stripPath(importer) + "?inline", meta: { "rollup-plugin-rust": { root: true } } }; } else { const name = $path.basename(id); const normal = (name === "Cargo.toml"); const custom = (name === "Cargo.toml?custom"); if ((normal || custom) && filter(id)) { const path = (importer ? $path.resolve($path.dirname(importer), id) : $path.resolve(id)); return { id: path, moduleSideEffects: !custom, meta: { "rollup-plugin-rust": { root: true } } }; // Rewrites the fake file paths to real file paths. } else if (importer && id[0] === ".") { const info = this.getModuleInfo(importer); if (info && info.meta) { const meta = info.meta["rollup-plugin-rust"]; if (meta && !meta.root) { // TODO maybe use resolve ? const path = $path.join($path.dirname(importer), id); const realPath = (id.startsWith(PREFIX) ? meta.realPath : $path.join($path.dirname(meta.realPath), id)); return { id: path, meta: { "rollup-plugin-rust": { root: false, realPath, } } }; } } } } return null; }, }, load(id, loadState) { const info = this.getModuleInfo(id); if (info && info.meta) { const meta = info.meta["rollup-plugin-rust"]; if (meta) { if (meta.root) { // This causes Vite to load a noop module during SSR if (state.vite && loadState && loadState.ssr) { return { code: `export {};`, map: { mappings: '' }, moduleSideEffects: false, }; // This compiles the Cargo.toml } else { return state.load(this, id); } } else { if (options.verbose) { debug(`Loading file ${meta.realPath}`); } // This maps the fake path to a real path on disk and loads it return readString(meta.realPath); } } } return null; }, resolveFileUrl(info) { if (state.fileIds.has(info.referenceId)) { return `new URL(${JSON.stringify(info.fileName)}, import.meta.url)`; } else { return null; } }, }; };