From caedf459089852703a488d0a8f300561359a5fdd Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 25 May 2026 16:01:15 +0000 Subject: [PATCH 1/3] fix: repair browser wasm loader --- .changeset/fix-browser-sync-wasm.md | 5 + crates/loro-wasm/scripts/browser_patch.js | 24 +- crates/loro-wasm/scripts/build.ts | 100 ++++++-- crates/loro-wasm/scripts/post-rollup.ts | 295 ++++++++++++++-------- 4 files changed, 278 insertions(+), 146 deletions(-) create mode 100644 .changeset/fix-browser-sync-wasm.md diff --git a/.changeset/fix-browser-sync-wasm.md b/.changeset/fix-browser-sync-wasm.md new file mode 100644 index 000000000..802f3a620 --- /dev/null +++ b/.changeset/fix-browser-sync-wasm.md @@ -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. diff --git a/crates/loro-wasm/scripts/browser_patch.js b/crates/loro-wasm/scripts/browser_patch.js index ceb8ab30b..9b6a56a16 100644 --- a/crates/loro-wasm/scripts/browser_patch.js +++ b/crates/loro-wasm/scripts/browser_patch.js @@ -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, }; @@ -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)) { @@ -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) { @@ -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__ */ diff --git a/crates/loro-wasm/scripts/build.ts b/crates/loro-wasm/scripts/build.ts index 5cdba2f64..da3b41085 100644 --- a/crates/loro-wasm/scripts/build.ts +++ b/crates/loro-wasm/scripts/build.ts @@ -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 = () => {}; } @@ -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", @@ -144,7 +144,7 @@ async function build() { const sizeReportMarker = ""; const existingComment = comments.find((comment) => - comment.body?.includes(sizeReportMarker) + comment.body?.includes(sizeReportMarker), ); if (existingComment) { @@ -184,20 +184,21 @@ async function cargoBuild() { profile, ]; console.log(cmd.join(" ")); - const env: Record | 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 | 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, @@ -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(); @@ -259,9 +259,13 @@ 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, @@ -269,6 +273,54 @@ async function buildTarget(target: string) { } } +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(); + 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, @@ -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]); } } diff --git a/crates/loro-wasm/scripts/post-rollup.ts b/crates/loro-wasm/scripts/post-rollup.ts index 79afce5ac..b41056a6d 100644 --- a/crates/loro-wasm/scripts/post-rollup.ts +++ b/crates/loro-wasm/scripts/post-rollup.ts @@ -4,101 +4,172 @@ const DIRS_TO_SCAN = ["./nodejs", "./bundler", "./browser", "./web"]; const FILES_TO_PROCESS = ["index.js", "index.d.ts"]; async function replaceInFile(filePath: string) { + try { + let content = await Deno.readTextFile(filePath); + + // Replace various import/require patterns for 'loro-wasm' + const isWebIndexJs = + filePath.includes("web") && filePath.endsWith("index.js"); + const target = isWebIndexJs ? "./loro_wasm.js" : "./loro_wasm"; + + content = content.replace(/from ["']loro-wasm["']/g, `from "${target}"`); + content = content.replace( + /require\(["']loro-wasm["']\)/g, + `require("${target}")`, + ); + content = content.replace( + /import\(["']loro-wasm["']\)/g, + `import("${target}")`, + ); + + if (isWebIndexJs) { + content = `export { default } from "./loro_wasm.js";\n${content}`; + } + + if ( + filePath.endsWith("index.js") && + !filePath.includes("nodejs") && + !filePath.includes("base64") + ) { + content = await injectExplicitWasmReexports(content, filePath, target); + } + + await Deno.writeTextFile(filePath, content); + console.log(`✓ Processed: ${filePath}`); + } catch (error) { + console.error(`Error processing ${filePath}:`, error); + } +} + +async function injectExplicitWasmReexports( + content: string, + filePath: string, + target: string, +): Promise { + const marker = "loro-explicit-wasm-reexports"; + if (content.includes(marker)) { + return content; + } + + const dir = filePath.slice(0, filePath.lastIndexOf("/")); + const wasmBgPath = `${dir}/loro_wasm_bg.js`; + const wasmPath = `${dir}/loro_wasm.js`; + const exportSource = await readFirstExisting([wasmBgPath, wasmPath]); + const names = extractExportNames(exportSource.content); + if (names.length === 0) { + throw new Error(`Could not find WASM exports in ${exportSource.path}`); + } + + const explicitExports = names.map((name) => ` ${name},`).join("\n"); + const block = `// ${marker}\nexport {\n${explicitExports}\n} from "${target}";`; + const sourceMapPattern = /\n\/\/# sourceMappingURL=.*$/; + if (sourceMapPattern.test(content)) { + return content.replace(sourceMapPattern, `\n${block}$&`); + } + + return `${content}\n${block}\n`; +} + +async function readFirstExisting( + paths: string[], +): Promise<{ path: string; content: string }> { + for (const path of paths) { try { - let content = await Deno.readTextFile(filePath); - - // Replace various import/require patterns for 'loro-wasm' - const isWebIndexJs = filePath.includes("web") && - filePath.endsWith("index.js"); - const target = isWebIndexJs ? "./loro_wasm.js" : "./loro_wasm"; - - content = content.replace( - /from ["']loro-wasm["']/g, - `from "${target}"`, - ); - content = content.replace( - /require\(["']loro-wasm["']\)/g, - `require("${target}")`, - ); - content = content.replace( - /import\(["']loro-wasm["']\)/g, - `import("${target}")`, - ); - - if (isWebIndexJs) { - content = `export { default } from "./loro_wasm.js";\n${content}`; - } - - await Deno.writeTextFile(filePath, content); - console.log(`✓ Processed: ${filePath}`); + return { path, content: await Deno.readTextFile(path) }; } catch (error) { - console.error(`Error processing ${filePath}:`, error); + if (!(error instanceof Deno.errors.NotFound)) { + throw error; + } } + } + + throw new Error(`None of these files exist: ${paths.join(", ")}`); +} + +function extractExportNames(source: string): string[] { + const names = new Set(); + 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 transform(dir: string) { - try { - for await ( - const entry of walk(dir, { - includeDirs: false, - match: [/index\.(js|d\.ts)$/], - }) - ) { - if (FILES_TO_PROCESS.includes(entry.name)) { - await replaceInFile(entry.path); - } - } - } catch (error) { - console.error(`Error scanning directory ${dir}:`, error); + try { + for await (const entry of walk(dir, { + includeDirs: false, + match: [/index\.(js|d\.ts)$/], + })) { + if (FILES_TO_PROCESS.includes(entry.name)) { + await replaceInFile(entry.path); + } } + } catch (error) { + console.error(`Error scanning directory ${dir}:`, error); + } } async function rollupBase64() { - const command = new Deno.Command("rollup", { - args: ["--config", "./rollup.base64.config.mjs"], - }); + const command = new Deno.Command("rollup", { + args: ["--config", "./rollup.base64.config.mjs"], + }); - try { - const { code, stdout, stderr } = await command.output(); - if (code === 0) { - console.log("✓ Rollup base64 build completed successfully"); - } else { - console.error("Error running rollup base64 build:"); - console.error(new TextDecoder().decode(stdout)); - console.error(new TextDecoder().decode(stderr)); - } - } catch (error) { - console.error("Failed to execute rollup command:", error); + try { + const { code, stdout, stderr } = await command.output(); + if (code === 0) { + console.log("✓ Rollup base64 build completed successfully"); + } else { + console.error("Error running rollup base64 build:"); + console.error(new TextDecoder().decode(stdout)); + console.error(new TextDecoder().decode(stderr)); } + } catch (error) { + console.error("Failed to execute rollup command:", error); + } - const base64IndexPath = "./base64/index.js"; - const content = await Deno.readTextFile(base64IndexPath); - let nextContent = injectBase64WasmBranch(content, base64IndexPath); - nextContent = simplifyBase64WasmInitialization( - nextContent, - base64IndexPath, - ); - nextContent = patchBase64NodeRequires(nextContent, base64IndexPath); - await Deno.writeTextFile(base64IndexPath, nextContent); + const base64IndexPath = "./base64/index.js"; + const content = await Deno.readTextFile(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"); + await Deno.copyFile("./bundler/loro_wasm.d.ts", "./base64/loro_wasm.d.ts"); } function injectBase64WasmBranch(content: string, filePath: string): string { - const alreadyPatched = - content.includes("typeof wasmModuleOrExports === \"function\""); - if (alreadyPatched) { - return content; - } + const alreadyPatched = content.includes( + 'typeof wasmModuleOrExports === "function"', + ); + if (alreadyPatched) { + return content; + } - const bunBranchPattern = /}\s*else if\s*\(\s*(['"])Bun\1\s+in\s+globalThis\s*\)\s*\{/; - if (!bunBranchPattern.test(content)) { - throw new Error( - `Could not locate Bun branch while patching ${filePath}`, - ); - } + const bunBranchPattern = + /}\s*else if\s*\(\s*(['"])Bun\1\s+in\s+globalThis\s*\)\s*\{/; + if (!bunBranchPattern.test(content)) { + throw new Error(`Could not locate Bun branch while patching ${filePath}`); + } - const base64Branch = `} else if (typeof wasmModuleOrExports === "function") { + const base64Branch = `} else if (typeof wasmModuleOrExports === "function") { const moduleOrInstance = wasmModuleOrExports({ "./loro_wasm_bg.js": imports, }); @@ -111,27 +182,27 @@ function injectBase64WasmBranch(content: string, filePath: string): string { finalize(instance.exports ?? instance); } else if ("Bun" in globalThis) {`; - return content.replace(bunBranchPattern, base64Branch); + return content.replace(bunBranchPattern, base64Branch); } function simplifyBase64WasmInitialization( - content: string, - filePath: string, + content: string, + filePath: string, ): string { - const startMarker = - "// Normalize how bundlers expose the wasm module/exports."; - const endMarker = `\n\n/** + 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 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 replacement = `// Instantiate the inlined base64 wasm synchronously. const wasmModuleOrInstance = rawWasm.default({ "./loro_wasm_bg.js": imports, }); @@ -146,44 +217,44 @@ if (typeof imports.__wbindgen_start === "function") { imports.__wbindgen_start(); }`; - return content.slice(0, start) + replacement + content.slice(end); + return content.slice(0, start) + replacement + content.slice(end); } function patchBase64NodeRequires(content: string, filePath: string): string { - const directRequires = `var fs = require("fs"); + const directRequires = `var fs = require("fs"); var path = require("path");`; - const indirectRequires = `var nodeRequire = typeof require === "function" ? require : null; + const indirectRequires = `var nodeRequire = typeof require === "function" ? require : null; var fs = nodeRequire && nodeRequire("fs"); var path = nodeRequire && nodeRequire("path");`; - const browserSafeRequires = `var fs = null; + const browserSafeRequires = `var fs = null; var path = null;`; - if (content.includes(browserSafeRequires)) { - return content; - } + if (content.includes(browserSafeRequires)) { + return content; + } - if (content.includes(directRequires)) { - return content.replace(directRequires, browserSafeRequires); - } + if (content.includes(directRequires)) { + return content.replace(directRequires, browserSafeRequires); + } - if (content.includes(indirectRequires)) { - return content.replace(indirectRequires, browserSafeRequires); - } + if (content.includes(indirectRequires)) { + return content.replace(indirectRequires, browserSafeRequires); + } - throw new Error( - `Could not locate Node require block while patching ${filePath}`, - ); + 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); - } + for (const dir of DIRS_TO_SCAN) { + await transform(dir); + } - await rollupBase64(); - await transform("./base64"); + await rollupBase64(); + await transform("./base64"); } if (import.meta.main) { - main(); + main(); } From ceb78ffaa4b4e9dc6934ea37683179ded2bbc37b Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 25 May 2026 17:03:56 +0000 Subject: [PATCH 2/3] test: add browser bundler runtime smoke --- crates/loro-wasm/AGENTS.md | 1 + examples/bundler-smoke-tests/README.md | 9 + examples/bundler-smoke-tests/package.json | 5 + .../scripts/browser-runtime.mjs | 354 ++++++++++++++++++ examples/bundler-smoke-tests/scripts/run.mjs | 46 ++- pnpm-lock.yaml | 6 +- 6 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 crates/loro-wasm/AGENTS.md create mode 100644 examples/bundler-smoke-tests/scripts/browser-runtime.mjs diff --git a/crates/loro-wasm/AGENTS.md b/crates/loro-wasm/AGENTS.md new file mode 100644 index 000000000..81e5a1bf4 --- /dev/null +++ b/crates/loro-wasm/AGENTS.md @@ -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. diff --git a/examples/bundler-smoke-tests/README.md b/examples/bundler-smoke-tests/README.md index 05b01b9cd..ba507eb1f 100644 --- a/examples/bundler-smoke-tests/README.md +++ b/examples/bundler-smoke-tests/README.md @@ -28,6 +28,15 @@ Run Next.js/Turbopack separately because it is heavier: pnpm --dir examples/bundler-smoke-tests run test:next ``` +To also launch each built app in Chromium and verify `doc.toJSON()` returns +`{ t: "hi" }` in a real browser: + +```sh +pnpm --dir examples/bundler-smoke-tests run test:browser +``` + +This command installs Playwright's Chromium browser if needed. + To test an already-published package instead of the local workspace build: ```sh diff --git a/examples/bundler-smoke-tests/package.json b/examples/bundler-smoke-tests/package.json index 4aa9269d8..70cc3574c 100644 --- a/examples/bundler-smoke-tests/package.json +++ b/examples/bundler-smoke-tests/package.json @@ -5,8 +5,13 @@ "type": "module", "scripts": { "test": "node ./scripts/run.mjs", + "pretest:browser": "playwright install chromium", + "test:browser": "LORO_SMOKE_MODE=json node ./scripts/run.mjs && LORO_SMOKE_MODE=json node ./scripts/browser-runtime.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" + }, + "devDependencies": { + "playwright": "^1.40.0" } } diff --git a/examples/bundler-smoke-tests/scripts/browser-runtime.mjs b/examples/bundler-smoke-tests/scripts/browser-runtime.mjs new file mode 100644 index 000000000..b3ae802d9 --- /dev/null +++ b/examples/bundler-smoke-tests/scripts/browser-runtime.mjs @@ -0,0 +1,354 @@ +import { spawn } from "node:child_process"; +import { createReadStream, existsSync } from "node:fs"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.resolve(__dirname, ".."); +const tmpRoot = path.join(packageDir, ".tmp"); + +const defaultCases = [ + "vite5", + "vite6", + "vite7", + "vite8", + "rolldown-vite", + "webpack5", + "rsbuild2", + "rspack2", + "esbuild-default-copy", + "esbuild-base64", + "rollup-default-copy", + "rollup-base64", + "parcel2", + "next16-turbopack", + "next16-webpack", +]; + +const mimeTypes = new Map([ + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".mjs", "text/javascript; charset=utf-8"], + [".css", "text/css; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".map", "application/json; charset=utf-8"], + [".wasm", "application/wasm"], +]); + +function findFreePort(start) { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", (error) => { + if (error.code === "EADDRINUSE") { + findFreePort(start + 1).then(resolve, reject); + } else { + reject(error); + } + }); + server.listen(start, "127.0.0.1", () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +async function findEntrypoint(distDir) { + const preferred = ["index.html", "bundle.js", "main.js", "index.js"]; + for (const name of preferred) { + const file = path.join(distDir, name); + if (existsSync(file)) { + return name; + } + } + + const entries = await readdir(distDir); + const js = entries.find((entry) => entry.endsWith(".js")); + if (js) { + return js; + } + + throw new Error(`No browser entrypoint found in ${distDir}`); +} + +function startStaticServer(distDir, port, entrypoint) { + const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`); + let pathname = decodeURIComponent(url.pathname); + + if (pathname === "/favicon.ico") { + response.writeHead(204); + response.end(); + return; + } + + if (pathname === "/") { + if (existsSync(path.join(distDir, "index.html"))) { + pathname = "/index.html"; + } else { + const isModule = entrypoint !== "bundle.js"; + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + }); + response.end( + `
`, + ); + return; + } + } + + const target = path.normalize(path.join(distDir, pathname)); + if (!target.startsWith(distDir + path.sep) && target !== distDir) { + response.writeHead(403); + response.end("Forbidden"); + return; + } + + if (!existsSync(target) || !(await stat(target)).isFile()) { + response.writeHead(404); + response.end("Not found"); + return; + } + + const contentType = + mimeTypes.get(path.extname(target)) ?? "application/octet-stream"; + response.writeHead(200, { "content-type": contentType }); + + if (path.basename(target) === "index.html") { + const html = await readFile(target, "utf8"); + response.end( + html.replace( + /<\/script>\s*/g, + "", + ), + ); + return; + } + + createReadStream(target).pipe(response); + } catch (error) { + response.writeHead(500); + response.end(String(error.stack ?? error)); + } + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} + +async function waitForHttp(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastError; + + while (Date.now() < deadline) { + try { + const response = await fetch(url); + if (response.status < 500) { + return; + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw lastError ?? new Error(`Timed out waiting for ${url}`); +} + +async function startNext(caseDir, port) { + const child = spawn( + "pnpm", + ["exec", "next", "start", "-H", "127.0.0.1", "-p", String(port)], + { + cwd: caseDir, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" }, + }, + ); + + const logs = []; + const collect = (chunk) => logs.push(chunk.toString()); + child.stdout.on("data", collect); + child.stderr.on("data", collect); + + let exited = false; + child.once("exit", (code, signal) => { + exited = true; + logs.push(`\n[next exited code=${code} signal=${signal}]\n`); + }); + + await waitForHttp(`http://127.0.0.1:${port}/`, 60_000).catch((error) => { + if (exited) { + throw new Error(`next start exited early:\n${logs.join("")}`); + } + throw new Error(`${error.message}\n${logs.join("")}`); + }); + + return { + stop: async () => { + if (!exited) { + child.kill("SIGTERM"); + await new Promise((resolve) => child.once("exit", resolve)); + } + }, + }; +} + +async function verifyPage(browser, name, url) { + const page = await browser.newPage(); + const pageErrors = []; + const consoleErrors = []; + + page.on("pageerror", (error) => + pageErrors.push(error.stack ?? error.message), + ); + page.on("console", (message) => { + if (message.type() === "error") { + consoleErrors.push(message.text()); + } + }); + + try { + const response = await page.goto(url, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + if (!response || !response.ok()) { + throw new Error( + `${name}: navigation failed with ${response?.status() ?? "no response"}`, + ); + } + + await page.waitForFunction( + () => { + const value = globalThis.__LORO_JSON_SMOKE__; + return value?.t === "hi" && Object.keys(value).length === 1; + }, + null, + { timeout: 60_000 }, + ); + await page.waitForTimeout(250); + + const bodyText = (await page.locator("body").innerText()) + .trim() + .replace(/\s+/g, " "); + const jsonSmokeValue = await page.evaluate( + () => globalThis.__LORO_JSON_SMOKE__ ?? null, + ); + + if (pageErrors.length || consoleErrors.length) { + throw new Error( + `${name}: browser errors\npageErrors=${JSON.stringify( + pageErrors, + null, + 2, + )}\nconsoleErrors=${JSON.stringify(consoleErrors, null, 2)}`, + ); + } + + if (JSON.stringify(jsonSmokeValue) !== JSON.stringify({ t: "hi" })) { + throw new Error( + `${name}: unexpected JSON smoke value ${JSON.stringify(jsonSmokeValue)}`, + ); + } + + if (!bodyText.includes('{"t":"hi"}')) { + throw new Error( + `${name}: expected body to include {"t":"hi"}, got ${JSON.stringify( + bodyText, + )}`, + ); + } + + return { bodyText, jsonSmokeValue }; + } catch (error) { + const bodyText = await page + .locator("body") + .innerText() + .catch(() => ""); + throw new Error( + `${name}: ${error.message}\nbody=${JSON.stringify( + bodyText.trim().replace(/\s+/g, " "), + )}\npageErrors=${JSON.stringify( + pageErrors, + null, + 2, + )}\nconsoleErrors=${JSON.stringify(consoleErrors, null, 2)}`, + ); + } finally { + await page.close(); + } +} + +async function runCase(browser, name, index) { + const caseDir = path.join(tmpRoot, name); + if (!existsSync(caseDir)) { + throw new Error( + `Missing generated case ${name}. Run \`pnpm --dir examples/bundler-smoke-tests run test:browser\` from the repo root.`, + ); + } + + const port = await findFreePort(43100 + index * 10); + const url = `http://127.0.0.1:${port}/`; + + if (name.startsWith("next")) { + const server = await startNext(caseDir, port); + try { + const result = await verifyPage(browser, name, url); + return { name, mode: "next start", ...result }; + } finally { + await server.stop(); + } + } + + const distDir = path.join(caseDir, "dist"); + const entrypoint = await findEntrypoint(distDir); + const server = await startStaticServer(distDir, port, entrypoint); + try { + const result = await verifyPage(browser, name, url); + return { name, mode: `static ${entrypoint}`, ...result }; + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +async function main() { + const selected = process.argv.slice(2); + const cases = selected.length > 0 ? selected : defaultCases; + const unknown = cases.filter((name) => !defaultCases.includes(name)); + if (unknown.length > 0) { + throw new Error(`Unknown browser case(s): ${unknown.join(", ")}`); + } + + const browser = await chromium.launch(); + const results = []; + + try { + for (const [index, name] of cases.entries()) { + const result = await runCase(browser, name, index); + results.push(result); + console.log( + `ok ${result.name.padEnd(20)} ${result.mode.padEnd( + 18, + )} body=${JSON.stringify(result.bodyText)}`, + ); + } + } finally { + await browser.close(); + } + + console.log(JSON.stringify(results, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/examples/bundler-smoke-tests/scripts/run.mjs b/examples/bundler-smoke-tests/scripts/run.mjs index 3b00ebff0..234975961 100644 --- a/examples/bundler-smoke-tests/scripts/run.mjs +++ b/examples/bundler-smoke-tests/scripts/run.mjs @@ -21,6 +21,7 @@ const localLoroPackage = path.join(repoRoot, "crates/loro-wasm"); const loroPackageSpec = normalizeLoroPackageSpec( process.env.LORO_SMOKE_PACKAGE ?? `file:${localLoroPackage}`, ); +const smokeMode = process.env.LORO_SMOKE_MODE ?? "default"; function normalizeLoroPackageSpec(spec) { if (spec === "loro-crdt" || spec.startsWith("loro-crdt@")) { @@ -30,7 +31,28 @@ function normalizeLoroPackageSpec(spec) { return spec; } -const sharedApp = (importPath) => `import { LoroDoc } from "${importPath}"; +const sharedApp = (importPath) => { + if (smokeMode === "json") { + return `import { LoroDoc } from "${importPath}"; + +const doc = new LoroDoc(); +doc.getText("t").insert(0, "hi"); +const value = doc.toJSON(); + +if (value.t !== "hi" || Object.keys(value).length !== 1) { + throw new Error(\`Unexpected Loro JSON: \${JSON.stringify(value)}\`); +} + +console.log(value); +globalThis.__LORO_JSON_SMOKE__ = value; +const app = document.getElementById("app"); +if (app) { + app.textContent = JSON.stringify(value); +} +`; + } + + return `import { LoroDoc } from "${importPath}"; const doc = new LoroDoc(); const text = doc.getText("text"); @@ -47,6 +69,7 @@ if (app) { app.textContent = value; } `; +}; const html = ` @@ -332,7 +355,26 @@ export default function Page() { ); await writeFile( path.join(dir, "components/Smoke.jsx"), - `"use client"; + smokeMode === "json" + ? `"use client"; + +import { LoroDoc } from "${importPath}"; + +export default function Smoke() { + const doc = new LoroDoc(); + doc.getText("t").insert(0, "hi"); + const value = doc.toJSON(); + + if (value.t !== "hi" || Object.keys(value).length !== 1) { + throw new Error(\`Unexpected Loro JSON: \${JSON.stringify(value)}\`); + } + + console.log(value); + globalThis.__LORO_JSON_SMOKE__ = value; + return
{JSON.stringify(value)}
; +} +` + : `"use client"; import { LoroDoc } from "${importPath}"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cf76af76..1cd2bfee0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,7 +89,11 @@ importers: crates/loro-wasm-map: {} - examples/bundler-smoke-tests: {} + examples/bundler-smoke-tests: + devDependencies: + playwright: + specifier: ^1.40.0 + version: 1.56.1 examples/loro-quill: dependencies: From e5f3959481401eff1fe08aca497c14141a81db3e Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 25 May 2026 17:43:55 +0000 Subject: [PATCH 3/3] fix: keep source map comments last --- crates/loro-wasm/scripts/post-rollup.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/loro-wasm/scripts/post-rollup.ts b/crates/loro-wasm/scripts/post-rollup.ts index b41056a6d..4849c42bf 100644 --- a/crates/loro-wasm/scripts/post-rollup.ts +++ b/crates/loro-wasm/scripts/post-rollup.ts @@ -62,9 +62,13 @@ async function injectExplicitWasmReexports( const explicitExports = names.map((name) => ` ${name},`).join("\n"); const block = `// ${marker}\nexport {\n${explicitExports}\n} from "${target}";`; - const sourceMapPattern = /\n\/\/# sourceMappingURL=.*$/; + const sourceMapPattern = /(\n\/\/# sourceMappingURL=.*?)(\s*)$/; if (sourceMapPattern.test(content)) { - return content.replace(sourceMapPattern, `\n${block}$&`); + return content.replace( + sourceMapPattern, + (_match, sourceMapComment: string, trailingWhitespace: string) => + `\n${block}${sourceMapComment}${trailingWhitespace}`, + ); } return `${content}\n${block}\n`;