diff --git a/Cargo.toml b/Cargo.toml index e4e38e0a3ab0..ebb9d0268721 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -240,8 +240,10 @@ url = "2.3" uuid = "1.1" vec1 = "1.8" walkdir = "2.0" -wasm-bindgen = "0.2.89" -wasm-bindgen-cli-support = "0.2.89" +# NOTE: `rerun_js/web-viewer/build-wasm.mjs` is HIGHLY sensitive to changes in `wasm-bindgen`. +# Whenever updating `wasm-bindgen`, make sure that the build script still works. +wasm-bindgen = "=0.2.92" +wasm-bindgen-cli-support = "=0.2.92" wasm-bindgen-futures = "0.4.33" web-sys = "0.3" web-time = "0.2.0" diff --git a/crates/re_dev_tools/src/build_web_viewer/lib.rs b/crates/re_dev_tools/src/build_web_viewer/lib.rs index 0713363c825c..25169b31355d 100644 --- a/crates/re_dev_tools/src/build_web_viewer/lib.rs +++ b/crates/re_dev_tools/src/build_web_viewer/lib.rs @@ -49,6 +49,20 @@ impl Profile { pub enum Target { Browser, Module, + + /// Custom target meant for post-processing inside `rerun_js`. + NoModulesBase, +} + +impl argh::FromArgValue for Target { + fn from_arg_value(value: &str) -> Result { + match value { + "browser" => Ok(Self::Browser), + "module" => Ok(Self::Module), + "no-modules-base" => Ok(Self::NoModulesBase), + _ => Err(format!("Unknown target: {value}")), + } + } } /// Build `re_viewer` as Wasm, generate .js bindings for it, and place it all into the `build_dir` folder. @@ -158,6 +172,10 @@ pub fn build( match target { Target::Browser => bindgen_cmd.no_modules(true)?.typescript(false), Target::Module => bindgen_cmd.no_modules(false)?.typescript(true), + Target::NoModulesBase => bindgen_cmd + .no_modules(true)? + .reference_types(true) + .typescript(true), }; if let Err(err) = bindgen_cmd.generate(build_dir.as_str()) { if err @@ -191,7 +209,13 @@ pub fn build( // to get wasm-opt: apt/brew/dnf install binaryen let mut cmd = std::process::Command::new("wasm-opt"); - let mut args = vec![wasm_path.as_str(), "-O2", "--output", wasm_path.as_str()]; + let mut args = vec![ + wasm_path.as_str(), + "-O2", + "--output", + wasm_path.as_str(), + "--enable-reference-types", + ]; if debug_symbols { args.push("-g"); } diff --git a/crates/re_dev_tools/src/build_web_viewer/mod.rs b/crates/re_dev_tools/src/build_web_viewer/mod.rs index 2c6b6f3c11f0..88e8497f0484 100644 --- a/crates/re_dev_tools/src/build_web_viewer/mod.rs +++ b/crates/re_dev_tools/src/build_web_viewer/mod.rs @@ -27,9 +27,9 @@ pub struct Args { #[argh(switch, short = 'g')] debug_symbols: bool, - /// if set, will build the module target instead of the browser target. - #[argh(switch)] - module: bool, + /// target to build for. + #[argh(option, short = 't', long = "target", default = "Target::Browser")] + target: Target, /// set the output directory. This is a path relative to the cargo workspace root. #[argh(option, short = 'o', long = "out")] @@ -47,12 +47,7 @@ pub fn main(args: Args) -> anyhow::Result<()> { )); }; - let target = if args.module { - Target::Module - } else { - Target::Browser - }; let build_dir = args.build_dir.unwrap_or_else(default_build_dir); - build(profile, args.debug_symbols, target, &build_dir) + build(profile, args.debug_symbols, args.target, &build_dir) } diff --git a/lychee.toml b/lychee.toml index 9a3c8e141061..d72bfad39b6c 100644 --- a/lychee.toml +++ b/lychee.toml @@ -103,6 +103,7 @@ exclude = [ 'http://foo.com/', 'https://link.to', 'https://static.rerun.io/my_screenshot/', + 'https://your-hosted-asset-url.com/widget.js', # Link fragments and data links in examples. 'https://raw.githubusercontent.com/googlefonts/noto-emoji/', # URL fragment. diff --git a/rerun_js/web-viewer/.gitignore b/rerun_js/web-viewer/.gitignore index 0ef0ef112dc7..83db499e7586 100644 --- a/rerun_js/web-viewer/.gitignore +++ b/rerun_js/web-viewer/.gitignore @@ -7,3 +7,5 @@ index.d.ts index.d.ts.map index.js index.js.map +inlined.js +inlined.d.ts diff --git a/rerun_js/web-viewer/build-wasm.mjs b/rerun_js/web-viewer/build-wasm.mjs new file mode 100644 index 000000000000..8921bf378bb9 --- /dev/null +++ b/rerun_js/web-viewer/build-wasm.mjs @@ -0,0 +1,125 @@ +// Script responsible for building the wasm and transforming the JS bindings for the web viewer. + +import * as child_process from "node:child_process"; +import { fileURLToPath } from "node:url"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import * as util from "node:util"; + +const __filename = path.resolve(fileURLToPath(import.meta.url)); +const __dirname = path.dirname(__filename); + +const exec = (cmd) => { + console.log(cmd); + child_process.execSync(cmd, { cwd: __dirname, stdio: "inherit" }); +}; + +function wasm(mode) { + switch (mode) { + case "debug": { + return exec( + "cargo run -p re_dev_tools -- build-web-viewer --debug --target no-modules-base -o rerun_js/web-viewer", + ); + } + case "release": { + return exec( + "cargo run -p re_dev_tools -- build-web-viewer --release -g --target no-modules-base -o rerun_js/web-viewer", + ); + } + default: + throw new Error(`Unknown mode: ${mode}`); + } +} + +child_process.execSync( + "cargo run -p re_dev_tools -- build-web-viewer --debug --target no-modules-base -o rerun_js/web-viewer", + { cwd: __dirname, stdio: "inherit" }, +); + +function script() { + let code = fs.readFileSync(path.join(__dirname, "re_viewer.js"), "utf-8"); + + // this transforms the module, wrapping it in a default-exported function. + // calling the function produces a new "instance" of the module, because + // all of the globals are scoped to the function, and become closure state + // for any functions that reference them within the module. + // + // we do this so that we don't leak globals across web viewer instantiations: + // https://github.com/rustwasm/wasm-bindgen/issues/3130 + // + // this is HIGHLY sensitive to the exact output of `wasm-bindgen`, so if + // the output changes, this will need to be updated. + + const start = `let wasm_bindgen; +(function() {`; + const end = `wasm_bindgen = Object.assign(__wbg_init, { initSync }, __exports); + +})();`; + code = code.replace(start, "").replace(end, ""); + + code = ` +export default function() { +${code} + +function deinit() { + __wbg_init.__wbindgen_wasm_module = null; + wasm = null; + cachedFloat32Memory0 = null; + cachedFloat64Memory0 = null; + cachedInt32Memory0 = null; + cachedUint32Memory0 = null; + cachedUint8Memory0 = null; +} + +return Object.assign(__wbg_init, { initSync, deinit }, __exports); +} +`; + + // Since we are nulling `wasm` we also have to patch the closure destructor code to let things be cleaned up fully. + // Otherwise we end up with an exceptioon during closure destruction which prevents the references from all being + // cleaned up properly. + // TODO(jprochazk): Can we force these to run before we null `wasm` instead? + const closure_dtors = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => { + wasm.__wbindgen_export_3.get(state.dtor)(state.a, state.b)`; + + const closure_dtors_patch = `const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => { + wasm?.__wbindgen_export_3.get(state.dtor)(state.a, state.b)`; + + code = code.replace(closure_dtors, closure_dtors_patch); + + fs.writeFileSync(path.join(__dirname, "re_viewer.js"), code); +} + +function types() { + let code = fs.readFileSync(path.join(__dirname, "re_viewer.d.ts"), "utf-8"); + + // this transformation just re-exports WebHandle and adds a default export inside the `.d.ts` file + + code = ` +${code} +export type WebHandle = wasm_bindgen.WebHandle; +export default function(): wasm_bindgen; +`; + + fs.writeFileSync(path.join(__dirname, "re_viewer.d.ts"), code); +} + +const args = util.parseArgs({ + options: { + mode: { + type: "string", + }, + }, +}); + +if (!args.values.mode) { + throw new Error("Missing required argument: mode"); +} + +wasm(args.values.mode); +script(); +types(); diff --git a/rerun_js/web-viewer/bundle.mjs b/rerun_js/web-viewer/bundle.mjs new file mode 100644 index 000000000000..c93feaf232b1 --- /dev/null +++ b/rerun_js/web-viewer/bundle.mjs @@ -0,0 +1,63 @@ +// @ts-check + +// Script responsible for taking the generated Wasm/JS, and transpiled TS +// and producing a single file with everything inlined. + +import { fileURLToPath } from "node:url"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import * as util from "node:util"; + +const __filename = path.resolve(fileURLToPath(import.meta.url)); +const __dirname = path.dirname(__filename); + +const wasm = fs.readFileSync(path.join(__dirname, "re_viewer_bg.wasm")); +const js = fs.readFileSync(path.join(__dirname, "re_viewer.js"), "utf-8"); +const index = fs.readFileSync(path.join(__dirname, "index.js"), "utf-8"); + +const INLINE_MARKER = "/**/"; + +/** @param {Buffer} buffer */ +function buffer_to_data_url(buffer) { + return `data:application/wasm;base64,${buffer.toString("base64")}`; +} + +async function data_url_to_buffer(dataUrl) { + const response = await fetch(dataUrl); + return response.arrayBuffer(); +} + +const inlined_js = js.replace("export default function", "return function"); + +const inlined_code = ` +async function fetch_viewer_js() { + ${inlined_js} +} + +async function fetch_viewer_wasm() { + ${data_url_to_buffer.toString()} + const dataUrl = ${JSON.stringify(buffer_to_data_url(wasm))}; + const buffer = await data_url_to_buffer(dataUrl); + return new Response(buffer, { "headers": { "Content-Type": "application/wasm" } }); +} +`; + +// replace INLINE_MARKER, inclusive +const inline_start = index.indexOf(INLINE_MARKER); +if (inline_start === -1) { + throw new Error("no inline marker in source file"); +} +let inline_end = index.indexOf(INLINE_MARKER, inline_start + 1); +if (inline_end === -1) { + throw new Error("no inline marker in source file"); +} +inline_end += INLINE_MARKER.length; + +const bundle = + index.substring(0, inline_start) + inlined_code + index.substring(inline_end); + +fs.writeFileSync(path.join(__dirname, "inlined.js"), bundle); +fs.copyFileSync( + path.join(__dirname, "index.d.ts"), + path.join(__dirname, "inlined.d.ts"), +); diff --git a/rerun_js/web-viewer/index.ts b/rerun_js/web-viewer/index.ts index 2f3837ff61e1..62173a31ee22 100644 --- a/rerun_js/web-viewer/index.ts +++ b/rerun_js/web-viewer/index.ts @@ -1,28 +1,36 @@ -import type { WebHandle } from "./re_viewer.js"; +import type { WebHandle, wasm_bindgen } from "./re_viewer"; -interface AppOptions { - url?: string; - manifest_url?: string; - render_backend?: Backend; - hide_welcome_screen?: boolean; - panel_state_overrides?: Partial<{ - [K in Panel]: PanelState; - }>; - fullscreen?: FullscreenOptions; -} +let get_wasm_bindgen: (() => typeof wasm_bindgen) | null = null; +let _wasm_module: WebAssembly.Module | null = null; -type WebHandleConstructor = { - new (app_options?: AppOptions): WebHandle; -}; - -let WebHandleConstructor: WebHandleConstructor | null = null; +/**/ +async function fetch_viewer_js() { + return (await import("./re_viewer")).default; +} -async function load(): Promise { - if (WebHandleConstructor) { - return WebHandleConstructor; +async function fetch_viewer_wasm() { + return fetch(new URL("./re_viewer_bg.wasm", import.meta.url)); +} +/**/ + +async function load(): Promise { + // instantiate wbg globals+module for every invocation of `load`, + // but don't load the JS/Wasm source every time + if (!get_wasm_bindgen || !_wasm_module) { + [get_wasm_bindgen, _wasm_module] = await Promise.all([ + fetch_viewer_js(), + WebAssembly.compileStreaming(fetch_viewer_wasm()), + ]); } - WebHandleConstructor = (await import("./re_viewer.js")).WebHandle; - return WebHandleConstructor; + let bindgen = get_wasm_bindgen(); + await bindgen(_wasm_module); + return class extends bindgen.WebHandle { + free() { + super.free(); + // @ts-expect-error + bindgen.deinit(); + } + }; } let _minimize_current_fullscreen_viewer: (() => void) | null = null; @@ -49,6 +57,19 @@ interface WebViewerOptions { height?: string; } +// `AppOptions` and `WebViewerOptions` must be compatible +// otherwise we need to restructure how we pass options to the viewer +interface AppOptions extends WebViewerOptions { + url?: string; + manifest_url?: string; + render_backend?: Backend; + hide_welcome_screen?: boolean; + panel_state_overrides?: Partial<{ + [K in Panel]: PanelState; + }>; + fullscreen?: FullscreenOptions; +} + interface FullscreenOptions { get_state: () => boolean; on_toggle: () => void; @@ -82,6 +103,8 @@ function delay(ms: number) { export class WebViewer { #id = randomId(); + // NOTE: Using the handle requires wrapping all calls to its methods in try/catch. + // On failure, call `this.stop` to prevent a memory leak, then re-throw the error. #handle: WebHandle | null = null; #canvas: HTMLCanvasElement | null = null; #state: "ready" | "starting" | "stopped" = "stopped"; @@ -135,12 +158,13 @@ export class WebViewer { : undefined; this.#handle = new WebHandle_class({ ...options, fullscreen }); - await this.#handle.start(this.#canvas.id); - if (this.#state !== "starting") return; - - if (this.#handle.has_panicked()) { - throw new Error(`Web viewer crashed: ${this.#handle.panic_message()}`); + try { + await this.#handle.start(this.#canvas.id); + } catch (e) { + this.stop(); + throw e; } + if (this.#state !== "starting") return; this.#state = "ready"; this.#dispatch_event("ready"); @@ -269,11 +293,14 @@ export class WebViewer { if (!this.#handle) { throw new Error(`attempted to open \`${rrd}\` in a stopped viewer`); } + const urls = Array.isArray(rrd) ? rrd : [rrd]; for (const url of urls) { - this.#handle.add_receiver(url, options.follow_if_http); - if (this.#handle.has_panicked()) { - throw new Error(`Web viewer crashed: ${this.#handle.panic_message()}`); + try { + this.#handle.add_receiver(url, options.follow_if_http); + } catch (e) { + this.stop(); + throw e; } } } @@ -289,11 +316,14 @@ export class WebViewer { if (!this.#handle) { throw new Error(`attempted to close \`${rrd}\` in a stopped viewer`); } + const urls = Array.isArray(rrd) ? rrd : [rrd]; for (const url of urls) { - this.#handle.remove_receiver(url); - if (this.#handle.has_panicked()) { - throw new Error(`Web viewer crashed: ${this.#handle.panic_message()}`); + try { + this.#handle.remove_receiver(url); + } catch (e) { + this.stop(); + throw e; } } } @@ -312,8 +342,14 @@ export class WebViewer { this.#state = "stopped"; this.#canvas?.remove(); - this.#handle?.destroy(); - this.#handle?.free(); + + try { + this.#handle?.destroy(); + this.#handle?.free(); + } catch (e) { + this.#handle = null; + throw e; + } this.#canvas = null; this.#handle = null; @@ -334,25 +370,48 @@ export class WebViewer { `attempted to open channel \"${channel_name}\" in a stopped web viewer`, ); } + const id = crypto.randomUUID(); - this.#handle.open_channel(id, channel_name); + + try { + this.#handle.open_channel(id, channel_name); + } catch (e) { + this.stop(); + throw e; + } + const on_send = (/** @type {Uint8Array} */ data: Uint8Array) => { if (!this.#handle) { throw new Error( `attempted to send data through channel \"${channel_name}\" to a stopped web viewer`, ); } - this.#handle.send_rrd_to_channel(id, data); + + try { + this.#handle.send_rrd_to_channel(id, data); + } catch (e) { + this.stop(); + throw e; + } }; + const on_close = () => { if (!this.#handle) { throw new Error( `attempted to send data through channel \"${channel_name}\" to a stopped web viewer`, ); } - this.#handle.close_channel(id); + + try { + this.#handle.close_channel(id); + } catch (e) { + this.stop(); + throw e; + } }; + const get_state = () => this.#state; + return new LogChannel(on_send, on_close, get_state); } @@ -368,7 +427,13 @@ export class WebViewer { `attempted to set ${panel} panel to ${state} in a stopped web viewer`, ); } - this.#handle.override_panel_state(panel, state); + + try { + this.#handle.override_panel_state(panel, state); + } catch (e) { + this.stop(); + throw e; + } } /** @@ -382,7 +447,13 @@ export class WebViewer { `attempted to toggle panel overrides in a stopped web viewer`, ); } - this.#handle.toggle_panel_overrides(value as boolean | undefined); + + try { + this.#handle.toggle_panel_overrides(value as boolean | undefined); + } catch (e) { + this.stop(); + throw e; + } } /** diff --git a/rerun_js/web-viewer/package.json b/rerun_js/web-viewer/package.json index f36e894deb4d..d4c38e321790 100644 --- a/rerun_js/web-viewer/package.json +++ b/rerun_js/web-viewer/package.json @@ -11,10 +11,10 @@ } ], "scripts": { - "build:wasm": "cargo run -p re_dev_tools -- build-web-viewer --release -g --module -o rerun_js/web-viewer", - "build:js": "tsc", + "build:wasm": "node build-wasm.mjs --mode release", + "build:js": "tsc && node bundle.mjs", "build": "npm run build:wasm && npm run build:js", - "build:wasm:debug": "cargo run -p re_dev_tools -- build-web-viewer --debug --module -o rerun_js/web-viewer", + "build:wasm:debug": "node build-wasm.mjs --mode debug", "build:debug": "npm run build:wasm:debug && npm run build:js" }, "repository": { @@ -28,6 +28,8 @@ }, "homepage": "https://rerun.io", "type": "module", + "main": "index.js", + "types": "index.d.ts", "exports": { ".": { "types": "./index.d.ts", @@ -36,16 +38,19 @@ "./re_viewer.js": { "types": "./re_viewer.d.ts", "import": "./re_viewer.js" + }, + "./inlined.js": { + "types": "./inlined.d.ts", + "import": "./inlined.js" } }, "files": [ - "index.ts", "index.d.ts", "index.d.ts.map", "index.js", "index.js.map", + "index.ts", "package.json", - "re_viewer_bg.js", "re_viewer_bg.wasm", "re_viewer_bg.wasm.d.ts", "re_viewer.d.ts", diff --git a/rerun_notebook/build.mjs b/rerun_notebook/build.mjs index d620f88284c5..bbe75b8b2f1b 100644 --- a/rerun_notebook/build.mjs +++ b/rerun_notebook/build.mjs @@ -14,9 +14,12 @@ if (args.values.watch) { } async function watch() { - const watcher = await subscribe(path.join(process.cwd(), "src/js"), async () => { - await build(); - }); + const watcher = await subscribe( + path.join(process.cwd(), "src/js"), + async () => { + await build(); + }, + ); process.on("SIGINT", async () => { await watcher.unsubscribe(); @@ -35,9 +38,13 @@ async function build() { entryPoints: ["src/js/widget.ts"], bundle: true, format: "esm", - minify: true, + // Minification doesn't help much with size, most of it is the embedded wasm binary. + // What it _does_ do is cause most editors to be unable to open the file at all, + // because it ends up being a single 30 MB-long line. + // + // minify: true, + keepNames: true, outdir: "src/rerun_notebook/static", - plugins: [wasmLoader({ mode: "embedded" })], }); log(`Built widget in ${Date.now() - start}ms`); } catch (e) { @@ -58,4 +65,3 @@ function log(message) { function error(message) { return console.error(`[${now()}] ${message}`); } - diff --git a/rerun_notebook/src/js/widget.ts b/rerun_notebook/src/js/widget.ts index c514ab6367d5..f6333c00bcc3 100644 --- a/rerun_notebook/src/js/widget.ts +++ b/rerun_notebook/src/js/widget.ts @@ -1,4 +1,10 @@ -import { LogChannel, Panel, PanelState, WebViewer } from "@rerun-io/web-viewer"; +import { + type LogChannel, + type Panel, + type PanelState, + WebViewer, +} from "@rerun-io/web-viewer/inlined.js"; + import type { AnyModel, Render } from "@anywidget/types"; import "./widget.css"; diff --git a/rerun_notebook/src/rerun_notebook/__init__.py b/rerun_notebook/src/rerun_notebook/__init__.py index d90939de8ca9..763c056ffa0b 100644 --- a/rerun_notebook/src/rerun_notebook/__init__.py +++ b/rerun_notebook/src/rerun_notebook/__init__.py @@ -47,7 +47,7 @@ else: ESM_MOD = ASSET_ENV if not (ASSET_ENV.startswith("http://") or ASSET_ENV.startswith("https://")): - raise ValueError(f"RERUN_NOTEBOOK_ASSET_URL should be a URL starting with http or https. Found: {ASSET_ENV}") + raise ValueError(f"RERUN_NOTEBOOK_ASSET should be a URL starting with http or https. Found: {ASSET_ENV}") class Viewer(anywidget.AnyWidget):