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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"clean": "pnpm -r clean",
"format": "prettier --write .",
"format:check": "prettier --check .",
"dev-simple-server": "pnpm --filter loro-websocket exec tsx src/server/bin.ts"
"dev-simple-server": "pnpm --filter loro-websocket exec tsx src/server/bin.ts",
"check": "pnpm lint && pnpm typecheck && pnpm test"
},
"devDependencies": {
"@rslint/core": "^0.1.11",
Expand Down
67 changes: 50 additions & 17 deletions packages/loro-adaptors/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
# loro-adaptors

Adaptors that bridge the Loro protocol to `loro-crdt` documents and the ephemeral store. Includes an end‑to‑end encrypted adaptor for %ELO.
Adaptors that bridge the Loro protocol to `loro-crdt` documents, `flock` replicas, and the ephemeral store. Includes an end‑to‑end encrypted adaptor for %ELO.

## Install

```bash
pnpm add loro-adaptors loro-protocol loro-crdt
pnpm add loro-adaptors loro-protocol

# If using loro-crdt:
pnpm add loro-crdt

# If using flock:
pnpm add @loro-dev/flock

# If using yjs:
pnpm add yjs
```

## Why
Expand All @@ -15,18 +24,22 @@ The websocket client (`loro-websocket`) speaks the binary wire protocol. These a
- `LoroAdaptor`: wraps a `LoroDoc` and streams local updates to the connection; applies remote updates on receipt
- `LoroEphemeralAdaptor`: wraps an `EphemeralStore` for transient presence/cursor data
- `LoroPersistentStoreAdaptor`: wraps an `EphemeralStore` but marks updates as persisted so the server stores them for new peers
- `EloLoroAdaptor`: wraps a `LoroDoc` and packages updates into %ELO containers with AES‑GCM; decrypts inbound containers and imports plaintext.
- `EloAdaptor`: wraps a `LoroDoc` and packages updates into %ELO containers with AES‑GCM; decrypts inbound containers and imports plaintext.
- `FlockAdaptor`: wraps a `Flock` replica and streams local updates to the connection; applies remote updates on receipt.
- `YjsAwarenessServerAdaptor`: handles Yjs awareness updates on the server side (opaque blob merging).

## Usage

### Loro

```ts
import { LoroWebsocketClient } from "loro-websocket";
import {
LoroAdaptor,
LoroEphemeralAdaptor,
LoroPersistentStoreAdaptor,
EloLoroAdaptor,
} from "loro-adaptors";
EloAdaptor,
} from "loro-adaptors/loro"; // Import from "loro-adaptors/loro" to avoid pulling in unused peer dependencies
import { LoroDoc, EphemeralStore } from "loro-crdt";

const client = new LoroWebsocketClient({ url: "ws://localhost:8787" });
Expand All @@ -53,7 +66,7 @@ const roomPersisted = await client.join({

// %ELO (end‑to‑end encrypted Loro)
const key = new Uint8Array(32);
const elo = new EloLoroAdaptor({
const elo = new EloAdaptor({
getPrivateKey: async () => ({ keyId: "k1", key }),
});
const secure = await client.join({ roomId: "secure-room", crdtAdaptor: elo });
Expand All @@ -69,19 +82,39 @@ await roomPersisted.destroy();
await secure.destroy();
```

## API
### Flock

- `new LoroAdaptor(doc?: LoroDoc, config?: { onImportError?, onUpdateError? })`
- `new LoroEphemeralAdaptor(store?: EphemeralStore)`
- `new LoroPersistentStoreAdaptor(store?: EphemeralStore)`
- `new EloLoroAdaptor(docOrConfig: LoroDoc | { getPrivateKey, ivFactory?, onDecryptError?, onUpdateError? })`
- `getPrivateKey: (keyId?) => Promise<{ keyId: string, key: CryptoKey | Uint8Array }>`
- Optional `ivFactory()` for testing (12‑byte IV)
```ts
import { LoroWebsocketClient } from "loro-websocket";
import { FlockAdaptor } from "loro-adaptors/flock"; // Import from "loro-adaptors/flock"
import { Flock } from "@loro-dev/flock";

Notes (E2EE)
const client = new LoroWebsocketClient({ url: "ws://localhost:8787" });
await client.waitConnected();

const flock = new Flock();
const adaptor = new FlockAdaptor(flock);
const room = await client.join({ roomId: "flock-demo", crdtAdaptor: adaptor });
```

### YJS Awareness

```ts
import { YjsAwarenessServerAdaptor } from "loro-adaptors/yjs";
// This is primarily for server-side use or specific awareness integration
```

## API

- IV must be 12 bytes and unique per key. The `ivFactory` is for tests only.
- The server never decrypts; it indexes plaintext headers to select backfill.
- `loro-adaptors/loro`
- `new LoroAdaptor(doc?: LoroDoc, config?: { onImportError?, onUpdateError? })`
- `new LoroEphemeralAdaptor(store?: EphemeralStore)`
- `new LoroPersistentStoreAdaptor(store?: EphemeralStore)`
- `new EloAdaptor(docOrConfig: LoroDoc | { getPrivateKey, ivFactory?, onDecryptError?, onUpdateError? })`
- `loro-adaptors/flock`
- `new FlockAdaptor(flock: Flock, config?: { onImportError?, onUpdateError? })`
- `loro-adaptors/yjs`
- `new YjsAwarenessServerAdaptor()`

## Development

Expand All @@ -93,4 +126,4 @@ pnpm typecheck

## License

MIT
MIT
15 changes: 15 additions & 0 deletions packages/loro-adaptors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./flock": {
"types": "./dist/flock.d.ts",
"import": "./dist/flock.js",
"require": "./dist/flock.cjs"
},
"./yjs": {
"types": "./dist/yjs.d.ts",
"import": "./dist/yjs.js",
"require": "./dist/yjs.cjs"
},
"./loro": {
"types": "./dist/loro.d.ts",
"import": "./dist/loro.js",
"require": "./dist/loro.cjs"
}
},
"files": [
Expand Down
5 changes: 0 additions & 5 deletions packages/loro-adaptors/src/adaptors.ts

This file was deleted.

2 changes: 2 additions & 0 deletions packages/loro-adaptors/src/flock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./flock-adaptor";
export * from "./server/server-flock-adaptor";
2 changes: 0 additions & 2 deletions packages/loro-adaptors/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
export * from "./types";
export * from "./adaptors";
export * from "./server";
7 changes: 7 additions & 0 deletions packages/loro-adaptors/src/loro.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export * from "./loro-adaptor";
export * from "./loro-ephemeral-adaptor";
export * from "./loro-persistent-store-adaptor";
export * from "./elo-adaptor";
export * from "./server/server-loro-adaptor";
export * from "./server/server-loro-ephemeral-adaptor";
export * from "./server/server-loro-persistent-store-adaptor";
2 changes: 0 additions & 2 deletions packages/loro-adaptors/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
export * from "./server-registry";
export * from "./server-loro-adaptor";
export * from "./server-loro-ephemeral-adaptor";
export * from "./server-loro-persistent-store-adaptor";
export * from "./server-yjs-awareness-adaptor";
export * from "./server-flock-adaptor";
50 changes: 0 additions & 50 deletions packages/loro-adaptors/src/server/server-registry.ts

This file was deleted.

8 changes: 0 additions & 8 deletions packages/loro-adaptors/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,3 @@ export interface CrdtServerAdaptor {

merge(documents: Uint8Array[]): Uint8Array;
}

export interface AdaptorsForServer {
register(adaptor: CrdtServerAdaptor): void;
registerMany(adaptors: Iterable<CrdtServerAdaptor>): void;
get(crdtType: CrdtType): CrdtServerAdaptor | undefined;
clear(): void;
list(): CrdtServerAdaptor[];
}
1 change: 1 addition & 0 deletions packages/loro-adaptors/src/yjs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./server/server-yjs-awareness-adaptor";
2 changes: 1 addition & 1 deletion packages/loro-adaptors/tests/adaptors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
LoroAdaptor,
LoroEphemeralAdaptor,
LoroPersistentStoreAdaptor,
} from "../src/adaptors";
} from "../src/loro";
import { CrdtType, JoinResponseOk, MessageType } from "loro-protocol";

