Skip to content

Commit 2fc389b

Browse files
authored
adaptors: add server adaptor registry (#9)
* adaptors: add server adaptor registry * tests: cover server adaptors in loro e2e * adaptors: group server sources under server/ * fix: handle snapshot import errors * fix: guard ephemeral snapshot import * chore: fix lint issues * chore: fix type err
1 parent 2a9a47c commit 2fc389b

File tree

19 files changed

+711
-66
lines changed

19 files changed

+711
-66
lines changed

examples/excalidraw-example/src/hooks/useLoroSync.ts

Lines changed: 54 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import { useEffect, useRef, useCallback, useState } from "react";
22
import { throttle } from "throttle-debounce"; // TODO: REVIEW [stability] replace custom throttle with lib
3-
import { LoroDoc, EphemeralStore, LoroEventBatch, LoroMap } from "loro-crdt";
3+
import {
4+
LoroDoc,
5+
EphemeralStore,
6+
LoroEventBatch,
7+
LoroMap,
8+
type Value,
9+
} from "loro-crdt";
410
import { LoroWebsocketClient } from "loro-websocket/client";
511
import { LoroAdaptor, LoroEphemeralAdaptor } from "loro-adaptors";
6-
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types/types";
12+
import type {
13+
AppState as ExcalidrawAppState,
14+
ExcalidrawImperativeAPI,
15+
} from "@excalidraw/excalidraw/types/types";
16+
import type { ExcalidrawElement } from "@excalidraw/excalidraw/types/element/types";
717

818
interface UseLoroSyncOptions {
919
roomId: string;
@@ -14,35 +24,28 @@ interface UseLoroSyncOptions {
1424
excalidrawAPI: React.RefObject<ExcalidrawImperativeAPI>;
1525
}
1626

17-
interface Collaborator {
27+
interface PresenceEntry extends Record<string, Value> {
1828
userId: string;
1929
userName: string;
2030
userColor: string;
21-
cursor?: { x: number; y: number };
31+
cursor?: CursorPosition;
2232
selectedElementIds?: string[];
2333
lastActive: number;
2434
}
2535

26-
interface CursorPosition {
36+
interface CursorPosition extends Record<string, Value> {
2737
x: number;
2838
y: number;
2939
}
40+
interface Collaborator extends PresenceEntry {}
41+
type AppState = ExcalidrawAppState;
3042

31-
// Minimal type definitions for Excalidraw (to avoid import issues)
32-
export interface ExcalidrawElement {
33-
id: string;
34-
type: string;
35-
x: number;
36-
y: number;
37-
width: number;
38-
height: number;
39-
version: number;
40-
[key: string]: any;
41-
}
42-
43-
export interface AppState {
44-
[key: string]: any;
45-
}
43+
type SceneUpdateArgs = Parameters<
44+
ExcalidrawImperativeAPI["updateScene"]
45+
>[0];
46+
type SceneElements = NonNullable<SceneUpdateArgs["elements"]>;
47+
type SceneAppStateUpdate = NonNullable<SceneUpdateArgs["appState"]>;
48+
type PresenceStoreState = Record<string, PresenceEntry>;
4649

4750
export function useLoroSync({
4851
roomId,
@@ -54,7 +57,8 @@ export function useLoroSync({
5457
}: UseLoroSyncOptions) {
5558
const docRef = useRef<LoroDoc | null>(null);
5659
const clientRef = useRef<LoroWebsocketClient | null>(null);
57-
const ephemeralRef = useRef<EphemeralStore<Record<string, any>> | null>(null);
60+
const ephemeralRef =
61+
useRef<EphemeralStore<PresenceStoreState> | null>(null);
5862

5963
const [isConnected, setIsConnected] = useState(false);
6064
const [collaborators, setCollaborators] = useState<Map<string, Collaborator>>(new Map());
@@ -65,7 +69,7 @@ export function useLoroSync({
6569
useEffect(() => {
6670
const doc = new LoroDoc();
6771
const client = new LoroWebsocketClient({ url: wsUrl });
68-
const ephemeral = new EphemeralStore<Record<string, any>>(30000); // 30 second timeout
72+
const ephemeral = new EphemeralStore<PresenceStoreState>(30000); // 30 second timeout
6973

7074
docRef.current = doc;
7175
clientRef.current = client;
@@ -81,7 +85,8 @@ export function useLoroSync({
8185
if (event.by !== "local") {
8286
// Build scene data from doc and apply to Excalidraw. Avoid echo via flag.
8387
// TODO: REVIEW [avoid echo] We set a guard so the next Excalidraw onChange from updateScene is ignored.
84-
const newElements = (elementsContainer.toJSON() || []) as ExcalidrawElement[];
88+
const newElements =
89+
(elementsContainer.toJSON() || []) as SceneElements;
8590
const newAppState: Partial<AppState> = {};
8691
for (const [key, value] of appStateContainer.entries()) {
8792
newAppState[key as keyof AppState] = value;
@@ -90,7 +95,11 @@ export function useLoroSync({
9095
// Update checksum to match scene state
9196
const checksum = newElements.reduce((acc, e) => acc + (e?.version || 0), 0);
9297
lastChecksumRef.current = checksum;
93-
excalidrawAPI.current?.updateScene({ elements: newElements as any, appState: newAppState as any });
98+
const sceneUpdate: SceneUpdateArgs = { elements: newElements };
99+
if (Object.keys(newAppState).length > 0) {
100+
sceneUpdate.appState = newAppState as SceneAppStateUpdate;
101+
}
102+
excalidrawAPI.current?.updateScene(sceneUpdate);
94103
}
95104
});
96105

@@ -197,6 +206,17 @@ export function useLoroSync({
197206

198207
const doc = docRef.current;
199208
const list = doc.getList("elements");
209+
const getMapAt = (index: number): LoroMap | undefined => {
210+
const value = list.get(index);
211+
return value instanceof LoroMap ? value : undefined;
212+
};
213+
const ensureMapAt = (index: number): LoroMap => {
214+
const map = getMapAt(index);
215+
if (!map) {
216+
throw new Error(`Expected LoroMap at index ${index}`);
217+
}
218+
return map;
219+
};
200220

201221
// Filter out deleted
202222
const filtered = elements.filter(e => !e.isDeleted);
@@ -205,9 +225,9 @@ export function useLoroSync({
205225
const buildIndex = () => {
206226
const idx = new Map<string, number>();
207227
for (let i = 0; i < list.length; i++) {
208-
const m = list.get(i) as unknown as LoroMap | undefined;
209-
if (!m) continue;
210-
const id = m.get("id") as string | undefined;
228+
const map = getMapAt(i);
229+
if (!map) continue;
230+
const id = map.get("id") as string | undefined;
211231
if (id) idx.set(id, i);
212232
}
213233
return idx;
@@ -223,9 +243,9 @@ export function useLoroSync({
223243
if (pos == null) {
224244
// New element: insert at the desired position
225245
list.insertContainer(i, new LoroMap());
226-
const m = list.get(i) as unknown as LoroMap;
246+
const map = ensureMapAt(i);
227247
for (const [k, v] of Object.entries(target)) {
228-
m.set(k, v);
248+
map.set(k, v);
229249
}
230250
changed = true;
231251
indexMap = buildIndex();
@@ -237,21 +257,21 @@ export function useLoroSync({
237257
list.delete(pos, 1);
238258
const adjI = pos < i ? i - 1 : i;
239259
list.insertContainer(adjI, new LoroMap());
240-
const m = list.get(adjI) as unknown as LoroMap;
260+
const map = ensureMapAt(adjI);
241261
for (const [k, v] of Object.entries(target)) {
242-
m.set(k, v);
262+
map.set(k, v);
243263
}
244264
changed = true;
245265
indexMap = buildIndex();
246266
continue;
247267
}
248268

249269
// Same position: update only if version changed
250-
const m = list.get(i) as unknown as LoroMap;
251-
const prevVersion = m.get("version");
270+
const map = ensureMapAt(i);
271+
const prevVersion = map.get("version");
252272
if (prevVersion !== target.version) {
253273
for (const [k, v] of Object.entries(target)) {
254-
m.set(k, v);
274+
map.set(k, v);
255275
}
256276
changed = true;
257277
}

examples/excalidraw-example/tsconfig.node.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"skipLibCheck": true,
55
"module": "ESNext",
66
"moduleResolution": "bundler",
7-
"allowSyntheticDefaultImports": true
7+
"allowSyntheticDefaultImports": true,
8+
"strict": true
89
},
910
"include": ["vite.config.ts"]
10-
}
11+
}

packages/loro-adaptors/src/elo-loro-adaptor.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,17 +110,20 @@ export class EloLoroAdaptor implements CrdtDocAdaptor {
110110

111111
if (spans.length === 1) {
112112
const { keyId, key } = await this.config.getPrivateKey();
113-
const s = spans[0]!;
114-
const peerIdBytes = new TextEncoder().encode(String(s.peer));
113+
const [span] = spans;
114+
if (!span) {
115+
throw new Error("Expected delta span when packaging single update");
116+
}
117+
const peerIdBytes = new TextEncoder().encode(String(span.peer));
115118
const iv = this.config.ivFactory
116119
? this.config.ivFactory()
117120
: undefined;
118121
const { record } = await encryptDeltaSpan(
119122
updates,
120123
{
121124
peerId: peerIdBytes,
122-
start: s.start,
123-
end: s.start + s.length,
125+
start: span.start,
126+
end: span.start + span.length,
124127
keyId,
125128
iv,
126129
},
@@ -247,10 +250,11 @@ export class EloLoroAdaptor implements CrdtDocAdaptor {
247250
const mode = "snapshot";
248251
const plaintext = this.doc.export({ mode });
249252
const vvObj = vvToObject(this.doc.version());
253+
const encoder = new TextEncoder();
250254
const vvEntries: Array<{ peerId: Uint8Array; counter: number }> =
251255
Object.keys(vvObj).map(peer => ({
252-
peerId: new TextEncoder().encode(peer),
253-
counter: vvObj[peer]!,
256+
peerId: encoder.encode(peer),
257+
counter: vvObj[peer],
254258
}));
255259
const iv = this.config.ivFactory ? this.config.ivFactory() : undefined;
256260
const { record } = await encryptSnapshot(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./types";
22
export * from "./adaptors";
3+
export * from "./server";
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from "./server-registry";
2+
export * from "./server-loro-adaptor";
3+
export * from "./server-loro-ephemeral-adaptor";
4+
export * from "./server-yjs-awareness-adaptor";

0 commit comments

Comments
 (0)