| keywords | loro-crdt, build collaboration software, local-first, operation transform, crdts, ot |
|---|---|
| description | How to use Loro to build real-time or asynchronous collaboration software. |
You can use Loro in your application by using:
loro-crdtNPM packageloroRust crateloro-swiftSwift packageloro-pyPython packageloro-csCommunity-maintained C# package- You can also find a list of examples in Loro examples in Deno.
loro-goCommunity-maintained Go packageloro-goCommunity-maintained pure-Go package (no cgo)
You can use Loro Inspector to debug and visualize the state and history of Loro documents.
The following guide will use loro-crdt js package as the example.
npm install loro-crdt
# Or
pnpm install loro-crdt
# Or
yarn add loro-crdtIf you're using Vite, you should add the following to your vite.config.ts:
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
export default defineConfig({
plugins: [...otherConfigures, wasm(), topLevelAwait()],
});⚠️ DOMContentLoaded Timing Issue with Vite
When using Loro with Vite, be aware of module loading timing issues with DOM events:
Problem: The following code will cause nothing to load on the screen:
import { LoroDoc } from "loro-crdt";
document.addEventListener("DOMContentLoaded", () => {
const doc = new LoroDoc();
// Your code here...
});Reason: This occurs because Vite loads ES modules asynchronously, and the WASM module initialization within loro-crdt also happens asynchronously. When you import at the top level but execute code inside DOMContentLoaded, the WASM module may not be fully initialized when the event fires, causing the application to fail silently.
Solutions:
-
Remove the event listener (recommended for most cases):
import { LoroDoc } from "loro-crdt"; const doc = new LoroDoc(); // Your code here...
-
Use dynamic import inside the event listener:
document.addEventListener("DOMContentLoaded", async () => { const { LoroDoc } = await import("loro-crdt"); const doc = new LoroDoc(); // Your code here... });
The dynamic import ensures the module and its WASM dependencies are fully loaded before use.
If you're using Next.js, you should add the following to your next.config.js:
module.exports = {
webpack: function (config) {
config.experiments = {
layers: true,
asyncWebAssembly: true,
};
return config;
},
};You can also use Loro directly in the browser via ESM imports. Here's a minimal example:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESM Module Example</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import init, {
LoroDoc,
} from "https://cdn.jsdelivr.net/npm/loro-crdt@1.13.3/web/index.js";
init().then(() => {
const doc = new LoroDoc();
const text = doc.getText("text");
});
</script>
</body>
</html>It is well-known that syncing data/building realtime collaborative apps is challenging, especially when devices can be offline or part of a peer-to-peer network. Loro simplifies this process for you.
After you model your app state by Loro, syncing is simple:
import { LoroDoc } from "loro-crdt";
const docA = new LoroDoc();
const docB = new LoroDoc();
//...operations on docA and docB
// Assume docA and docB are two Loro documents in two different devices
const bytesA = docA.export({ mode: "update" });
// send bytes to docB by any method
docB.import(bytesA);
// docB is now updated with all the changes from docA
const bytesB = docB.export({ mode: "update" });
// send bytes to docA by any method
docA.import(bytesB);
// docA and docB are now in sync, they have the same stateSaving your app state is also straightforward:
import { LoroDoc } from "loro-crdt";
// ---cut---
const doc = new LoroDoc();
doc.getText("text").insert(0, "Hello world!");
const bytes = doc.export({ mode: "snapshot" });
// Bytes can be saved to local storage, database, or sent over the networkSnapshots and updates include a checksum in their headers, so any corruption from storage or transmission (like bit flips) is detected during import before it can affect your document.
Loading your app state:
import { LoroDoc } from "loro-crdt";
const bytes = new Uint8Array();
// ---cut---
const newDoc = new LoroDoc();
newDoc.import(bytes);Loro also makes it easy for you to time travel the history and add version control to your app. Learn more about time travel.
import { LoroDoc } from "loro-crdt";
const doc = new LoroDoc();
const version = doc.frontiers();
// ---cut---
doc.checkout(version); // Checkout the doc to the given versionLoro is compatible with the JSON schema. If you can model your app state with JSON, you probably can sync your app with Loro. Because we need to adhere to the JSON schema, using a number as a key in a Map is not permitted, and cyclic links should be avoided.
import { LoroDoc } from "loro-crdt";
const doc = new LoroDoc();
// ---cut---
doc.toJSON(); // Get the JSON representation of the docLoroDoc is the entry point for using Loro. You must create a Doc to use Map, List, Text, and other types and to complete data synchronization.
import { LoroDoc, LoroText } from "loro-crdt";
// ---cut---
const doc = new LoroDoc();
const text: LoroText = doc.getText("text");
text.insert(0, "Hello world!");
console.log(doc.toJSON()); // { "text": "Hello world!" }We refer to CRDT types such as List, Map, Tree, MovableList, and Text
as Containers.
Here are their basic operations:
import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt";
import { expect } from "expect";
// ---cut---
const doc = new LoroDoc();
const list: LoroList = doc.getList("list");
list.insert(0, "A");
list.insert(1, "B");
list.insert(2, "C");
const map: LoroMap = doc.getMap("map");
// map can only has string key
map.set("key", "value");
expect(doc.toJSON()).toStrictEqual({
list: ["A", "B", "C"],
map: { key: "value" },
});
// delete 2 element at index 0
list.delete(0, 2);
expect(doc.toJSON()).toStrictEqual({
list: ["C"],
map: { key: "value" },
});
// Insert a text container to the list
const text = list.insertContainer(0, new LoroText());
text.insert(0, "Hello");
text.insert(0, "Hi! ");
expect(doc.toJSON()).toStrictEqual({
list: ["Hi! Hello", "C"],
map: { key: "value" },
});
// Insert a list container to the map
const list2 = map.setContainer("test", new LoroList());
list2.insert(0, 1);
expect(doc.toJSON()).toStrictEqual({
list: ["Hi! Hello", "C"],
map: { key: "value", test: [1] },
});Loro is a pure library and does not handle network protocols or storage mechanisms. It is your responsibility to manage the storage and transmission of the binary data exported by Loro.
To save the document, use doc.export({mode: "snapshot"}) to get its binary
form. To open it again, use doc.import(data) to load this binary data.
import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt";
import { expect } from "expect";
// ---cut---
const doc = new LoroDoc();
doc.getText("text").insert(0, "Hello world!");
const data = doc.export({ mode: "snapshot" });
const newDoc = new LoroDoc();
newDoc.import(data);
expect(newDoc.toJSON()).toStrictEqual({
text: "Hello world!",
});Exporting the entire document on each keypress is inefficient. Instead, use
doc.export({mode: "update", from: VersionVector}) to obtain binary data for
operations since the last export.
import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt";
import { expect } from "expect";
// ---cut---
const doc = new LoroDoc();
doc.getText("text").insert(0, "Hello world!");
const data = doc.export({ mode: "snapshot" });
let lastSavedVersion = doc.version();
doc.getText("text").insert(0, "✨");
const update0 = doc.export({ mode: "update", from: lastSavedVersion });
lastSavedVersion = doc.version();
doc.getText("text").insert(0, "😶🌫️");
const update1 = doc.export({ mode: "update", from: lastSavedVersion });
{
/**
* You can import the snapshot and the updates to get the latest version of the document.
*/
// import the snapshot
const newDoc = new LoroDoc();
newDoc.import(data);
expect(newDoc.toJSON()).toStrictEqual({
text: "Hello world!",
});
// import update0
newDoc.import(update0);
expect(newDoc.toJSON()).toStrictEqual({
text: "✨Hello world!",
});
// import update1
newDoc.import(update1);
expect(newDoc.toJSON()).toStrictEqual({
text: "😶🌫️✨Hello world!",
});
}
{
/**
* You may also import them in a batch
*/
const newDoc = new LoroDoc();
newDoc.importUpdateBatch([update1, update0, data]);
expect(newDoc.toJSON()).toStrictEqual({
text: "😶🌫️✨Hello world!",
});
}If updates accumulate, exporting a new snapshot can quicken import times and decrease the overall size of the exported data.
You can store the binary data exported from Loro wherever you prefer.
Two documents with concurrent edits can be synchronized by just two message exchanges.
Below is an example of synchronization between two documents:
import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt";
import { expect } from "expect";
// ---cut---
const docA = new LoroDoc();
const docB = new LoroDoc();
const listA: LoroList = docA.getList("list");
listA.insert(0, "A");
listA.insert(1, "B");
listA.insert(2, "C");
// B import the ops from A
const data: Uint8Array = docA.export({ mode: "update" });
// The data can be sent to B through the network
docB.import(data);
expect(docB.toJSON()).toStrictEqual({
list: ["A", "B", "C"],
});
const listB: LoroList = docB.getList("list");
listB.delete(1, 1);
// `doc.export({mode: "update", from: version})` can encode all the ops from the version to the latest version
// `version` is the version vector of another document
const missingOps = docB.export({
mode: "update",
from: docA.oplogVersion(),
});
docA.import(missingOps);
expect(docA.toJSON()).toStrictEqual({
list: ["A", "C"],
});
expect(docA.toJSON()).toStrictEqual(docB.toJSON());You can subscribe to the event from Containers.
LoroText and LoroList can receive updates in
Quill Delta format.
The events will be emitted after a transaction is committed. A transaction is committed when:
doc.commit()is called.doc.export(mode)is called.doc.import(data)is called.doc.checkout(version)is called.
Below is an example of rich text event:
import { LoroDoc, LoroList, LoroMap, LoroText } from "loro-crdt";
import { expect } from "expect";
// ---cut---
// The code is from https://github.com/loro-dev/loro-examples-deno
const doc = new LoroDoc();
const text = doc.getText("text");
text.insert(0, "Hello world!");
doc.commit();
let ran = false;
text.subscribe((e) => {
for (const event of e.events) {
if (event.diff.type === "text") {
expect(event.diff.diff).toStrictEqual([
{
retain: 5,
attributes: { bold: true },
},
]);
ran = true;
}
}
});
text.mark({ start: 0, end: 5 }, "bold", true);
doc.commit();
await new Promise((r) => setTimeout(r, 1));
expect(ran).toBeTruthy();The types of events are defined as follows:
import { Path, Diff, Frontiers, ContainerID } from "loro-crdt";
export interface LoroEventBatch {
/**
* How the event is triggered.
*
* - `local`: The event is triggered by a local transaction.
* - `import`: The event is triggered by an import operation.
* - `checkout`: The event is triggered by a checkout operation.
*/
by: "local" | "import" | "checkout";
origin?: string;
/**
* The container ID of the current event receiver.
* It's undefined if the subscriber is on the root document.
*/
currentTarget?: ContainerID;
events: LoroEvent[];
from: Frontiers;
to: Frontiers;
}
/**
* The concrete event of Loro.
*/
export interface LoroEvent {
/**
* The container ID of the event's target.
*/
target: ContainerID;
diff: Diff;
/**
* The absolute path of the event's emitter, which can be an index of a list container or a key of a map container.
*/
path: Path;
}