describe("LoroAdaptor", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/loro-adaptors/tests/elo-adaptor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { LoroDoc } from "loro-crdt";
import { EloAdaptor } from "../src/adaptors";
import { EloAdaptor } from "../src/loro";
import { CrdtType, MessageType } from "loro-protocol";
import { parseEloRecordHeader, decodeEloContainer } from "loro-protocol";
import { encryptDeltaSpan, encodeEloContainer } from "loro-protocol";
Expand Down
2 changes: 1 addition & 1 deletion packages/loro-adaptors/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineConfig } from "tsdown";

export default defineConfig({
entry: ["src/index.ts"],
entry: ["src/index.ts", "src/flock.ts", "src/loro.ts", "src/yjs.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
Expand Down
19 changes: 2 additions & 17 deletions packages/loro-websocket/src/server/crdt-doc.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import type { CrdtServerAdaptor } from "loro-adaptors";
import {
getServerAdaptor,
registerServerAdaptor,
LoroServerAdaptor,
LoroEphemeralServerAdaptor,
LoroPersistentStoreServerAdaptor,
FlockServerAdaptor,
} from "loro-adaptors";
import { LoroServerAdaptor, LoroEphemeralServerAdaptor, LoroPersistentStoreServerAdaptor } from "loro-adaptors/loro";
import { FlockServerAdaptor } from "loro-adaptors/flock";
import { CrdtType } from "loro-protocol";
import { EloServerAdaptor } from "./elo-server-adaptor";

Expand All @@ -19,7 +13,6 @@ export interface ServerAdaptorDescriptor {
const descriptors = new Map<CrdtType, ServerAdaptorDescriptor>();

function registerDescriptor(descriptor: ServerAdaptorDescriptor): void {
registerServerAdaptor(descriptor.adaptor);
descriptors.set(descriptor.adaptor.crdtType, descriptor);
}

Expand Down Expand Up @@ -66,11 +59,3 @@ export function getServerAdaptorDescriptor(
): ServerAdaptorDescriptor | undefined {
return descriptors.get(crdtType);
}

export function ensureServerAdaptor(
crdtType: CrdtType
): CrdtServerAdaptor | undefined {
const descriptor = descriptors.get(crdtType);
if (descriptor) return descriptor.adaptor;
return getServerAdaptor(crdtType);
}
6 changes: 3 additions & 3 deletions packages/loro-websocket/tests/e2e-elo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type ProtocolMessage,
type UpdateError,
} from "loro-protocol";
import { EloAdaptor } from "loro-adaptors";
import { EloAdaptor } from "loro-adaptors/loro";

// Make WebSocket available globally for the client
Object.defineProperty(globalThis, "WebSocket", {
Expand Down Expand Up @@ -355,7 +355,7 @@ async function waitForJoinOk(ws: WebSocket): Promise<void> {
clearTimeout(t);
resolve();
}
} catch {}
} catch { }
});
});
}
Expand All @@ -372,7 +372,7 @@ async function waitForUpdateError(ws: WebSocket): Promise<UpdateError> {
clearTimeout(t);
resolve(msg);
}
} catch {}
} catch { }
});
});
}
Expand Down
15 changes: 8 additions & 7 deletions packages/loro-websocket/tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import getPort from "get-port";
import { SimpleServer } from "../src/server/simple-server";
import { LoroWebsocketClient, ClientStatus } from "../src/client";
import type { LoroWebsocketClientRoom } from "../src/client";
import { FlockAdaptor, LoroAdaptor } from "loro-adaptors";
import { LoroAdaptor } from "loro-adaptors/loro";
import { FlockAdaptor } from "loro-adaptors/flock";
import { Flock } from "@loro-dev/flock";

