Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fix-loro-vite-rolldown-wasm.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions crates/loro-wasm-map/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"license": "MIT",
"files": [
"bundler",
"browser",
"nodejs",
"web",
"README.md",
Expand Down
8 changes: 8 additions & 0 deletions crates/loro-wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/edit/loro-basic-test?file=test%2Floro-sync.test.ts)
Expand Down
5 changes: 5 additions & 0 deletions crates/loro-wasm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions crates/loro-wasm/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
];
57 changes: 57 additions & 0 deletions crates/loro-wasm/scripts/browser_patch.js
Original file line number Diff line number Diff line change
@@ -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";
16 changes: 14 additions & 2 deletions crates/loro-wasm/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down
72 changes: 69 additions & 3 deletions crates/loro-wasm/scripts/post-rollup.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions examples/bundler-smoke-tests/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.tmp/
node_modules/
66 changes: 66 additions & 0 deletions examples/bundler-smoke-tests/README.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions examples/bundler-smoke-tests/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading