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 [![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) 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 = ` + + + + Loro bundler smoke + + +
+ + + +`; + +const cases = { + vite5: viteCase("vite5", "vite", "^5.4.21"), + vite6: viteCase("vite6", "vite", "^6.4.2"), + vite7: viteCase("vite7", "vite", "^7.3.2"), + vite8: viteCase("vite8", "vite", "^8.0.10"), + "rolldown-vite": viteCase("rolldown-vite", "rolldown-vite", "^7.3.1"), + webpack5: { + description: "Webpack 5 production build", + dependencies: {}, + devDependencies: { + webpack: "^5.106.2", + "webpack-cli": "^6.0.1", + }, + setup: setupWebpack, + command: ["pnpm", "exec", "webpack", "--config", "webpack.config.cjs"], + inspect: { dir: "dist", wasmAsset: true, noWasmWrapper: true }, + }, + rsbuild2: { + description: "Rsbuild 2 production build", + dependencies: {}, + devDependencies: { + "@rsbuild/core": "^2.0.3", + }, + setup: setupRsbuild, + command: ["pnpm", "exec", "rsbuild", "build"], + inspect: { dir: "dist", wasmAsset: true, noWasmWrapper: true }, + }, + rspack2: { + description: "Rspack 2 production build", + dependencies: {}, + devDependencies: { + "@rspack/cli": "^2.0.1", + "@rspack/core": "^2.0.1", + }, + setup: setupRspack, + command: ["pnpm", "exec", "rspack", "build", "--config", "rspack.config.cjs"], + inspect: { dir: "dist", wasmAsset: true, noWasmWrapper: true }, + }, + parcel2: { + description: "Parcel 2 production build", + dependencies: {}, + devDependencies: { + parcel: "^2.16.4", + }, + setup: setupParcel, + command: [ + "pnpm", + "exec", + "parcel", + "build", + "index.html", + "--dist-dir", + "dist", + "--no-cache", + ], + inspect: { dir: "dist", wasmAsset: true, noWasmWrapper: true }, + }, + "esbuild-default-copy": { + description: "esbuild browser build with explicit WASM copy", + dependencies: {}, + devDependencies: { + esbuild: "^0.28.0", + }, + setup: (dir) => setupBasic(dir, "loro-crdt"), + command: [ + "pnpm", + "exec", + "esbuild", + "src/main.js", + "--bundle", + "--format=esm", + "--platform=browser", + "--outdir=dist", + ], + afterBuild: (dir) => copyBrowserWasm(dir, "dist/loro_wasm_bg.wasm"), + inspect: { dir: "dist", wasmAsset: true, noWasmWrapper: true }, + }, + "esbuild-base64": { + description: "esbuild browser build with base64 entry", + dependencies: {}, + devDependencies: { + esbuild: "^0.28.0", + }, + setup: (dir) => setupBasic(dir, "loro-crdt/base64"), + command: [ + "pnpm", + "exec", + "esbuild", + "src/main.js", + "--bundle", + "--format=esm", + "--platform=browser", + "--outdir=dist", + ], + inspect: { dir: "dist", noWasmAsset: true, noWasmWrapper: true }, + }, + "rollup-default-copy": { + description: "Rollup 4 browser build with explicit WASM copy", + dependencies: {}, + devDependencies: { + rollup: "^4.60.2", + "@rollup/plugin-node-resolve": "^16.0.3", + }, + setup: (dir) => setupRollup(dir, "loro-crdt"), + command: ["pnpm", "exec", "rollup", "-c", "rollup.config.mjs"], + afterBuild: (dir) => copyBrowserWasm(dir, "dist/loro_wasm_bg.wasm"), + inspect: { dir: "dist", wasmAsset: true, noWasmWrapper: true }, + }, + "rollup-base64": { + description: "Rollup 4 browser build with base64 entry", + dependencies: {}, + devDependencies: { + rollup: "^4.60.2", + "@rollup/plugin-node-resolve": "^16.0.3", + }, + setup: (dir) => setupRollup(dir, "loro-crdt/base64"), + command: ["pnpm", "exec", "rollup", "-c", "rollup.config.mjs"], + inspect: { dir: "dist", noWasmAsset: true, noWasmWrapper: true }, + }, + "next16-turbopack": { + description: "Next 16 production build with default Turbopack", + dependencies: { + next: "^16.2.4", + react: "^19.2.1", + "react-dom": "^19.2.1", + }, + devDependencies: {}, + setup: setupNext, + command: ["pnpm", "exec", "next", "build"], + inspect: { dir: ".next", noWasmWrapper: true }, + }, + "next16-webpack": { + description: "Next 16 production build with Webpack and base64 entry", + dependencies: { + next: "^16.2.4", + react: "^19.2.1", + "react-dom": "^19.2.1", + }, + devDependencies: {}, + setup: (dir) => setupNext(dir, "loro-crdt/base64"), + command: ["pnpm", "exec", "next", "build", "--webpack"], + inspect: { dir: ".next", noWasmAsset: true, noWasmWrapper: true }, + }, +}; + +function viteCase(name, packageName, version) { + return { + description: `${name} production build`, + dependencies: {}, + devDependencies: { + [packageName]: version, + typescript: "^5.9.3", + }, + setup: setupVite, + command: ["pnpm", "exec", "vite", "build"], + inspect: { dir: "dist", wasmAsset: true, noWasmWrapper: true }, + }; +} + +async function setupBasic(dir, importPath) { + await mkdir(path.join(dir, "src"), { recursive: true }); + await writeFile(path.join(dir, "index.html"), html); + await writeFile(path.join(dir, "src/main.js"), sharedApp(importPath)); +} + +async function setupVite(dir) { + await setupBasic(dir, "loro-crdt"); + await writeFile( + path.join(dir, "vite.config.js"), + `export default { build: { outDir: "dist" } };\n`, + ); +} + +async function setupWebpack(dir) { + await setupBasic(dir, "loro-crdt"); + await writeFile( + path.join(dir, "webpack.config.cjs"), + `const path = require("node:path"); + +module.exports = { + mode: "production", + entry: "./src/main.js", + output: { + path: path.resolve(__dirname, "dist"), + filename: "bundle.js", + clean: true, + }, +}; +`, + ); +} + +async function setupRspack(dir) { + await setupBasic(dir, "loro-crdt"); + await writeFile( + path.join(dir, "rspack.config.cjs"), + `const path = require("node:path"); + +module.exports = { + mode: "production", + entry: "./src/main.js", + output: { + path: path.resolve(__dirname, "dist"), + filename: "bundle.js", + clean: true, + }, +}; +`, + ); +} + +async function setupRsbuild(dir) { + await setupBasic(dir, "loro-crdt"); + await writeFile( + path.join(dir, "rsbuild.config.mjs"), + `export default { + source: { entry: { index: "./src/main.js" } }, + html: { template: "./index.html" }, + output: { distPath: { root: "dist" } }, +}; +`, + ); +} + +async function setupParcel(dir) { + await mkdir(path.join(dir, "src"), { recursive: true }); + await writeFile( + path.join(dir, "index.html"), + html.replace("/src/main.js", "./src/main.js"), + ); + await writeFile(path.join(dir, "src/main.js"), sharedApp("loro-crdt")); +} + +async function setupRollup(dir, importPath) { + await setupBasic(dir, importPath); + await writeFile( + path.join(dir, "rollup.config.mjs"), + `import nodeResolve from "@rollup/plugin-node-resolve"; + +export default { + input: "./src/main.js", + output: { dir: "dist", format: "esm" }, + plugins: [nodeResolve({ browser: true })], +}; +`, + ); +} + +async function setupNext(dir, importPath = "loro-crdt") { + await mkdir(path.join(dir, "app"), { recursive: true }); + await mkdir(path.join(dir, "components"), { recursive: true }); + await writeFile( + path.join(dir, "app/layout.jsx"), + `export default function RootLayout({ children }) { + return {children}; +} +`, + ); + await writeFile( + path.join(dir, "app/page.jsx"), + `"use client"; + +import Smoke from "../components/Smoke.jsx"; + +export default function Page() { + return ; +} +`, + ); + await writeFile( + path.join(dir, "components/Smoke.jsx"), + `"use client"; + +import { LoroDoc } from "${importPath}"; + +export default function Smoke() { + const doc = new LoroDoc(); + const text = doc.getText("text"); + text.insert(0, "bundler-smoke"); + return
{doc.toJSON().text}
; +} +`, + ); + await writeFile( + path.join(dir, "next.config.mjs"), + `export default { + typescript: { ignoreBuildErrors: true }, +}; +`, + ); +} + +async function copyBrowserWasm(dir, outputRelativePath) { + const source = path.join( + dir, + "node_modules/loro-crdt/browser/loro_wasm_bg.wasm", + ); + const target = path.join(dir, outputRelativePath); + await mkdir(path.dirname(target), { recursive: true }); + await cp(source, target); +} + +async function writePackageJson(dir, testCase) { + const pkg = { + private: true, + type: "module", + scripts: {}, + dependencies: { + "loro-crdt": loroPackageSpec, + ...testCase.dependencies, + }, + devDependencies: testCase.devDependencies, + }; + + await writeFile( + path.join(dir, "package.json"), + `${JSON.stringify(pkg, null, 2)}\n`, + ); +} + +async function runCommand(command, cwd) { + const [bin, ...args] = command; + try { + const { stdout, stderr } = await execFile(bin, args, { + cwd, + env: { + ...process.env, + NEXT_TELEMETRY_DISABLED: "1", + }, + maxBuffer: 1024 * 1024 * 16, + }); + if (stdout.trim()) { + process.stdout.write(stdout); + } + if (stderr.trim()) { + process.stderr.write(stderr); + } + } catch (error) { + if (error.stdout) { + process.stdout.write(error.stdout); + } + if (error.stderr) { + process.stderr.write(error.stderr); + } + throw error; + } +} + +async function install(dir) { + await runCommand( + ["pnpm", "install", "--ignore-workspace", "--prefer-offline"], + dir, + ); +} + +async function runCase(name, testCase) { + const dir = path.join(tmpRoot, name); + await rm(dir, { recursive: true, force: true }); + await mkdir(dir, { recursive: true }); + await writePackageJson(dir, testCase); + await testCase.setup(dir); + + console.log(`\n[${name}] ${testCase.description}`); + await install(dir); + await runCommand(testCase.command, dir); + if (testCase.afterBuild) { + await testCase.afterBuild(dir); + } + await inspectOutput(dir, testCase.inspect); + console.log(`[${name}] ok`); +} + +async function inspectOutput(dir, inspect) { + const outputDir = path.join(dir, inspect.dir); + await assertExists(outputDir, `Expected output directory ${inspect.dir}`); + const files = await listFiles(outputDir); + const wasmFiles = files.filter((file) => file.endsWith(".wasm")); + const wasmWrapperFiles = files.filter((file) => + /(^|[/\\])loro_wasm_bg(?:[-.][^/\\]+)?\.js$/.test(file) + ); + + if (inspect.wasmAsset && wasmFiles.length === 0) { + throw new Error(`Expected a emitted .wasm asset in ${inspect.dir}`); + } + + if (inspect.noWasmAsset && wasmFiles.length > 0) { + throw new Error( + `Did not expect .wasm assets in ${inspect.dir}: ${wasmFiles.join(", ")}`, + ); + } + + if (inspect.noWasmWrapper && wasmWrapperFiles.length > 0) { + throw new Error( + `Unexpected wasm wrapper chunk(s): ${wasmWrapperFiles.join(", ")}`, + ); + } +} + +async function listFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await listFiles(fullPath)); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + return files; +} + +async function assertExists(filePath, message) { + try { + await stat(filePath); + } catch { + throw new Error(message); + } +} + +async function ensureLocalPackageArtifacts() { + if (process.env.LORO_SMOKE_PACKAGE) { + return; + } + + const required = [ + "bundler/index.js", + "browser/loro_wasm.js", + "browser/loro_wasm_bg.wasm", + "base64/index.js", + "nodejs/index.js", + "web/index.js", + ]; + + const missing = []; + for (const file of required) { + try { + await stat(path.join(localLoroPackage, file)); + } catch { + missing.push(file); + } + } + + if (missing.length > 0) { + throw new Error( + `Local loro-crdt build artifacts are missing: ${missing.join(", ")}.\n` + + "Run `pnpm release-wasm` first, or set LORO_SMOKE_PACKAGE to a published package spec.", + ); + } +} + +function listCases() { + for (const [name, testCase] of Object.entries(cases)) { + console.log(`${name.padEnd(22)} ${testCase.description}`); + } +} + +async function main() { + const args = process.argv.slice(2); + if (args.includes("--list")) { + listCases(); + return; + } + + await ensureLocalPackageArtifacts(); + const selected = args.length > 0 ? args : Object.keys(cases); + const unknown = selected.filter((name) => !cases[name]); + if (unknown.length > 0) { + throw new Error(`Unknown smoke case(s): ${unknown.join(", ")}`); + } + + for (const name of selected) { + await runCase(name, cases[name]); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/package.json b/package.json index f98d97157..44da89fa6 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "check": "cargo clippy --all-features -- -Dwarnings", "test-rsbuild": "pnpm --dir examples/test-rsbuild install --ignore-workspace --prefer-offline && pnpm --filter loro-test-rsbuild build", "test-esbuild": "pnpm --dir examples/test-esbuild install --ignore-workspace --prefer-offline && pnpm --filter loro-test-esbuild serve", + "test-bundlers": "pnpm --dir examples/bundler-smoke-tests run test:fast", + "test-bundlers-next": "pnpm --dir examples/bundler-smoke-tests run test:next", "run-fuzz-corpus": "cd crates/fuzz && cargo +nightly fuzz run all -- -max_total_time=1", "fix": "cargo clippy --fix --features=test_utils", "vet": "cargo vet", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8f557d66b..5ac76ac3b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,6 @@ packages: - "crates/loro-wasm" - "crates/loro-wasm-map" - "examples/loro-quill" + - "examples/bundler-smoke-tests" - "examples/test-rsbuild" - "examples/test-esbuild"