// Make WebSocket available globally for the client
Expand Down Expand Up @@ -180,7 +181,7 @@ describe("E2E: Client-Server Sync", () => {

await new Promise(resolve => setTimeout(resolve, 100));

expect(text2.toString()).toBe("After reconnect");
expect(text2.toString()).toBe("After reconnectBefore disconnect");

await room2.destroy();
}, 10000);
Expand Down Expand Up @@ -407,9 +408,9 @@ describe("E2E: Client-Server Sync", () => {
await waitUntil(
() =>
statuses1.filter(s => s === ClientStatus.Connected).length >
initialConnectedCount1 &&
initialConnectedCount1 &&
statuses2.filter(s => s === ClientStatus.Connected).length >
initialConnectedCount2,
initialConnectedCount2,
5000,
25
);
Expand Down Expand Up @@ -496,9 +497,9 @@ describe("E2E: Client-Server Sync", () => {
await waitUntil(
() =>
statuses1.filter(s => s === ClientStatus.Connected).length >
initialConnectedCount1 &&
initialConnectedCount1 &&
statuses2.filter(s => s === ClientStatus.Connected).length >
initialConnectedCount2,
initialConnectedCount2,
5000,
25
);
Expand All @@ -525,7 +526,7 @@ describe("E2E: Client-Server Sync", () => {
handlePong: () => void;
};
const originalHandlePong = clientWithPong.handlePong;
clientWithPong.handlePong = () => {};
clientWithPong.handlePong = () => { };

const pingPromise = client.ping(5000);

Expand Down
2 changes: 1 addition & 1 deletion packages/loro-websocket/tests/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { WebSocket } from "ws";
import getPort from "get-port";
import { SimpleServer } from "../src/server/simple-server";
import { LoroWebsocketClient } from "../src/client";
import { LoroAdaptor } from "loro-adaptors";
import { LoroAdaptor } from "loro-adaptors/loro";

// Make WebSocket available globally for the client
Object.defineProperty(globalThis, "WebSocket", {
Expand Down