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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pnpm clean
- **packages/loro-protocol**: Protocol types and binary encoders/decoders, bytes utilities, and `%ELO` container/crypto helpers (TypeScript). Key files: `src/{protocol,encoding,bytes,e2ee}.ts` with tests under `src/`.
- **packages/loro-websocket**: WebSocket client and a `SimpleServer` (TypeScript).
- Features: message fragmentation/reassembly (≤256 KiB), connection‑scoped keepalive frames (`"ping"/"pong"` text), permission hooks, optional persistence hooks.
- **packages/loro-adaptors**: Adaptors that connect the WebSocket client to `loro-crdt` (`LoroAdaptor`, `LoroEphemeralAdaptor`) and `%ELO` (`EloLoroAdaptor`).
- **packages/loro-adaptors**: Adaptors that connect the WebSocket client to `loro-crdt` (`LoroAdaptor`, `LoroEphemeralAdaptor`) and `%ELO` (`EloAdaptor`).
- **examples/excalidraw-example**: React demo using `SimpleServer`; syncs a Loro doc and ephemeral presence.
- **rust/**: Rust workspace mirroring the TS packages:
- `rust/loro-protocol`: Encoder/decoder parity with JS (snapshot tests included).
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,15 @@ Tip: For a working reference, see `packages/loro-websocket/src/e2e.test.ts` whic

`%ELO` adds end‑to‑end encryption to Loro sync. The server never decrypts; it indexes plaintext headers only to support backfill and routing. Clients encrypt/decrypt using AES‑GCM with a 12‑byte IV and the exact encoded header bytes as AAD.

- TypeScript: use `EloLoroAdaptor` from `loro-adaptors` + `LoroWebsocketClient`.
- TypeScript: use `EloAdaptor` from `loro-adaptors` + `LoroWebsocketClient`.
- Provide a `getPrivateKey()` hook that resolves `{ keyId, key }` (Web Crypto CryptoKey or Uint8Array).
- The adaptor packages updates into `%ELO` containers and decrypts incoming ones, applying to its internal `LoroDoc`.

Example (Node 18+):

```ts
import { LoroWebsocketClient } from "loro-websocket/client";
import { EloLoroAdaptor } from "loro-adaptors/loro";
import { EloAdaptor } from "loro-adaptors/loro";
import { WebSocket } from "ws";
(globalThis as any).WebSocket =
WebSocket as unknown as typeof globalThis.WebSocket;
Expand All @@ -89,7 +89,7 @@ const key = new Uint8Array([

const client = new LoroWebsocketClient({ url: "ws://localhost:8787" });
await client.waitConnected();
const adaptor = new EloLoroAdaptor({
const adaptor = new EloAdaptor({
getPrivateKey: async () => ({ keyId: "k1", key }),
});
const room = await client.join({ roomId: "elo-room", crdtAdaptor: adaptor });
Expand Down
2 changes: 1 addition & 1 deletion llms.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ Adaptors bridge the client to actual CRDT state:

- `LoroAdaptor` – Loro document (`const adaptor = new LoroAdaptor()`).
- `LoroEphemeralAdaptor` – transient presence (`%EPH`).
- `EloLoroAdaptor` – `%ELO` encrypted Loro with `getPrivateKey()`.
- `EloAdaptor` – `%ELO` encrypted Loro with `getPrivateKey()`.

### 3.2 Constructor Options

Expand Down
16 changes: 8 additions & 8 deletions packages/loro-adaptors/src/elo-adaptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import {
} from "loro-protocol";

// Minimal placeholder to avoid requiring DOM lib in this package
interface CryptoKey {}
interface CryptoKey { }

export interface EloLoroAdaptorConfig {
export interface EloAdaptorConfig {
getPrivateKey: (
keyId?: string
) => Promise<{ keyId: string; key: CryptoKey | Uint8Array }>;
Expand All @@ -36,7 +36,7 @@ export class EloAdaptor implements CrdtDocAdaptor {
private doc: LoroDoc;
private ctx?: CrdtAdaptorContext;
private destroyed = false;
private config: EloLoroAdaptorConfig;
private config: EloAdaptorConfig;
private localUpdateUnsubscribe?: () => void;
private initServerVersion?: VersionVector;
private hasReachedServerVersion = false;
Expand All @@ -48,15 +48,15 @@ export class EloAdaptor implements CrdtDocAdaptor {
private lastSentVV?: Record<string, number>;

// Overloads to allow (config) or (doc, config)
constructor(doc: LoroDoc, config: EloLoroAdaptorConfig);
constructor(config: EloLoroAdaptorConfig);
constructor(doc: LoroDoc, config: EloAdaptorConfig);
constructor(config: EloAdaptorConfig);
constructor(
docOrConfig: LoroDoc | EloLoroAdaptorConfig,
maybeConfig?: EloLoroAdaptorConfig
docOrConfig: LoroDoc | EloAdaptorConfig,
maybeConfig?: EloAdaptorConfig
) {
if (docOrConfig instanceof LoroDoc) {
this.doc = docOrConfig;
this.config = maybeConfig as EloLoroAdaptorConfig;
this.config = maybeConfig as EloAdaptorConfig;
} else {
this.doc = new LoroDoc();
this.config = docOrConfig;
Expand Down
6 changes: 3 additions & 3 deletions packages/loro-adaptors/tests/elo-adaptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function hexToBytes(hex: string): Uint8Array {
return out;
}

describe("EloLoroAdaptor — snapshot join", () => {
describe("EloAdaptor — snapshot join", () => {
let doc: LoroDoc;
let adaptor: EloAdaptor;
const key = hexToBytes(KEY_HEX);
Expand Down Expand Up @@ -60,7 +60,7 @@ describe("EloLoroAdaptor — snapshot join", () => {
});
});

describe("EloLoroAdaptor — apply snapshot update", () => {
describe("EloAdaptor — apply snapshot update", () => {
it("applies a snapshot container and updates the doc", async () => {
const key = hexToBytes(KEY_HEX);
// Create a source doc with content
Expand Down Expand Up @@ -99,7 +99,7 @@ describe("EloLoroAdaptor — apply snapshot update", () => {
});
});

describe("EloLoroAdaptor — apply delta update", () => {
describe("EloAdaptor — apply delta update", () => {
it("applies a delta container update and updates the doc", async () => {
const key = hexToBytes(KEY_HEX);
// Create source update plaintext via standard update export
Expand Down
2 changes: 1 addition & 1 deletion packages/loro-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { encode, decode, CrdtType, MessageType } from "loro-protocol";
const join = encode({
type: MessageType.JoinRequest,
crdt: CrdtType.Loro,
roomId: new TextEncoder().encode("room-1"),
roomId: "room-1",
auth: new Uint8Array(),
version: new Uint8Array(),
});
Expand Down
22 changes: 7 additions & 15 deletions packages/loro-protocol/src/encoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,13 @@ export function encode(message: ProtocolMessage): Uint8Array {
const crdtBytes = new TextEncoder().encode(message.crdt);
writer.pushBytes(crdtBytes);

// Write room ID as varBytes
if (typeof message.roomId === "string") {
if (message.roomId.length > MAX_ROOM_ID_LENGTH) {
throw new Error("Room ID too long");
}
writer.pushVarString(message.roomId);
} else {
if (message.roomId.length > MAX_ROOM_ID_LENGTH) {
throw new Error("Room ID too long");
}
writer.pushVarBytes(message.roomId);
// Write room ID as varString
const roomIdBytes = new TextEncoder().encode(message.roomId);
if (roomIdBytes.byteLength > MAX_ROOM_ID_LENGTH) {
throw new Error("Room ID too long");
}
writer.pushVarBytes(roomIdBytes);


// Write message type
writer.pushByte(message.type);
Expand Down Expand Up @@ -209,10 +204,7 @@ export function decode(data: Uint8Array): ProtocolMessage {
}

// Read room ID
const roomId = reader.readVarBytes();
if (roomId.length > 128) {
throw new Error("Room ID exceeds maximum length of 128 bytes");
}
const roomId = reader.readVarString();

// Read message type
const typeByte = reader.readByte();
Expand Down
2 changes: 1 addition & 1 deletion packages/loro-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const UpdateErrorCode = {
export type UpdateErrorCode =
(typeof UpdateErrorCode)[keyof typeof UpdateErrorCode];

export type RoomId = string | Uint8Array;
export type RoomId = string;

export interface MessageBase {
crdt: CrdtType;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`encoding snapshots > DocUpdate multiple updates 1`] = `"0x25594a53040102030403030301020304040506070108"`;
exports[`encoding snapshots > DocUpdate multiple updates 1`] = `"0x25594a5309726f6f6d2d3132333403030301020304040506070108"`;

exports[`encoding snapshots > DocUpdateFragment 1`] = `"0x254c4f5204010203040500000000000000000320000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"`;
exports[`encoding snapshots > DocUpdateFragment 1`] = `"0x254c4f5209726f6f6d2d313233340500000000000000000320000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"`;

exports[`encoding snapshots > DocUpdateFragmentHeader 1`] = `"0x254c4f52040102030404ff112233445566770a80c03e"`;
exports[`encoding snapshots > DocUpdateFragmentHeader 1`] = `"0x254c4f5209726f6f6d2d3132333404ff112233445566770a80c03e"`;

exports[`encoding snapshots > JoinError app error with appCode 1`] = `"0x25594a530401020304027f1a4170706c69636174696f6e207370656369666963206572726f720e71756f74615f6578636565646564"`;
exports[`encoding snapshots > JoinError app error with appCode 1`] = `"0x25594a5309726f6f6d2d31323334027f1a4170706c69636174696f6e207370656369666963206572726f720e71756f74615f6578636565646564"`;

exports[`encoding snapshots > JoinError auth failed 1`] = `"0x254550480401020304020213496e76616c69642063726564656e7469616c73"`;
exports[`encoding snapshots > JoinError auth failed 1`] = `"0x2545504809726f6f6d2d31323334020213496e76616c69642063726564656e7469616c73"`;

exports[`encoding snapshots > JoinError version unknown with receiver version 1`] = `"0x254c4f52040102030402011056657273696f6e206d69736d61746368026364"`;
exports[`encoding snapshots > JoinError version unknown with receiver version 1`] = `"0x254c4f5209726f6f6d2d3132333402011056657273696f6e206d69736d61746368026364"`;

exports[`encoding snapshots > JoinRequest 1`] = `"0x254c4f52040102030400030a141e0328323c"`;
exports[`encoding snapshots > JoinRequest 1`] = `"0x254c4f5209726f6f6d2d3132333400030a141e0328323c"`;

exports[`encoding snapshots > JoinResponseOk read 1`] = `"0x25594a530401020304010472656164030b162100"`;
exports[`encoding snapshots > JoinResponseOk read 1`] = `"0x25594a5309726f6f6d2d31323334010472656164030b162100"`;

exports[`encoding snapshots > JoinResponseOk write + extra 1`] = `"0x254c4f52040102030401057772697465022c3703424d58"`;
exports[`encoding snapshots > JoinResponseOk write + extra 1`] = `"0x254c4f5209726f6f6d2d3132333401057772697465022c3703424d58"`;

exports[`encoding snapshots > Leave 1`] = `"0x254c4f52040102030407"`;
exports[`encoding snapshots > Leave 1`] = `"0x254c4f5209726f6f6d2d3132333407"`;

exports[`encoding snapshots > UpdateError app error with appCode 1`] = `"0x254c4f520401020304067f10437573746f6d20617070206572726f720f637573746f6d5f636f64655f313233"`;
exports[`encoding snapshots > UpdateError app error with appCode 1`] = `"0x254c4f5209726f6f6d2d31323334067f10437573746f6d20617070206572726f720f637573746f6d5f636f64655f313233"`;

exports[`encoding snapshots > UpdateError fragment timeout with batchId 1`] = `"0x255941570401020304060710467261676d656e742074696d656f75740100000000000000"`;
exports[`encoding snapshots > UpdateError fragment timeout with batchId 1`] = `"0x2559415709726f6f6d2d31323334060710467261676d656e742074696d656f75740100000000000000"`;

exports[`encoding snapshots > UpdateError permission denied 1`] = `"0x254c4f5204010203040603134e6f207772697465207065726d697373696f6e"`;
exports[`encoding snapshots > UpdateError permission denied 1`] = `"0x254c4f5209726f6f6d2d313233340603134e6f207772697465207065726d697373696f6e"`;
2 changes: 1 addition & 1 deletion packages/loro-protocol/tests/encoding.snap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
// TODO: REVIEW ensure binary snapshots stay stable across changes to protocol

describe("encoding snapshots", () => {
const roomId = new Uint8Array([1, 2, 3, 4]);
const roomId = "room-1234";

it("JoinRequest", () => {
const msg: JoinRequest = {
Expand Down
10 changes: 5 additions & 5 deletions packages/loro-protocol/tests/encoding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from "../src/protocol";

describe("Message Encoding and Decoding", () => {
const roomId = new Uint8Array([1, 2, 3, 4, 5]);
const roomId = "room-123";

describe("JoinRequest", () => {
it("encodes and decodes JoinRequest correctly", () => {
Expand Down Expand Up @@ -437,7 +437,7 @@ describe("Message Encoding and Decoding", () => {
const message: Leave = {
type: MessageType.Leave,
crdt: CrdtType.Loro,
roomId: new Uint8Array([1, 2, 3]),
roomId: "room-123",
};

const encoded = encode(message);
Expand Down Expand Up @@ -466,7 +466,7 @@ describe("Message Encoding and Decoding", () => {

describe("Room ID validation", () => {
it("accepts room IDs up to 128 bytes", () => {
const maxRoomId = new Uint8Array(128);
const maxRoomId = "a".repeat(128);
const message: Leave = {
type: MessageType.Leave,
crdt: CrdtType.Loro,
Expand All @@ -480,7 +480,7 @@ describe("Message Encoding and Decoding", () => {
});

it("throws error for room IDs exceeding 128 bytes during encode", () => {
const largeRoomId = new Uint8Array(129);
const largeRoomId = "a".repeat(129);
const message: Leave = {
type: MessageType.Leave,
crdt: CrdtType.Loro,
Expand Down Expand Up @@ -542,7 +542,7 @@ describe("Message Encoding and Decoding", () => {
const message: Leave = {
type: MessageType.Leave,
crdt: CrdtType.Loro,
roomId: new Uint8Array(0),
roomId: "",
};

const encoded = encode(message);
Expand Down
6 changes: 3 additions & 3 deletions packages/loro-websocket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,18 @@ await room.destroy();
offStatus();
```

%ELO (end‑to‑end encrypted Loro) using `EloLoroAdaptor`:
%ELO (end‑to‑end encrypted Loro) using `EloAdaptor`:

```ts
import { LoroWebsocketClient } from "loro-websocket";
import { EloLoroAdaptor } from "loro-adaptors/loro";
import { EloAdaptor } from "loro-adaptors/loro";

const key = new Uint8Array(32);
key[0] = 1;
const client = new LoroWebsocketClient({ url: "ws://localhost:8787" });
await client.waitConnected();

const adaptor = new EloLoroAdaptor({
const adaptor = new EloAdaptor({
getPrivateKey: async () => ({ keyId: "k1", key }),
});
await client.join({ roomId: "secure-room", crdtAdaptor: adaptor });
Expand Down
20 changes: 4 additions & 16 deletions packages/loro-websocket/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,7 @@ export class LoroWebsocketClient {
}

private async handleMessage(msg: ProtocolMessage) {
const roomIdStr =
typeof msg.roomId === "string"
? msg.roomId
: new TextDecoder().decode(msg.roomId);
const roomIdStr = msg.roomId;
const roomId = msg.crdt + roomIdStr;

switch (msg.type) {
Expand Down Expand Up @@ -555,10 +552,7 @@ export class LoroWebsocketClient {
if (active) {
active.handler.handleUpdateError(msg);
} else {
const roomIdStr =
typeof msg.roomId === "string"
? msg.roomId
: new TextDecoder().decode(msg.roomId);
const roomIdStr = msg.roomId;
console.error(
`Update error for room ${roomIdStr}: ${msg.code} - ${msg.message}`
);
Expand All @@ -569,10 +563,7 @@ export class LoroWebsocketClient {
}

private handleFragmentHeader(msg: DocUpdateFragmentHeader) {
const roomIdStr =
typeof msg.roomId === "string"
? msg.roomId
: new TextDecoder().decode(msg.roomId);
const roomIdStr = msg.roomId;
const batchKey = `${msg.crdt}-${roomIdStr}-${msg.batchId}`;

// Clear any existing batch with same ID
Expand Down Expand Up @@ -608,10 +599,7 @@ export class LoroWebsocketClient {
}

private handleFragment(msg: DocUpdateFragment) {
const roomIdStr =
typeof msg.roomId === "string"
? msg.roomId
: new TextDecoder().decode(msg.roomId);
const roomIdStr = msg.roomId;
const batchKey = `${msg.crdt}-${roomIdStr}-${msg.batchId}`;
const batch = this.fragmentBatches.get(batchKey);

Expand Down
12 changes: 3 additions & 9 deletions packages/loro-websocket/src/server/simple-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
HexString,
DocUpdateFragmentHeader,
DocUpdateFragment,
bytesToHex,
MAX_MESSAGE_SIZE,
} from "loro-protocol";
import {
Expand Down Expand Up @@ -276,7 +275,7 @@ export class SimpleServer {
let permission: Permission = "write";
if (this.config.authenticate) {
const authResult = await this.config.authenticate(
this.roomIdToString(message.roomId),
message.roomId,
message.crdt,
message.auth
);
Expand Down Expand Up @@ -624,7 +623,7 @@ export class SimpleServer {
if (descriptor.shouldPersist && this.config.onLoadDocument) {
try {
const loaded = await this.config.onLoadDocument(
this.roomIdToString(roomId),
roomId,
crdtType
);
if (loaded) {
Expand Down Expand Up @@ -703,12 +702,7 @@ export class SimpleServer {
}

private getRoomKey(roomId: RoomId, crdtType: CrdtType): string {
const roomStr = this.roomIdToString(roomId);
return `${roomStr}:${crdtType}`;
}

private roomIdToString(roomId: RoomId): string {
return typeof roomId === "string" ? roomId : bytesToHex(roomId);
return `${roomId}:${crdtType}`;
}

private parseRoomKey(
Expand Down
4 changes: 2 additions & 2 deletions packages/loro-websocket/test-wrappers/recv-elo-doc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { WebSocket as NodeWebSocket } from "ws";
import { LoroWebsocketClient } from "../src/client";
import { EloLoroAdaptor } from "loro-adaptors";
import { EloAdaptor } from "loro-adaptors/loro";
import { pathToFileURL } from "node:url";

function hexToBytes(s: string): Uint8Array {
Expand All @@ -25,7 +25,7 @@ async function main() {
const key = hexToBytes(
"0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
);
const adaptor = new EloLoroAdaptor({
const adaptor = new EloAdaptor({
getPrivateKey: async () => ({ keyId: "k1", key }),
});
const client = new LoroWebsocketClient({ url });
Expand Down
2 changes: 1 addition & 1 deletion packages/loro-websocket/test-wrappers/recv-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ async function main() {
const join: ProtocolMessage = {
type: MessageType.JoinRequest,
crdt: CrdtType.Elo,
roomId: Buffer.from(roomId),
roomId: roomId,
auth: new Uint8Array(),
version: new Uint8Array(),
};
Expand Down
Loading