diff --git a/.changeset/fix-loro-vite-rolldown-wasm.md b/.changeset/fix-loro-vite-rolldown-wasm.md new file mode 100644 index 000000000..75894ce34 --- /dev/null +++ b/.changeset/fix-loro-vite-rolldown-wasm.md @@ -0,0 +1,8 @@ +--- +"loro-crdt": patch +"loro-crdt-map": patch +--- + +Add a browser package remapping so Vite/Rolldown production builds load WASM without top-level await or circular wasm wrapper chunks. + +Also make the base64 entry easier to bundle with plain esbuild, Rollup, and Next.js Webpack by avoiding static Node builtin `require()` calls and top-level await in browser bundles. diff --git a/crates/loro-wasm-map/package.json b/crates/loro-wasm-map/package.json index 46cb100dc..70d66e502 100644 --- a/crates/loro-wasm-map/package.json +++ b/crates/loro-wasm-map/package.json @@ -16,6 +16,7 @@ "license": "MIT", "files": [ "bundler", + "browser", "nodejs", "web", "README.md", diff --git a/crates/loro-wasm/README.md b/crates/loro-wasm/README.md index 3f4f4c1a7..3b85690b7 100644 --- a/crates/loro-wasm/README.md +++ b/crates/loro-wasm/README.md @@ -80,6 +80,14 @@ The standard build pipeline (`deno run -A ./scripts/build.ts dev|release`) now k Load the source map in browser devtools; when devtools fetches the debug companion it can map instructions back to Rust source files and line numbers without inflating the shipped `.wasm`. +## Bundler entries + +Bare `import { LoroDoc } from "loro-crdt"` in browser bundlers that respect the package `browser` field remaps the WASM glue to a synchronous browser build, which avoids Vite/Rolldown production chunk cycles around `.wasm` wrappers. Runtimes that need native `.wasm` module imports can still use the `bundler` entry, and apps that prefer explicit async initialization can use `loro-crdt/web`. + +Vite and Webpack understand `new URL("./loro_wasm_bg.wasm", import.meta.url)` and emit the WASM asset automatically. Plain esbuild and plain Rollup do not copy that asset by default. For those tools, either import `loro-crdt/base64` to inline the WASM into the JS bundle without top-level await, or keep the default `loro-crdt` import and copy `node_modules/loro-crdt/browser/loro_wasm_bg.wasm` next to the emitted JS bundle as a build step. + +Next.js Turbopack can use the default browser entry. If a Next.js Webpack build resolves the `bundler` entry instead of the package `browser` remap, use `loro-crdt/base64`. + # Example [](https://stackblitz.com/edit/loro-basic-test?file=test%2Floro-sync.test.ts) diff --git a/crates/loro-wasm/package.json b/crates/loro-wasm/package.json index daefb7693..0806c0aef 100644 --- a/crates/loro-wasm/package.json +++ b/crates/loro-wasm/package.json @@ -15,10 +15,15 @@ "url": "git+https://github.com/loro-dev/loro.git" }, "main": "nodejs/index.js", + "browser": { + "./bundler/loro_wasm": "./browser/loro_wasm.js", + "./bundler/loro_wasm.js": "./browser/loro_wasm.js" + }, "module": "bundler/index.js", "types": "bundler/index.d.ts", "files": [ "./bundler", + "./browser", "./nodejs", "./web", "./base64", diff --git a/crates/loro-wasm/rollup.config.mjs b/crates/loro-wasm/rollup.config.mjs index a41e5847e..d8dffa3b8 100644 --- a/crates/loro-wasm/rollup.config.mjs +++ b/crates/loro-wasm/rollup.config.mjs @@ -34,6 +34,9 @@ export default [ // ESM for Web createConfig('es', 'ES2020', 'web'), + // ESM for browser bundlers that do not support top-level await. + createConfig('es', 'ES2020', 'browser'), + // ESM for bundler createConfig('es', 'ES2020', 'bundler'), ]; diff --git a/crates/loro-wasm/scripts/browser_patch.js b/crates/loro-wasm/scripts/browser_patch.js new file mode 100644 index 000000000..ceb8ab30b --- /dev/null +++ b/crates/loro-wasm/scripts/browser_patch.js @@ -0,0 +1,57 @@ +import * as imports from "./loro_wasm_bg.js"; + +const WASM_IMPORTS = { + "./loro_wasm_bg.js": imports, +}; + +const finalize = (exports) => { + imports.__wbg_set_wasm(exports); + tryStart(imports); +}; + +function tryStart(imports) { + if (typeof imports.__wbindgen_start === "function") { + imports.__wbindgen_start(); + } +} + +// Keep this entry synchronous without top-level await. Vite/Rolldown can +// otherwise create circular wasm wrapper chunks in production builds. +function loadWasmBytesSync(url) { + if (typeof XMLHttpRequest !== "function") { + throw new Error( + "loro-crdt browser build requires XMLHttpRequest for synchronous WASM loading. Use the nodejs, web, base64, or bundler entry for this runtime.", + ); + } + + const request = new XMLHttpRequest(); + request.open("GET", url, false); + request.responseType = "arraybuffer"; + request.send(null); + + if (request.status !== 0 && (request.status < 200 || request.status >= 300)) { + throw new Error( + `Failed to load loro-crdt WASM from ${url}: ${request.status} ${request.statusText}`, + ); + } + + if (!(request.response instanceof ArrayBuffer)) { + throw new Error( + "Failed to load loro-crdt WASM: response is not an ArrayBuffer", + ); + } + + return request.response; +} + +function instantiateSync(bytes, importObject) { + const module = new WebAssembly.Module(bytes); + return new WebAssembly.Instance(module, importObject); +} + +const wasmUrl = new URL("./loro_wasm_bg.wasm", import.meta.url); +const instance = instantiateSync(loadWasmBytesSync(wasmUrl.href), WASM_IMPORTS); + +finalize(instance.exports); + +export * from "./loro_wasm_bg.js"; diff --git a/crates/loro-wasm/scripts/build.ts b/crates/loro-wasm/scripts/build.ts index 9e3c0f71e..5cdba2f64 100644 --- a/crates/loro-wasm/scripts/build.ts +++ b/crates/loro-wasm/scripts/build.ts @@ -14,6 +14,7 @@ const __dirname = path.dirname(path.fromFileUrl(import.meta.url)); // deno run -A build.ts debug // deno run -A build.ts release +// deno run -A build.ts release browser // deno run -A build.ts release web // deno run -A build.ts release nodejs let profile = "dev"; @@ -22,7 +23,7 @@ if (Deno.args[0] == "release") { profile = "release"; profileDir = "release"; } -const TARGETS = ["bundler", "nodejs", "web"]; +const TARGETS = ["bundler", "browser", "nodejs", "web"]; const startTime = performance.now(); const LoroWasmDir = path.resolve(__dirname, ".."); const WorkspaceCargoToml = path.resolve(__dirname, "../../../Cargo.toml"); @@ -224,8 +225,9 @@ async function buildTarget(target: string) { } // TODO: polyfill FinalizationRegistry + const bindgenTarget = target === "browser" ? "bundler" : target; const cmd = - `wasm-bindgen --keep-debug --weak-refs --target ${target} --out-dir ${target} ${RawWasmPath}`; + `wasm-bindgen --keep-debug --weak-refs --target ${bindgenTarget} --out-dir ${target} ${RawWasmPath}`; console.log(">", cmd); await Deno.run({ cmd: cmd.split(" "), cwd: LoroWasmDir }).status(); console.log(); @@ -255,6 +257,16 @@ async function buildTarget(target: string) { patch, ); } + if (target === "browser") { + console.log("🔨 Patching browser target"); + const patch = await Deno.readTextFile( + path.resolve(__dirname, "./browser_patch.js"), + ); + await Deno.writeTextFile( + path.resolve(targetDirPath, "loro_wasm.js"), + patch, + ); + } } async function stripReferenceTypesFeatureHint() { diff --git a/crates/loro-wasm/scripts/post-rollup.ts b/crates/loro-wasm/scripts/post-rollup.ts index 40ac24340..79afce5ac 100644 --- a/crates/loro-wasm/scripts/post-rollup.ts +++ b/crates/loro-wasm/scripts/post-rollup.ts @@ -1,6 +1,6 @@ import { walk } from "https://deno.land/std@0.224.0/fs/mod.ts"; -const DIRS_TO_SCAN = ["./nodejs", "./bundler", "./web"]; +const DIRS_TO_SCAN = ["./nodejs", "./bundler", "./browser", "./web"]; const FILES_TO_PROCESS = ["index.js", "index.d.ts"]; async function replaceInFile(filePath: string) { @@ -73,7 +73,12 @@ async function rollupBase64() { const base64IndexPath = "./base64/index.js"; const content = await Deno.readTextFile(base64IndexPath); - const nextContent = injectBase64WasmBranch(content, base64IndexPath); + let nextContent = injectBase64WasmBranch(content, base64IndexPath); + nextContent = simplifyBase64WasmInitialization( + nextContent, + base64IndexPath, + ); + nextContent = patchBase64NodeRequires(nextContent, base64IndexPath); await Deno.writeTextFile(base64IndexPath, nextContent); await Deno.copyFile("./bundler/loro_wasm.d.ts", "./base64/loro_wasm.d.ts"); @@ -109,13 +114,74 @@ function injectBase64WasmBranch(content: string, filePath: string): string { return content.replace(bunBranchPattern, base64Branch); } +function simplifyBase64WasmInitialization( + content: string, + filePath: string, +): string { + const startMarker = + "// Normalize how bundlers expose the wasm module/exports."; + const endMarker = `\n\n/** + * @deprecated Please use LoroDoc + */`; + const start = content.indexOf(startMarker); + const end = start === -1 ? -1 : content.indexOf(endMarker, start); + if (start === -1 || end === -1) { + throw new Error( + `Could not locate wasm initialization block while patching ${filePath}`, + ); + } + + const replacement = `// Instantiate the inlined base64 wasm synchronously. +const wasmModuleOrInstance = rawWasm.default({ + "./loro_wasm_bg.js": imports, +}); +const wasmInstance = + wasmModuleOrInstance instanceof WebAssembly.Instance + ? wasmModuleOrInstance + : new WebAssembly.Instance(wasmModuleOrInstance, { + "./loro_wasm_bg.js": imports, + }); +__wbg_set_wasm(wasmInstance.exports ?? wasmInstance); +if (typeof imports.__wbindgen_start === "function") { + imports.__wbindgen_start(); +}`; + + return content.slice(0, start) + replacement + content.slice(end); +} + +function patchBase64NodeRequires(content: string, filePath: string): string { + const directRequires = `var fs = require("fs"); +var path = require("path");`; + const indirectRequires = `var nodeRequire = typeof require === "function" ? require : null; +var fs = nodeRequire && nodeRequire("fs"); +var path = nodeRequire && nodeRequire("path");`; + const browserSafeRequires = `var fs = null; +var path = null;`; + + if (content.includes(browserSafeRequires)) { + return content; + } + + if (content.includes(directRequires)) { + return content.replace(directRequires, browserSafeRequires); + } + + if (content.includes(indirectRequires)) { + return content.replace(indirectRequires, browserSafeRequires); + } + + throw new Error( + `Could not locate Node require block while patching ${filePath}`, + ); +} + async function main() { for (const dir of DIRS_TO_SCAN) { await transform(dir); } await rollupBase64(); - transform("./base64"); + await transform("./base64"); } if (import.meta.main) { diff --git a/examples/bundler-smoke-tests/.gitignore b/examples/bundler-smoke-tests/.gitignore new file mode 100644 index 000000000..ac617faed --- /dev/null +++ b/examples/bundler-smoke-tests/.gitignore @@ -0,0 +1,2 @@ +.tmp/ +node_modules/ diff --git a/examples/bundler-smoke-tests/README.md b/examples/bundler-smoke-tests/README.md new file mode 100644 index 000000000..05b01b9cd --- /dev/null +++ b/examples/bundler-smoke-tests/README.md @@ -0,0 +1,66 @@ +# Loro Bundler Smoke Tests + +This package checks that the published `loro-crdt` JavaScript/WASM package can be +imported by common browser bundlers. + +The tests create one temporary project per bundler under `.tmp/`, install the +requested bundler version there, build a tiny app, and inspect the output for the +WASM packaging shape that matters for Loro. + +## Usage + +Build `crates/loro-wasm` first so `bundler/`, `browser/`, `base64/`, `nodejs/`, +and `web/` artifacts exist: + +```sh +pnpm release-wasm +``` + +Then run: + +```sh +pnpm --dir examples/bundler-smoke-tests run test:fast +``` + +Run Next.js/Turbopack separately because it is heavier: + +```sh +pnpm --dir examples/bundler-smoke-tests run test:next +``` + +To test an already-published package instead of the local workspace build: + +```sh +LORO_SMOKE_PACKAGE=loro-crdt@1.12.1 pnpm --dir examples/bundler-smoke-tests run test:fast +``` + +## Matrix + +- `vite5`, `vite6`, `vite7`, `vite8`: bare `import "loro-crdt"`. +- `rolldown-vite`: bare `import "loro-crdt"` against Rolldown's Vite package. +- `webpack5`: bare `import "loro-crdt"`. +- `rsbuild2`: bare `import "loro-crdt"`. +- `rspack2`: bare `import "loro-crdt"`. +- `parcel2`: bare `import "loro-crdt"`. +- `esbuild-default-copy`: bare `import "loro-crdt"` plus a post-build copy of + `browser/loro_wasm_bg.wasm` next to the emitted JS bundle. +- `rollup-default-copy`: bare `import "loro-crdt"` plus the same post-build copy. +- `esbuild-base64`: `import "loro-crdt/base64"` with no external WASM asset. +- `rollup-base64`: `import "loro-crdt/base64"` with no external WASM asset. +- `next16-turbopack`: Next 16 default Turbopack production build with bare + `import "loro-crdt"`. +- `next16-webpack`: Next 16 production build with `--webpack` and + `import "loro-crdt/base64"`. + +## Notes + +Vite and Webpack understand `new URL("./asset", import.meta.url)` as an asset +reference and emit the `.wasm` file automatically. Plain esbuild and plain Rollup +do not do that by themselves, so they should either use `loro-crdt/base64` for a +single-file bundle with no top-level await, or copy `browser/loro_wasm_bg.wasm` +to the output directory as a build step. + +Next 16 Turbopack handles the default browser entry in this smoke test. Next 16's +Webpack build was observed to resolve Loro's bundler entry instead of the package +`browser` remap, so this matrix tests the documented `base64` entry for that +mode. diff --git a/examples/bundler-smoke-tests/package.json b/examples/bundler-smoke-tests/package.json new file mode 100644 index 000000000..4aa9269d8 --- /dev/null +++ b/examples/bundler-smoke-tests/package.json @@ -0,0 +1,12 @@ +{ + "name": "loro-bundler-smoke-tests", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "node ./scripts/run.mjs", + "test:fast": "node ./scripts/run.mjs vite5 vite6 vite7 vite8 rolldown-vite webpack5 rsbuild2 rspack2 esbuild-default-copy esbuild-base64 rollup-default-copy rollup-base64 parcel2", + "test:next": "node ./scripts/run.mjs next16-turbopack next16-webpack", + "list": "node ./scripts/run.mjs --list" + } +} diff --git a/examples/bundler-smoke-tests/scripts/run.mjs b/examples/bundler-smoke-tests/scripts/run.mjs new file mode 100644 index 000000000..3b00ebff0 --- /dev/null +++ b/examples/bundler-smoke-tests/scripts/run.mjs @@ -0,0 +1,543 @@ +import { execFile as execFileCb } from "node:child_process"; +import { + cp, + mkdir, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFile = promisify(execFileCb); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(packageDir, "../.."); +const tmpRoot = path.join(packageDir, ".tmp"); +const localLoroPackage = path.join(repoRoot, "crates/loro-wasm"); +const loroPackageSpec = normalizeLoroPackageSpec( + process.env.LORO_SMOKE_PACKAGE ?? `file:${localLoroPackage}`, +); + +function normalizeLoroPackageSpec(spec) { + if (spec === "loro-crdt" || spec.startsWith("loro-crdt@")) { + return `npm:${spec}`; + } + + return spec; +} + +const sharedApp = (importPath) => `import { LoroDoc } from "${importPath}"; + +const doc = new LoroDoc(); +const text = doc.getText("text"); +text.insert(0, "bundler-smoke"); + +const value = doc.toJSON().text; +if (value !== "bundler-smoke") { + throw new Error(\`Unexpected Loro value: \${value}\`); +} + +globalThis.__LORO_SMOKE__ = value; +const app = document.getElementById("app"); +if (app) { + app.textContent = value; +} +`; + +const html = ` + +
+ +