| # The browser scripting lane | |
| On native platforms script modules run on WAMR, which is compiled into | |
| librive. On the web they run on the browser's own wasm engine instead, and | |
| WAMR is not in the build at all. This folder holds the page side of that. | |
| The web supports AnimaScript modules. The Luau host module, which is the | |
| Luau VM compiled to wasm, also needs a libc, WASI and setjmp/longjmp from its | |
| host. The page does not provide those, so that module fails to link there. | |
| Nothing forbids it; it is just not supported. | |
| ## How a file loads on the web | |
| Loading is two steps, so that the browser can instantiate modules | |
| asynchronously and no artboard ever exists before its scripts are ready. | |
| 1. `File::import` reads the file as usual. For each script module it makes a | |
| `BrowserScriptingVM` and registers the module bytes with the page. Nothing | |
| is instantiated and no script runs. | |
| 2. The host awaits `Module.riveScripting.prepare()`. The page compiles and | |
| instantiates every registered module. Module start runs each script's top | |
| level. | |
| 3. The host calls `File::startScripts()`. Each VM claims its instance, runs | |
| the same module boot as native (`host_newstate`, the installs, seal), and | |
| the file registers its scripts. Only now does the app get the File. | |
| A host that skips step 2 still works on any browser that allows synchronous | |
| compilation on the calling thread: step 3 instantiates inline. Every current | |
| browser allows that for modules of our size, see | |
| `browser_lane_instantiation_study.md`. | |
| On a build with threads, both paths compile each lane module the file carries | |
| once on the page and post it to the idle workers, so a pool thread does not | |
| compile it again for its first job. A thread that was already running when | |
| the file loaded gets no message, and compiles its own. | |
| The editor also boots a VM outside of a file load, to preview scripts it just | |
| compiled: `riveWasmVMBoot` in rive_native, which the editor then adopts into | |
| the open file. That call is synchronous, so on the web it registers the | |
| module and starts it inline, without the prepare step. | |
| Both of our web hosts do all of this inside their own file load, so apps see | |
| no change: | |
| - rive_native: `decodeRiveFile` in `lib/src/web/rive_web.dart`, next to the | |
| wait for image decodes. The C entry point is `riveFileStartScripts`. | |
| - The JS runtime: `Module.startFileScripts` in `wasm/js/shared.js`, used by | |
| both `load` wrappers. The binding is `File.startScripts`. | |
| Script heaps also need `File::frameBoundary` once a frame, after everything | |
| advanced and drew, or a frame collector module never collects. The JS | |
| runtime keeps no list of its files, so at the end of each frame, the | |
| batched animation frame or `resolveAnimationFrame`, it calls | |
| `Module.riveScripting.frameBoundary()`, which services every live VM and | |
| returns their leak warnings for `console.warn`. | |
| ## The files here | |
| - `rive_module_imports.mjs`, generated. The import object a script module is | |
| instantiated against, and `bindRiveHostCalls`, which binds librive's | |
| `rive_web_*` functions to one VM. librive exports them as one table, | |
| `rive_web_calls`, in the IDL's op order, so their names stay out of the | |
| binary and the glue. | |
| - `rive_script_runner.mjs`, hand written. Keeps one entry per VM, prepares | |
| and starts modules, and makes every call into a module, catching traps. | |
| - `rive_parallel.mjs`, hand written. Parallel calls on a build with threads: | |
| the team of pool threads, the members each runs for a module, and the run | |
| that hands a call's parts to them. | |
| - `rive_scripting_pre.js`, generated from the files above. One plain | |
| script for emscripten's `--pre-js`. It installs itself as | |
| `Module.riveScripting`. This is what hosts link. | |
| - `rive_audio_worklet_pre.js`, generated. The browser audio worklet and the | |
| lane modules it imports as one script's source, for builds with threads to | |
| link beside the bundle above. | |
| - `rive_threads_pre.js`, hand written. Every build with threads links it. On | |
| a page that cannot share memory such a build would wait forever for | |
| threads that never start, so this fails its load at once and says why, | |
| and the host loads its build without threads instead. | |
| Regenerate with `python3 src/wasm/idl/generate.py`. Never edit the generated | |
| files. `generate.py --check` fails when they are stale, and that includes a | |
| change to the runner that was not regenerated into the bundle. | |
| ## Turning it on | |
| The backend is in a web build only when premake gets `--scripting_vm=wasm` | |
| or `--scripting_vm=both`. No web build passes that by default. | |
| - rive_native: `./build.sh wasm release --scripting_vm=both` | |
| - The JS runtime: the `-w` flag of `wasm/build_wasm.sh` | |
| Scripts decode images with the browser's decoder on every web build, like | |
| the Luau lane, and with our decoders natively. | |
| Modules are trusted like shaders, so a file baked locally by rive-cli runs | |
| in any build. | |
| ## Gates | |
| - `packages/rasc/animascript`: `sh tests/web_boot.sh out/debug/rasc`. | |
| Plain node, no emscripten. Boots an AnimaScript module against the | |
| generated import object with a fake host, and checks nested staging and | |
| raised errors. | |
| - `packages/runtime/tests/web_scripting`: `./test.sh`. Builds librive for the | |
| web with this backend, links the bundle, and runs baked files under node: | |
| prepared start, inline start, a gradient, a trap the page survives, a | |
| script whose top level logs, a module importing an op the host lacks, | |
| which starts and traps only when it calls it, and image decodes through a | |
| stand in for the browser's decoder. Then it runs files on browser pages | |
| with `browser_audio.mjs`, in Chrome when it is installed and in Firefox | |
| and WebKit when `RIVE_WEB_BROWSERS` names them, which takes `npm ci` there | |
| and `npx playwright-core install firefox webkit`. Processors play on each | |
| browser's audio worklet, which must render the samples the harness hashes | |
| with SIMD or without, and jobs run on its workers. One page leaves the | |
| runner to make its own context, which in Firefox and WebKit must start on | |
| the page's first click. A page fails when librive's threads outgrow the | |
| harness's pool. A page without cross origin isolation loads the build | |
| without threads and runs its jobs on the page, and the build with threads | |
| refuses to load there. | |
| `fixture/bake.sh` rebakes the files and needs rive-cli built. | |
| - `packages/runtime_wasm/js`: `npm run test:scripting`. Loads baked files | |
| through the JS runtime's public API under jest, from a canvas build with | |
| this backend and tools on, so unsigned files run. Build that in | |
| `packages/runtime_wasm/wasm` with | |
| `OUT_DIR=build/canvas_scripting_single/bin/release ./build_wasm.sh -s -w -t release`. | |
| It covers init and draw, a trap the page survives, an op the host lacks, | |
| browser image decodes, the collection at the end of each frame and the | |
| bridge counters. CI runs it. | |
| - `packages/luau_wasm/tests/check_web_imports.mjs`: every `rive_*` import of | |
| the Luau host module resolves through the generated import object. It is | |
| there because that module imports the whole rive surface, which makes it a | |
| coverage check for the generator. It does not mean the web runs it. | |
| ## Rules the code depends on | |
| - **Every call into a module goes through `BrowserScriptingVM::call`.** That | |
| is where memory the host wrote gets copied back before the module runs, | |
| where `ScriptCallScope` opens, and where traps are caught. A JS exception | |
| must never cross librive frames, because it would skip their destructors. | |
| The catch sits directly around the export call, so only module frames ever | |
| unwind. `raiseModuleError` follows the same rule from the other side: it | |
| records the message and the page throws it after the host call returns. | |
| - **Staging is a stack.** A host op can call back into the script, which | |
| calls more imports. Each staged import takes a mark and restores it in a | |
| `finally`, so a throw through it still restores. | |
| - **Memory the host only read is never written back.** Natives read module | |
| memory while the module is running, and the module may reuse that memory | |
| before the next call. | |
| - **The module's start function is lifted into an export.** rasc runs every | |
| script's top level in the wasm start function, and a top level can pass | |
| memory to the host (a log line is enough). A module's memory is an export, | |
| which the page cannot reach until start has returned. So the runner | |
| rewrites the module bytes: it drops the start section, exports that | |
| function, and calls it once the instance and its memory exist. A module | |
| baked before the clocks moved to `rive_rt_v1` still imports them from `env` | |
| and needs a rebake to link here. | |
| - **librive's entry points are reached as `Module['_name']`.** Optimized | |
| emscripten builds rename the raw wasm exports, so looking them up by name | |
| only works in a debug build. | |
| - **Export names are string literals.** The page caches the export by the | |
| name's pointer, like the native lane. | |
| - **Names shared with wasm are quoted** in the generated code, because the | |
| JS runtime's release build uses the closure compiler. | |
| ## Limits | |
| The host sets the main call budget and the audio hard limit through | |
| `ScriptingLimits`, and each processor's start message carries its hard limit | |
| to the worklet, where 0 means none. Jobs and workers have no time limit, | |
| except where one with fuel holds the page's thread. | |
| Nothing can interrupt a call in a browser, so a lane ends early only at its | |
| own fuel checks. A bake gives a lane module fuel exactly when it gives the | |
| main module fuel, and each check calls `rive_lane_v1.fuel_check`. Natively | |
| that does nothing, since WAMR's interrupts already stop every lane there. | |
| - For a job or worker, the runner's `fuel_check` asks librive through | |
| `rive_web_lane_fuel(lane)` why the lane must end, and throws the answer when | |
| there is one. The web `WasmLane` keeps native's `interruptCall`, | |
| `interruptAfter` and `dropInterrupt` over one deadline, so a stop ends a job | |
| at its next check and a worker at its first check past its 50 ms grace, as | |
| natively, a released file's lanes included. | |
| - A job run or worker step on the page's thread, which is every one in a build | |
| without threads, runs under `PageThreadBudget`. It arms the thread's call | |
| deadline with the call budget the VM had when the lane started, and the | |
| check answers once that passes. A VM that bounds no calls, as under the | |
| debugger or with a budget of 0, gives its lanes no budget either. | |
| - For a processor the worklet's mixer answers alone, without librive. Init, | |
| with the lifted start and the free, gets `kProcessorInitMs` in all, and each | |
| block gets its block budget from when it starts. The mixer still drops a | |
| block that returns past its budget, which catches processors without fuel | |
| and time spent outside script. | |
| - A check's throw unwinds only the lane module's frames, never librive's, and | |
| the runner's catch turns it into the lane's failure, which settles stopped | |
| when the lane was stopped. | |
| Without fuel, a stopped job ends only once it polls `Lanes.stopping()`, a | |
| stopped worker ends at its next await, and a job or worker step on the page's | |
| thread runs to its end. | |
| ## Script audio in real time | |
| Audio processors run on an audio worklet, which reaches shared memory | |
| through Atomics on librive's heap while librive stays on the page. That | |
| needs librive built with threads and, in a browser, cross origin isolation. | |
| - The host names the worklet script in `riveScripting.audioWorkletUrl`: | |
| `rive_audio_worklet.mjs` in a browser and `rive_audio_worklet_nrdp.js` on | |
| NRDP. In a browser the worklet loads the modules it imports from beside it, | |
| so the host serves these four next to it as well: `rive_lane.mjs`, | |
| `rive_lane_imports.mjs`, `rive_shared_lane.mjs` and | |
| `rive_shared_layout.mjs`. The NRDP script is one bundle and needs nothing | |
| beside it. A build with threads that names no URL uses the browser worklet | |
| it carries, linked as `rive_audio_worklet_pre.js`, from a `blob:` URL, so | |
| the JS runtime serves nothing for it. | |
| - The host may hand over its own `AudioContext` as | |
| `riveScripting.audioContext`, and should resume it on the user's first | |
| gesture. Without one, the runner makes a context as the first processor | |
| starts, and resumes it on the page's first click, tap or key press, since | |
| browsers start audio only after a gesture. If that context has not started | |
| a second after it was made, the runner warns once on the console. It also | |
| sets an `auto` `navigator.audioSession` to `playback`, so iOS plays it | |
| through the silent switch as it does video. | |
| - The worklet counts every block it renders. When that stops while | |
| processors run, every processor fails. On NRDP that takes 100 ms, and the | |
| runner ends the worklet thread. A browser cannot end it, and Chrome's | |
| close waits on the stuck thread, so after a second the runner drops the | |
| output, takes its node out of the mix and leaves its context open. Real | |
| time audio then refuses a host's context that stalled or closed, so the | |
| host hands over a new one. While a start is pending the watch waits longer | |
| than `kProcessorInitMs`, so an init with fuel that runs past its own budget | |
| fails alone first, and each queued start gets that whole allowance. A block | |
| with fuel ends at its hard limit long before the watch, so the watch is left | |
| for a processor without fuel or without a hard limit, a first block on | |
| NRDP, which is allowed 100 ms, and a stall in code without checks, such as | |
| the collector. | |
| ## The browser lane | |
| `packages/runtime_wasm/scripting_lane/lane.mjs` runs the stress fixtures in | |
| `packages/runtime/tests/web_scripting/stress` through the webgl2 JS runtime | |
| in headless Chrome: a GPU Canvas pass, a path of thousands of segments, an | |
| image mesh, a data bound list of a thousand rows and shaped text, each | |
| rewritten every frame. For each it compares frame 60 with what rive-cli | |
| renders on WAMR, and times 300 frames after a warmup on both. It bakes the | |
| fixtures with `stress/bake.sh` first and the browser loads that same bake, | |
| so both lanes run the same module. Only the WAMR bench differs: it rebakes | |
| with the release scripts a publish ships, which drop the throw messages. | |
| Build and run it with: | |
| ``` | |
| cd packages/runtime_wasm/wasm | |
| OUT_DIR=build/webgl2_scripting/bin/release ./build_wasm.sh -r webgl2 -w -t release | |
| cd ../../rive-cli && ./build.sh release | |
| cd ../runtime_wasm/scripting_lane && npm install && node lane.mjs | |
| ``` | |
| It prints a table of milliseconds, host calls and bytes per frame. `--aot` | |
| also benches WAMR's AOT tier, with `RIVE_WAMRC` naming a wamrc built from our | |
| patched WAMR. `--swiftshader` draws with Chrome's software GL, and | |
| `--no-wamr` skips rive-cli and loads the committed `.riv` files. | |
| `stress/bake.sh` rebakes those. | |
| The page reads the bridge counters with | |
| `Module.riveScripting.countHostCalls(true)` and `hostCallCounts()`: calls | |
| into librive, bytes staged into its heap and copied back, and bytes read and | |
| written through its memory seam. | |
| The lane stays manual. It needs Chrome and a GPU: under SwiftShader the GPU | |
| Canvas fixture draws nothing from a vertex buffer it updates every frame, | |
| while the machine's GPU matches WAMR exactly, and frame times there mostly | |
| measure the software rasterizer. | |
| ## Not done yet | |
| - **The debugger** sees no trap text on the web. | |
| - **No automated gate for our hosts in a browser.** The browser pages of | |
| `tests/web_scripting/test.sh` load its harness, not a host. The browser | |
| lane above and `tests/web_scripting/browser_probe.mjs` are manual. | |
| The probe loads the real rive_native web build in Chrome, Firefox and | |
| WebKit. It passed in all three on 2026-09-19: file imported, module | |
| prepared in about 2 ms, `init`, `advance` and `draw` ran, a gradient | |
| crossed, and a trap was reported while the page kept drawing. Nothing has | |
| run inside the Flutter editor or an app yet. |