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
5 changes: 5 additions & 0 deletions .changeset/fix-browser-sync-wasm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"loro-crdt": patch
---

Fix the browser WASM loader for remapped bundler builds. The browser entry now avoids setting `XMLHttpRequest.responseType` on synchronous document requests, which browsers reject, reads the WASM bytes through a one-byte text decoding path, and emits explicit WASM re-exports so Parcel scope-hoisted builds can run in the browser.
1 change: 1 addition & 0 deletions crates/loro-wasm/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
If you change WASM packaging or bundler-facing entrypoints, run `pnpm --dir examples/bundler-smoke-tests run test:browser` from the repo root to verify the package still builds and executes in real browsers.
24 changes: 16 additions & 8 deletions crates/loro-wasm/scripts/browser_patch.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import * as imports from "./loro_wasm_bg.js";
/* __LORO_BROWSER_PATCH_IMPORTS__ */

// Keep the import object as a plain object built from named imports. Parcel
// scope hoisting can lose a direct namespace import when it is used as a
// WebAssembly import object.
const imports = {
/* __LORO_BROWSER_PATCH_IMPORT_OBJECT__ */
};
const WASM_IMPORTS = {
"./loro_wasm_bg.js": imports,
};
Expand All @@ -26,7 +32,9 @@ function loadWasmBytesSync(url) {

const request = new XMLHttpRequest();
request.open("GET", url, false);
request.responseType = "arraybuffer";
// A document cannot set `responseType` on synchronous XHR. Force a one-byte
// text decoding instead and convert the result back to wasm bytes.
request.overrideMimeType("text/plain; charset=x-user-defined");
request.send(null);

if (request.status !== 0 && (request.status < 200 || request.status >= 300)) {
Expand All @@ -35,13 +43,13 @@ function loadWasmBytesSync(url) {
);
}

if (!(request.response instanceof ArrayBuffer)) {
throw new Error(
"Failed to load loro-crdt WASM: response is not an ArrayBuffer",
);
const text = request.responseText;
const bytes = new Uint8Array(text.length);
for (let i = 0; i < text.length; i++) {
bytes[i] = text.charCodeAt(i) & 0xff;
}

return request.response;
return bytes;
}

function instantiateSync(bytes, importObject) {
Expand All @@ -54,4 +62,4 @@ const instance = instantiateSync(loadWasmBytesSync(wasmUrl.href), WASM_IMPORTS);

finalize(instance.exports);

export * from "./loro_wasm_bg.js";
/* __LORO_BROWSER_PATCH_EXPORTS__ */
100 changes: 74 additions & 26 deletions crates/loro-wasm/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { getOctokit } from "npm:@actions/github";

// Polyfill for missing performance.markResourceTiming function in Deno
if (
typeof performance !== "undefined" && !(performance as any).markResourceTiming
typeof performance !== "undefined" &&
!(performance as any).markResourceTiming
) {
(performance as any).markResourceTiming = () => {};
}
Expand Down Expand Up @@ -38,8 +39,7 @@ const wasmPackageJson = JSON.parse(
);
const LoroWasmVersion = (wasmPackageJson as { version: string }).version;
const MapPackageDir = path.resolve(__dirname, "../../loro-wasm-map");
const WASM_SOURCEMAP_BASE =
`https://unpkg.com/loro-crdt-map@${LoroWasmVersion}`;
const WASM_SOURCEMAP_BASE = `https://unpkg.com/loro-crdt-map@${LoroWasmVersion}`;
const EMBED_SCRIPT = path.resolve(
__dirname,
"../../../scripts/embed-wasm-sourcemap.mjs",
Expand Down Expand Up @@ -144,7 +144,7 @@ async function build() {

const sizeReportMarker = "<!-- loro-wasm-size-report -->";
const existingComment = comments.find((comment) =>
comment.body?.includes(sizeReportMarker)
comment.body?.includes(sizeReportMarker),
);

if (existingComment) {
Expand Down Expand Up @@ -184,20 +184,21 @@ async function cargoBuild() {
profile,
];
console.log(cmd.join(" "));
const env: Record<string, string> | undefined = profile === "release"
? (() => {
const existing = Deno.env.get("RUSTFLAGS");
const next = ["-C debuginfo=2"];
if (existing && existing.length > 0) {
next.unshift(existing);
}
return {
RUSTFLAGS: next.join(" "),
CARGO_PROFILE_RELEASE_DEBUG: "true",
CARGO_PROFILE_RELEASE_STRIP: "none",
};
})()
: undefined;
const env: Record<string, string> | undefined =
profile === "release"
? (() => {
const existing = Deno.env.get("RUSTFLAGS");
const next = ["-C debuginfo=2"];
if (existing && existing.length > 0) {
next.unshift(existing);
}
return {
RUSTFLAGS: next.join(" "),
CARGO_PROFILE_RELEASE_DEBUG: "true",
CARGO_PROFILE_RELEASE_STRIP: "none",
};
})()
: undefined;
const status = await Deno.run({
cmd,
cwd: LoroWasmDir,
Expand Down Expand Up @@ -226,8 +227,7 @@ async function buildTarget(target: string) {

// TODO: polyfill FinalizationRegistry
const bindgenTarget = target === "browser" ? "bundler" : target;
const cmd =
`wasm-bindgen --keep-debug --weak-refs --target ${bindgenTarget} --out-dir ${target} ${RawWasmPath}`;
const cmd = `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 @@ -259,16 +259,68 @@ async function buildTarget(target: string) {
}
if (target === "browser") {
console.log("🔨 Patching browser target");
const patch = await Deno.readTextFile(
const patchTemplate = await Deno.readTextFile(
path.resolve(__dirname, "./browser_patch.js"),
);
const wasmBg = await Deno.readTextFile(
path.resolve(targetDirPath, "loro_wasm_bg.js"),
);
const patch = renderBrowserPatch(patchTemplate, wasmBg);
await Deno.writeTextFile(
path.resolve(targetDirPath, "loro_wasm.js"),
patch,
);
}
}

function renderBrowserPatch(template: string, wasmBg: string): string {
const names = extractExportNames(wasmBg);
if (!names.includes("__wbg_set_wasm")) {
throw new Error("loro_wasm_bg.js does not export __wbg_set_wasm");
}

const importNames = names.map((name) => ` ${name},`).join("\n");
const importObject = names.map((name) => ` ${name},`).join("\n");
const exportNames = names.map((name) => ` ${name},`).join("\n");

return template
.replace(
"/* __LORO_BROWSER_PATCH_IMPORTS__ */",
`import {\n${importNames}\n} from "./loro_wasm_bg.js";`,
)
.replace("/* __LORO_BROWSER_PATCH_IMPORT_OBJECT__ */", importObject)
.replace(
"/* __LORO_BROWSER_PATCH_EXPORTS__ */",
`export {\n${exportNames}\n};`,
);
}

function extractExportNames(source: string): string[] {
const names = new Set<string>();
const declaration =
/^export\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm;
let match: RegExpExecArray | null;

while ((match = declaration.exec(source)) != null) {
names.add(match[1]);
}

const exportList = /^export\s*\{\s*([^}]+)\s*\}/gm;
while ((match = exportList.exec(source)) != null) {
for (const part of match[1].split(",")) {
const name = part
.trim()
.split(/\s+as\s+/)
.pop();
if (name != null && /^[A-Za-z_$][\w$]*$/.test(name)) {
names.add(name);
}
}
}

return [...names].sort();
}

async function stripReferenceTypesFeatureHint() {
// wasm-bindgen 0.2.100 uses the `target_features` custom section to choose
// externref table glue. Safari 16.0-16.3 lacks WebAssembly reference-types,
Expand Down Expand Up @@ -306,11 +358,7 @@ async function postProcessWasm(targetDirPath: string, target: string) {
]);

if (profile === "release") {
await runWasmTools([
"strip-debug",
wasmPath,
wasmPath,
]);
await runWasmTools(["strip-debug", wasmPath, wasmPath]);
}
}

Expand Down
Loading
Loading