feat: router-based wasm bundle splitting (#3683)

* feat: wasm-split base implementation

* kinda sorta works with the cli

* okay great it actually works

* fix: cache busting for chunks

* cleaned up version

* it works with the router

* wip: migrate to a faster linear solution using id remapping

* add a good harness

* it works completely on the docsite

* the super tight build works too

* make parallelizable

* works on the docsite but not the harness

* light cleanups, fix for both harness and docsite

* make codegen smaller by extract some shared functions to prevent monos

* chunking works, but tbd on tuning heuristics

* extract used module

* wip: cleaning up module

* wip: much better understanding of how imports work

* fix the call graph

* working again...

* wip: thinking about ifuncs

* ifunc approach works

* yes the ifunc approach works!!

* it all works completely even with chunks

* small cleanups

* comments

* actually locate all of the symbols

* clean up final implementation

* clean up tomls, revert some mono changs

* clean up, add some more comments / docs in places

* hoist more packages

* clean up impl a bit more

* clean cli

* move harness to playwright

* update playwright

* extract codgen

* add lazy to components

* Add docs to split loader

* clippy

* typos

* clippy, don't generate splits if not on wasm

* whoops, const

* Typos and docs

* enable optimizations in ci

* add optimizations to ci

* add wasm-split router feature

* parallel playwright?

* fix no case check

* disable pre-compress... think that might be the issue

* better logging, no default pre-compress, caching of shared symbols
* Fix: compressing assets

* fix non-interactive logging for serve

* cache playwright even on failure, use the cli with optimizations

* fix prebuild

* fix playwright webserver
This commit is contained in:
Jonathan Kelley authored and GitHub committed 2025-02-13 18:54:38 -08:00
1 parent 14eb7597df
commit bdeedc13eb
59 files changed
+4249 -299

No files matched your search

+10 -1
View File
@@ -142,11 +142,20 @@ module.exports = defineConfig({
cwd: path.join(process.cwd(), "cli-optimization"),
// Remove the cache folder for the cli-optimization build to force a full cache reset
command:
'cargo run --package dioxus-cli --release -- serve --addr "127.0.0.1" --port 8989',
'cargo run --package dioxus-cli --release --features optimizations -- serve --addr "127.0.0.1" --port 8989',
port: 8989,
timeout: 50 * 60 * 1000,
reuseExistingServer: !process.env.CI,
stdout: "pipe",
},
{
cwd: path.join(process.cwd(), "wasm-split-harness"),
command:
'cargo run --package dioxus-cli --release --features optimizations -- serve --bin wasm-split-harness --platform web --addr "127.0.0.1" --port 8001 --experimental-wasm-split --profile wasm-split-release',
port: 8001,
timeout: 50 * 60 * 1000,
reuseExistingServer: !process.env.CI,
stdout: "pipe",
},
],
});
@@ -0,0 +1,21 @@
[package]
name = "wasm-split-harness"
version = "0.1.0"
edition = "2021"
publish = false
authors = ["Jonathan Kelley"]
[dependencies]
dioxus = { workspace = true, features = ["web", "router", "wasm-split"] }
dioxus-router = { workspace = true, features = ["wasm-split"] }
anyhow = { workspace = true }
async-compression = { workspace = true, features = ["futures-io", "gzip", "brotli"] }
futures = { workspace = true }
js-sys = { workspace = true }
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
web-sys = { workspace = true, features = ["Document", "Window", "HtmlElement", "Text", "DomRectReadOnly", "console"] }
once_cell = { workspace = true }
getrandom = { workspace = true, features = ["js"] }
reqwest = { workspace = true, features = ["json"] }
@@ -0,0 +1 @@
harness/
@@ -0,0 +1,13 @@
<html>
<head>
<meta charset="utf-8">
<title>Hello wasm-bindgen</title>
</head>
<body>
<div id="main"></div>
</body>
<script type="module">
import init from "/harness/split/main.js";
init();
</script>
</html>
+14
View File
@@ -0,0 +1,14 @@
cargo build --bin wasm-split-cli
CLI=./target/debug/wasm-split-cli
rm -rf docsite/chunks
# Run the wasm-split-cli on the with_body.wasm file
${CLI} split docsite/input.wasm docsite/bindgen/main_bg.wasm docsite/chunks
# copy the contents of the wasm_bindgen folder to the docsite folder
mv docsite/chunks/main.wasm docsite/chunks/main_bg.wasm # rename the main wasm file
cp -r docsite/bindgen/snippets docsite/chunks/snippets
cp docsite/bindgen/main.js docsite/chunks/main.js
python3 -m http.server 8080 --directory docsite
+82
View File
@@ -0,0 +1,82 @@
# This file is a simple shell script that runs the bundle split process manually without the CLI involved
# it's not necessarily meant to work on your machine (sorry!)
#
# To hack on harness you need the `wasm-tools` CLI installed
# `cargo binstall wasm-tools`
#
# This script is also sensitive to where it's run from, so you *need* to be in the harness folder (running as `./run.sh`)
TARGET_DIR=../../../target
# build the harness
cargo rustc --package wasm-split-harness --target wasm32-unknown-unknown --profile wasm-split-release -- -Clink-args=--emit-relocs
# for a much smaller compile, you can crank up the flags. However, dioxus relies heavily on location detail, so we can't disable that
#
# -Zlocation-detail=none - we could compile with location detail off but if breaks our signals...
#
# cargo +nightly rustc \
# -Z build-std=std,panic_abort \
# -Z build-std-features="optimize_for_size" \
# -Z build-std-features=panic_immediate_abort \
# --target wasm32-unknown-unknown \
# --no-default-features \
# --profile wasm-split-release \
# -- -Clink-args=--emit-relocs
# Build the wasm-split-cli. We are going to call it directly since it's so noisy to build it multiple times
cargo build --package wasm-split-cli --bin wasm-split-cli
CLI=$TARGET_DIR/debug/wasm-split-cli
# clear the workdir and assemble the new structure
rm -rf data/harness
mkdir -p data/harness/split
mkdir -p data/harness/split_not
# copy the output wasm file to the harness dir
cp $TARGET_DIR/wasm32-unknown-unknown/wasm-split-release/wasm-split-harness.wasm data/harness/input.wasm
# Run wasm-bindgen on this module, without splitting it
wasm-bindgen data/harness/input.wasm --out-dir data/harness/split_not --target web --out-name main --no-demangle --no-typescript --keep-lld-exports --keep-debug
# Run the wasm-split-cli on the with_body.wasm file
${CLI} split data/harness/input.wasm data/harness/split_not/main_bg.wasm data/harness/chunks
# copy over the chunks
paths=$(ls data/harness/chunks/ | grep "\.wasm")
for path in $paths
do
path_without_ext=${path%.*}
wasm-opt -Oz data/harness/chunks/$path -o data/harness/split/$path --enable-reference-types --memory-packing --debuginfo
# remove stuff like manganis, etc
wasm-tools strip data/harness/split/$path -o data/harness/split/$path
# if you don't want names (making it harder to debug the outputs) use `--all`
# wasm-tools strip data/harness/split/$path -o data/harness/split/$path --all
done
# rename the main chunk
mv data/harness/split/main.wasm data/harness/split/main_bg.wasm
cp data/harness/split_not/main.js data/harness/split/main.js
cp -r data/harness/split_not/snippets data/harness/split/snippets
cp data/harness/chunks/__wasm_split.js data/harness/split/__wasm_split.js
wasm-opt -Oz data/harness/split_not/main_bg.wasm -o data/harness/split_not/main_bg_opt.wasm --enable-reference-types --memory-packing --debuginfo
# Run wasm-strip to strip out the debug symbols
wasm-tools strip data/harness/split_not/main_bg_opt.wasm -o data/harness/split_not/main_bg_opt.wasm
# if you don't want names (making it harder to debug the outputs) use `--all`
# wasm-tools strip data/harness/split/$path -o strip data/harness/split_not/main_bg_opt.wasm --all
echo "===========================================================================\n"
ls -l data/harness/split_not/main_bg_opt.wasm | awk '{ printf("%07d -> ", $5);print $9}'
echo ""
ls -l data/harness/split | grep "\.wasm" | awk '{ printf("%07d -> ", $5);print $9}'
echo "\n==========================================================================="
# hope you have python3 installed :)
python3 -m http.server 9876 --directory data
@@ -0,0 +1,296 @@
//! This file is a fuzz-test for the wasm-split engine to ensure that it works as expected.
//! The docsite is a better target for this, but we try to boil down the complexity into this small
//! test file.
#![allow(non_snake_case)]
use dioxus::prelude::*;
use futures::AsyncReadExt;
use js_sys::Date;
use std::pin::Pin;
use wasm_bindgen::prelude::*;
use wasm_split::lazy_loader;
fn main() {
dioxus::launch(|| {
rsx! {
Router::<Route> {}
}
});
}
#[derive(Routable, PartialEq, Eq, Debug, Clone)]
enum Route {
#[layout(Nav)]
#[route("/")]
Home,
#[route("/child")]
ChildSplit,
}
fn Nav() -> Element {
rsx! {
div {
Link { id: "link-home", to: Route::Home, "Home" }
Link { id: "link-child", to: Route::ChildSplit, "Child" }
Outlet::<Route> {}
}
}
}
pub(crate) static GLOBAL_COUNTER: GlobalSignal<usize> = Signal::global(|| 0);
fn Home() -> Element {
let mut count = use_signal(|| 1);
let mut res = use_signal(|| "hello".to_string());
rsx! {
h1 { "Hello bundle split 456" }
h3 { id: "counter-display", "Count: {count}" }
h3 { id: "global-counter", "Global Counter: {GLOBAL_COUNTER}" }
button {
id: "increment-counter",
onclick: move |_| count += 1,
"Click me"
}
button {
id: "increment-counter-global",
onclick: move |_| *GLOBAL_COUNTER.write() += 1,
"Click me"
}
button {
id: "add-body-text",
onclick: move |_| add_body_text(),
"Add body text"
}
button {
id: "add-body-element",
onclick: move |_| async move {
add_body_element().await;
count += 1;
},
"Add body element"
}
button {
id: "gzip-it",
onclick: move |_| async move {
gzip_it().await;
},
"GZIP it"
}
button {
id: "brotli-it",
onclick: move |_| async move {
brotli_it(&[0u8; 10]).await;
},
"Brotli It"
}
button {
id: "make-request",
onclick: move |_| async move {
let res_ = make_request().await.unwrap();
res.set(res_);
},
"Make Request!"
}
button {
id: "make-local-request",
onclick: move |_| async move {
let client = reqwest::Client::new();
let response = client
.get("https://dog.ceo/api/breeds/image/random")
.send()
.await?;
let body = response.text().await?;
*res.write() = body;
Ok(())
},
"local request"
}
LazyComponent {
abc: 0
}
div { "Response: {res}" }
div { id: "output-box" }
}
}
#[wasm_split::wasm_split(one)]
async fn add_body_text() {
let window = web_sys::window().unwrap_throw();
let document = window.document().unwrap_throw();
let output = document.create_text_node("Rendered!");
let output_box = document.get_element_by_id("output-box").unwrap_throw();
output_box.append_child(&output).unwrap_throw();
*GLOBAL_COUNTER.write() += 1;
}
#[wasm_split::wasm_split(two)]
async fn add_body_element() {
let window = web_sys::window().unwrap_throw();
let document = window.document().unwrap_throw();
let output = document.create_element("div").unwrap_throw();
output.set_text_content(Some("Some inner div"));
let output_box = document.get_element_by_id("output-box").unwrap_throw();
output_box.append_child(&output).unwrap_throw();
dioxus::prelude::queue_effect(move || {
web_sys::console::log_1(&"add body async internal!".into());
*GLOBAL_COUNTER.write() += 2;
});
}
#[wasm_split::wasm_split(four)]
async fn gzip_it() {
static DATA: &[u8] = &[0u8; 10];
let reader = Box::pin(futures::io::BufReader::new(DATA));
let reader: Pin<Box<dyn futures::io::AsyncBufRead>> = reader;
dioxus::prelude::spawn(async move {
let mut fut = Box::pin(async_compression::futures::bufread::GzipDecoder::new(
reader,
));
if fut.read_to_end(&mut Vec::new()).await.is_err() {
web_sys::console::log_1(&"error reading gzip".into());
}
*GLOBAL_COUNTER.write() += 3;
let res: Result<String, anyhow::Error> = Box::pin(async move {
let client = reqwest::Client::new();
let response = client
.get("https://dog.ceo/api/breeds/image/random")
.send()
.await?;
let body = response.text().await?;
Ok(body)
})
.await;
if res.is_err() {
web_sys::console::log_1(&"error making request".into());
}
});
}
#[wasm_split::wasm_split(three)]
async fn brotli_it(data: &'static [u8]) {
let reader = Box::pin(futures::io::BufReader::new(data));
let reader: Pin<Box<dyn futures::io::AsyncBufRead>> = reader;
dioxus::prelude::spawn(async move {
let mut fut = Box::pin(async_compression::futures::bufread::BrotliDecoder::new(
reader,
));
if fut.read_to_end(&mut Vec::new()).await.is_err() {
web_sys::console::log_1(&"error reading brotli".into());
}
*GLOBAL_COUNTER.write() += 4;
});
}
#[wasm_split::wasm_split(eleven)]
async fn make_request() -> Result<String, anyhow::Error> {
let client = reqwest::Client::new();
let response = client
.get("https://dog.ceo/api/breeds/image/random")
.send()
.await?;
let body = response.text().await?;
Ok(body)
}
#[component(lazy)]
fn LazyComponent(abc: i32) -> Element {
rsx! {
div {
"This is a lazy component! {abc}"
}
}
}
fn ChildSplit() -> Element {
pub(crate) static DATE: GlobalSignal<Date> = Signal::global(Date::new_0);
static LOADER: wasm_split::LazyLoader<(), Element> =
lazy_loader!(extern "five" fn InnerChild(props: ()) -> Element);
fn InnerChild(_props: ()) -> Element {
static LOADER2: wasm_split::LazyLoader<Signal<String>, Element> =
lazy_loader!(extern "fortytwo" fn InnerChild3(props: Signal<String>) -> Element);
fn InnerChild3(count: Signal<String>) -> Element {
pub(crate) static ICONCHECK: Component<()> = |_| {
rsx! {
svg {
class: "octicon octicon-check js-clipboard-check-icon d-inline-block d-none",
fill: "rgb(26, 127, 55)",
height: "24",
version: "1.1",
"aria_hidden": "true",
width: "24",
view_box: "0 0 16 16",
"data_view_component": "true",
path {
d: "M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z",
fill_rule: "evenodd",
}
}
button {
onclick: move |_| {
*DATE.write() = Date::new_0();
},
"Update Date"
}
}
};
let now = DATE.read().clone();
rsx! {
h1 { "Some other child" }
h3 { "Global Counter: {GLOBAL_COUNTER}" }
h3 { "Date: {now.to_date_string()}" }
h3 { "count: {count}" }
ICONCHECK {}
}
}
#[wasm_bindgen(module = "/src/stars.js")]
extern "C" {
pub(crate) fn get_stars(name: String) -> Option<usize>;
pub(crate) fn set_stars(name: String, stars: usize);
}
let num = get_stars("stars".to_string()).unwrap_or(0);
use_resource(|| async move { LOADER2.load().await }).suspend()?;
let mut count = use_signal(|| "hello".to_string());
let fp = LOADER2.call(count).unwrap();
rsx! {
h1 { "Some huge child?" }
p { "Stars: {num}" }
button {
onclick: move |_| {
set_stars("stars".to_string(), num + 1);
dioxus::prelude::needs_update();
},
"Add Star"
}
{fp}
h3 { id: "nested-child-count", "Count: {count}" }
button {
id: "nested-child-add-world",
onclick: move |_| {
*count.write() += " world";
},
"Add World"
}
}
}
use_resource(|| async move { LOADER.load().await }).suspend()?;
LOADER.call(()).unwrap()
}
@@ -0,0 +1,29 @@
// Handle caching of github stars
// This file is part of the fuzz test to prove we can handle linked js files
// Two days
const STAR_EXPIRE_TIME = 172800000;
export function get_stars(name) {
let item = localStorage.getItem(name);
let data = JSON.parse(item);
if (!data) {
return null;
}
if (data.expires <= Date.now()) {
localStorage.removeItem(name);
return null;
}
return data.stars;
}
export function set_stars(name, value) {
let expires = Date.now() + STAR_EXPIRE_TIME;
let data = { stars: value, expires };
let converted = JSON.stringify(data);
localStorage.setItem(name, converted);
}
@@ -0,0 +1,49 @@
// @ts-check
const { test, expect } = require("@playwright/test");
test("wasm-split page is functional", async ({ page }) => {
// Wait for the dev server to load
await page.goto("http://localhost:8001");
// Make sure the local button works - no broken wasm
const counter = page.locator("#counter-display");
await expect(counter).toContainText("Count: 1");
await page.locator("#increment-counter").click();
await expect(counter).toContainText("Count: 2");
// Make sure the global button works - no broken wasm
const counterGlobal = page.locator("#global-counter");
await expect(counterGlobal).toContainText("Global Counter: 0");
await page.locator("#increment-counter-global").click();
await expect(counterGlobal).toContainText("Global Counter: 1");
// Fire one of the wasm modules to load. Should update the counter and add some text
const addBodyTextButton = page.locator("#add-body-text");
await addBodyTextButton.click();
await expect(counterGlobal).toContainText("Global Counter: 2");
const outputBox = page.locator("#output-box");
await expect(outputBox).toContainText("Rendered!");
// The other wasm module
const addBodyElementButton = page.locator("#add-body-element");
await addBodyElementButton.click();
await expect(counterGlobal).toContainText("Global Counter: 4");
await expect(outputBox).toContainText("Some inner div");
// Load the gzip and brotli modules
const gzipButton = page.locator("#gzip-it");
await gzipButton.click();
await expect(counterGlobal).toContainText("Global Counter: 7");
const brotliButton = page.locator("#brotli-it");
await brotliButton.click();
await expect(counterGlobal).toContainText("Global Counter: 11");
// Ignore the requests in CI
// Load the other router module
const childRouteButton = page.locator("#link-child");
await childRouteButton.click();
const nestedChildCounter = page.locator("#nested-child-count");
await expect(nestedChildCounter).toContainText("Count: hello");
await page.locator("#nested-child-add-world").click();
await expect(nestedChildCounter).toContainText("Count: hello world");
});