Skip to content

Commit ebee770

Browse files
lodyai[bot]zxch3n
andauthored
Fix browser WASM loader for remapped bundler builds (#990)
* fix: repair browser wasm loader * test: add browser bundler runtime smoke * fix: keep source map comments last --------- Co-authored-by: Zixuan Chen <zx@loro.dev>
1 parent f3ece99 commit ebee770

10 files changed

Lines changed: 700 additions & 149 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"loro-crdt": patch
3+
---
4+
5+
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.

crates/loro-wasm/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

crates/loro-wasm/scripts/browser_patch.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import * as imports from "./loro_wasm_bg.js";
1+
/* __LORO_BROWSER_PATCH_IMPORTS__ */
22

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

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

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

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

44-
return request.response;
52+
return bytes;
4553
}
4654

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

5563
finalize(instance.exports);
5664

57-
export * from "./loro_wasm_bg.js";
65+
/* __LORO_BROWSER_PATCH_EXPORTS__ */

crates/loro-wasm/scripts/build.ts

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { getOctokit } from "npm:@actions/github";
55

66
// Polyfill for missing performance.markResourceTiming function in Deno
77
if (
8-
typeof performance !== "undefined" && !(performance as any).markResourceTiming
8+
typeof performance !== "undefined" &&
9+
!(performance as any).markResourceTiming
910
) {
1011
(performance as any).markResourceTiming = () => {};
1112
}
@@ -38,8 +39,7 @@ const wasmPackageJson = JSON.parse(
3839
);
3940
const LoroWasmVersion = (wasmPackageJson as { version: string }).version;
4041
const MapPackageDir = path.resolve(__dirname, "../../loro-wasm-map");
41-
const WASM_SOURCEMAP_BASE =
42-
`https://unpkg.com/loro-crdt-map@${LoroWasmVersion}`;
42+
const WASM_SOURCEMAP_BASE = `https://unpkg.com/loro-crdt-map@${LoroWasmVersion}`;
4343
const EMBED_SCRIPT = path.resolve(
4444
__dirname,
4545
"../../../scripts/embed-wasm-sourcemap.mjs",
@@ -144,7 +144,7 @@ async function build() {
144144

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

150150
if (existingComment) {
@@ -184,20 +184,21 @@ async function cargoBuild() {
184184
profile,
185185
];
186186
console.log(cmd.join(" "));
187-
const env: Record<string, string> | undefined = profile === "release"
188-
? (() => {
189-
const existing = Deno.env.get("RUSTFLAGS");
190-
const next = ["-C debuginfo=2"];
191-
if (existing && existing.length > 0) {
192-
next.unshift(existing);
193-
}
194-
return {
195-
RUSTFLAGS: next.join(" "),
196-
CARGO_PROFILE_RELEASE_DEBUG: "true",
197-
CARGO_PROFILE_RELEASE_STRIP: "none",
198-
};
199-
})()
200-
: undefined;
187+
const env: Record<string, string> | undefined =
188+
profile === "release"
189+
? (() => {
190+
const existing = Deno.env.get("RUSTFLAGS");
191+
const next = ["-C debuginfo=2"];
192+
if (existing && existing.length > 0) {
193+
next.unshift(existing);
194+
}
195+
return {
196+
RUSTFLAGS: next.join(" "),
197+
CARGO_PROFILE_RELEASE_DEBUG: "true",
198+
CARGO_PROFILE_RELEASE_STRIP: "none",
199+
};
200+
})()
201+
: undefined;
201202
const status = await Deno.run({
202203
cmd,
203204
cwd: LoroWasmDir,
@@ -226,8 +227,7 @@ async function buildTarget(target: string) {
226227

227228
// TODO: polyfill FinalizationRegistry
228229
const bindgenTarget = target === "browser" ? "bundler" : target;
229-
const cmd =
230-
`wasm-bindgen --keep-debug --weak-refs --target ${bindgenTarget} --out-dir ${target} ${RawWasmPath}`;
230+
const cmd = `wasm-bindgen --keep-debug --weak-refs --target ${bindgenTarget} --out-dir ${target} ${RawWasmPath}`;
231231
console.log(">", cmd);
232232
await Deno.run({ cmd: cmd.split(" "), cwd: LoroWasmDir }).status();
233233
console.log();
@@ -259,16 +259,68 @@ async function buildTarget(target: string) {
259259
}
260260
if (target === "browser") {
261261
console.log("🔨 Patching browser target");
262-
const patch = await Deno.readTextFile(
262+
const patchTemplate = await Deno.readTextFile(
263263
path.resolve(__dirname, "./browser_patch.js"),
264264
);
265+
const wasmBg = await Deno.readTextFile(
266+
path.resolve(targetDirPath, "loro_wasm_bg.js"),
267+
);
268+
const patch = renderBrowserPatch(patchTemplate, wasmBg);
265269
await Deno.writeTextFile(
266270
path.resolve(targetDirPath, "loro_wasm.js"),
267271
patch,
268272
);
269273
}
270274
}
271275

276+
function renderBrowserPatch(template: string, wasmBg: string): string {
277+
const names = extractExportNames(wasmBg);
278+
if (!names.includes("__wbg_set_wasm")) {
279+
throw new Error("loro_wasm_bg.js does not export __wbg_set_wasm");
280+
}
281+
282+
const importNames = names.map((name) => ` ${name},`).join("\n");
283+
const importObject = names.map((name) => ` ${name},`).join("\n");
284+
const exportNames = names.map((name) => ` ${name},`).join("\n");
285+
286+
return template
287+
.replace(
288+
"/* __LORO_BROWSER_PATCH_IMPORTS__ */",
289+
`import {\n${importNames}\n} from "./loro_wasm_bg.js";`,
290+
)
291+
.replace("/* __LORO_BROWSER_PATCH_IMPORT_OBJECT__ */", importObject)
292+
.replace(
293+
"/* __LORO_BROWSER_PATCH_EXPORTS__ */",
294+
`export {\n${exportNames}\n};`,
295+
);
296+
}
297+
298+
function extractExportNames(source: string): string[] {
299+
const names = new Set<string>();
300+
const declaration =
301+
/^export\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm;
302+
let match: RegExpExecArray | null;
303+
304+
while ((match = declaration.exec(source)) != null) {
305+
names.add(match[1]);
306+
}
307+
308+
const exportList = /^export\s*\{\s*([^}]+)\s*\}/gm;
309+
while ((match = exportList.exec(source)) != null) {
310+
for (const part of match[1].split(",")) {
311+
const name = part
312+
.trim()
313+
.split(/\s+as\s+/)
314+
.pop();
315+
if (name != null && /^[A-Za-z_$][\w$]*$/.test(name)) {
316+
names.add(name);
317+
}
318+
}
319+
}
320+
321+
return [...names].sort();
322+
}
323+
272324
async function stripReferenceTypesFeatureHint() {
273325
// wasm-bindgen 0.2.100 uses the `target_features` custom section to choose
274326
// externref table glue. Safari 16.0-16.3 lacks WebAssembly reference-types,
@@ -306,11 +358,7 @@ async function postProcessWasm(targetDirPath: string, target: string) {
306358
]);
307359

308360
if (profile === "release") {
309-
await runWasmTools([
310-
"strip-debug",
311-
wasmPath,
312-
wasmPath,
313-
]);
361+
await runWasmTools(["strip-debug", wasmPath, wasmPath]);
314362
}
315363
}
316364

0 commit comments

Comments
 (0)