From 97a43e66fbfbdc8a07cf20a636c0eb5c49b40228 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 14 Jul 2026 14:43:34 -0700 Subject: [PATCH] Split up Wasm binaries so Cloudflare deployments stay below 25 MB per file --- .github/workflows/build.yml | 2 + frontend/src/App.svelte | 5 +- frontend/src/global.d.ts | 3 + frontend/src/utility-functions/wasm-loader.ts | 25 +++++++++ frontend/vite.config.ts | 56 ++++++++++++++++++- tools/cargo-run/src/frontend.rs | 6 +- 6 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 frontend/src/utility-functions/wasm-loader.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b40501ec15..d089c6d135 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -112,6 +112,8 @@ jobs: - name: 🌐 Build Graphite web code env: NODE_ENV: production + # Split the Wasm binary to fit Cloudflare Pages' file size limit (see `wasmSplitting` in `frontend/vite.config.ts`) + SPLIT_WASM: "1" run: mold -run cargo run build web${{ inputs.debug && ' debug' || '' }} - name: 📤 Publish to Cloudflare Pages diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 406baa38d1..49f4b209d5 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -5,7 +5,8 @@ import type { MessageName, SubscriptionsRouter } from "/src/subscriptions-router"; import { loadDemoArtwork } from "/src/utility-functions/network"; import { operatingSystem } from "/src/utility-functions/platform"; - import init, { EditorWrapper, receiveNativeMessage } from "/wrapper/pkg/graphite_wasm_wrapper"; + import { initWasm } from "/src/utility-functions/wasm-loader"; + import { EditorWrapper, receiveNativeMessage } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { FrontendMessage } from "/wrapper/pkg/graphite_wasm_wrapper"; let subscriptions: SubscriptionsRouter | undefined = undefined; @@ -13,7 +14,7 @@ onMount(async () => { // Initialize the editor wrapper - const wrapper = await init(); + const wrapper = await initWasm(); for (const [name, f] of Object.entries(wrapper)) { if (name.startsWith("__node_registry")) f(); } diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts index 8f81d1f76b..c8ab4f1c8e 100644 --- a/frontend/src/global.d.ts +++ b/frontend/src/global.d.ts @@ -6,6 +6,9 @@ interface Window { receiveNativeMessage?: (buffer: ArrayBuffer) => void; } +// Build-time constant injected by the `wasmSplitting` plugin in `vite.config.ts` +declare const __WASM_PART_COUNT__: number; + // Graphite's custom "pointerlockmove" event dispatched by input.ts for pointer lock in the CEF desktop app interface WindowEventMap { pointerlockmove: CustomEvent<{ x: number; y: number }>; diff --git a/frontend/src/utility-functions/wasm-loader.ts b/frontend/src/utility-functions/wasm-loader.ts new file mode 100644 index 0000000000..155ad48241 --- /dev/null +++ b/frontend/src/utility-functions/wasm-loader.ts @@ -0,0 +1,25 @@ +import init from "/wrapper/pkg/graphite_wasm_wrapper"; +import wasmBinaryUrl from "/wrapper/pkg/graphite_wasm_wrapper_bg.wasm?url"; + +// Initializes the editor's Wasm module, rejoining the parts that CI deployments split the binary into +// to fit under the single-file size limit (see `wasmSplitting` in `vite.config.ts`) +export async function initWasm() { + // Local and native builds keep the binary whole, letting the wasm-bindgen glue code load it directly + if (__WASM_PART_COUNT__ <= 1) return init(); + + // Fetch all parts in parallel (served from the service worker's precache once it is installed) + const partRequests = []; + for (let index = 0; index < __WASM_PART_COUNT__; index += 1) { + partRequests.push(fetch(wasmBinaryUrl.replace(/\.wasm$/, `-part${index}.wasm`))); + } + const partResponses = await Promise.all(partRequests); + + const failedResponse = partResponses.find((response) => !response.ok); + if (failedResponse) throw new Error(`Failed to fetch Wasm binary part (status ${failedResponse.status}): ${failedResponse.url}`); + + // Rejoin the parts and hand them to wasm-bindgen as a single response, with the MIME type needed for streaming compilation + const parts = await Promise.all(partResponses.map((response) => response.blob())); + const joined = new Response(new Blob(parts), { headers: { "Content-Type": "application/wasm" } }); + // eslint-disable-next-line camelcase + return init({ module_or_path: joined }); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8a0adc56c3..8aee163687 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,6 +1,6 @@ import { execSync } from "child_process"; import { createHash } from "crypto"; -import { copyFileSync, cpSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"; +import { copyFileSync, cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; import path from "path"; import { svelte } from "@sveltejs/vite-plugin-svelte"; import { defineConfig } from "vite"; @@ -11,7 +11,17 @@ const projectRootDir = path.resolve(__dirname); // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { return { - plugins: [svelteGlobalStyles(), webkitUserSelectPrefix(), svelte(), staticAssets(), mode !== "native" && thirdPartyLicenses(), mode !== "native" && serviceWorker()], + plugins: [ + svelteGlobalStyles(), + webkitUserSelectPrefix(), + svelte(), + staticAssets(), + mode !== "native" && thirdPartyLicenses(), + mode !== "native" && wasmSplitting(), + mode !== "native" && serviceWorker(), + ], + // Default for builds that exclude the `wasmSplitting` plugin, which overrides this when active + define: { __WASM_PART_COUNT__: "1" }, resolve: { alias: [{ find: /\/..\/branding\/(.*\.svg)/, replacement: path.resolve(projectRootDir, "../branding", "$1?raw") }], }, @@ -136,6 +146,48 @@ function thirdPartyLicenses(): PluginOption { }; } +// Splits the Wasm binary into parts small enough for Cloudflare Pages' 25 MiB single-file limit, rejoined at runtime by `initWasm()` in `src/utility-functions/wasm-loader.ts`. +// Only active when the `SPLIT_WASM` environment variable is set, which CI does for deployments; local builds keep the single file. +function wasmSplitting(): PluginOption { + const PART_SIZE = 24 * 1024 * 1024; + + let partCount = 1; + + return { + name: "wasm-splitting", + config(_, { command }) { + // Measure the Wasm binary (already built by `cargo run build web` before Vite runs) to decide how many parts are needed + if (command === "build" && process.env.SPLIT_WASM) { + const wasmPath = path.resolve(projectRootDir, "wrapper/pkg/graphite_wasm_wrapper_bg.wasm"); + if (!existsSync(wasmPath)) throw new Error(`SPLIT_WASM is set but the Wasm binary is missing at ${wasmPath}`); + partCount = Math.ceil(statSync(wasmPath).size / PART_SIZE); + } + + // Bake the part count into the bundle so `initWasm()` knows how many parts to fetch and rejoin + return { define: { __WASM_PART_COUNT__: String(partCount) } }; + }, + // Synchronous so it completes before the `serviceWorker` plugin's `writeBundle` collects the precache manifest + writeBundle(options) { + if (partCount <= 1) return; + + const assetsDir = path.join(options.dir || "dist", "assets"); + const wasmFileName = readdirSync(assetsDir).find((name) => name.startsWith("graphite_wasm_wrapper_bg-") && name.endsWith(".wasm")); + if (!wasmFileName) throw new Error("Could not find the emitted Wasm asset to split"); + const wasmPath = path.join(assetsDir, wasmFileName); + + const contents = readFileSync(wasmPath); + if (Math.ceil(contents.length / PART_SIZE) !== partCount) throw new Error("Wasm binary size changed during the build, invalidating the baked-in part count"); + + // Replace the single Wasm file with its parts so only they get deployed and precached + for (let index = 0; index < partCount; index += 1) { + const partName = wasmFileName.replace(/\.wasm$/, `-part${index}.wasm`); + writeFileSync(path.join(assetsDir, partName), contents.subarray(index * PART_SIZE, (index + 1) * PART_SIZE)); + } + rmSync(wasmPath); + }, + }; +} + function serviceWorker(): PluginOption { // Files that should never be precached const EXCLUDED_FILES = new Set(["service-worker.js"]); diff --git a/tools/cargo-run/src/frontend.rs b/tools/cargo-run/src/frontend.rs index a95169d434..9aaadaaa6f 100644 --- a/tools/cargo-run/src/frontend.rs +++ b/tools/cargo-run/src/frontend.rs @@ -70,9 +70,9 @@ pub fn build_wasm_steps(release: bool, native: bool) -> Vec { if release { let wasm_file = pkg_dir.join(format!("{OUT_NAME}_bg.wasm")); - // `-Oz` (size over speed) keeps us under Cloudflare Pages' 25 MiB single-file cap. - // `-g` preserves the name section, which the panic hook reads at runtime to spot node-graph panics (see wrapper `lib.rs`). - steps.push(cmd!("wasm-opt", "-Oz", "-g", &wasm_file, "-o", &wasm_file)); + // `-O3` favors runtime speed over binary size and `-g` preserves the name section, + // which the panic hook reads at runtime to spot node-graph panics (see wrapper `lib.rs`). + steps.push(cmd!("wasm-opt", "-O3", "-g", &wasm_file, "-o", &wasm_file)); } steps