Skip to content

Commit b8ffcca

Browse files
committed
wip
1 parent ed7abba commit b8ffcca

13 files changed

+277
-10
lines changed

src/excalidraw-backend/server.ts

+53
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
defaultSaveTimeout,
2121
DISCONNECT,
2222
DISCONNECTING,
23+
ExcalidrawElement,
2324
IDLE_STATE,
2425
INIT_ROOM,
2526
JOIN_ROOM,
@@ -31,6 +32,7 @@ import {
3132
SERVER_SAVE_REQUEST,
3233
SERVER_SIDE_ROOM_DELETED,
3334
SERVER_VOLATILE_BROADCAST,
35+
SocketEventData,
3436
SocketIoServer,
3537
SocketIoSocket,
3638
} from './types';
@@ -55,11 +57,19 @@ import { CREATE_ROOM, DELETE_ROOM } from './adapters/adapter.event.names';
5557
import { APP_ID } from '../app.id';
5658
import { arrayRandomElement, isAbortError } from '../util';
5759
import { ConfigType } from '../config';
60+
import { tryDecodeIncoming } from './utils/decode.incoming';
61+
import { ServerBroadcastPayload } from './types/events';
62+
import { reconcileElements } from './utils/reconcile';
63+
import { detectChanges } from './utils/detect.changes';
5864

5965
type SaveMessageOpts = { timeout: number };
6066
type RoomTrackers = Map<string, AbortController>;
6167
type SocketTrackers = Map<string, AbortController>;
6268

69+
type MasterSnapshot = {
70+
elements: ExcalidrawElement[];
71+
} & Record<string, unknown>;
72+
6373
@Injectable()
6474
export class Server {
6575
private readonly wsServer: SocketIoServer;
@@ -74,6 +84,8 @@ export class Server {
7484
private readonly saveConsecutiveFailedAttempts: number;
7585
private readonly collaboratorInactivityMs: number;
7686

87+
private snapshots: Map<string, MasterSnapshot> = new Map();
88+
7789
constructor(
7890
@Inject(WINSTON_MODULE_NEST_PROVIDER) private logger: LoggerService,
7991
private readonly utilService: UtilService,
@@ -210,6 +222,9 @@ export class Server {
210222
});
211223

212224
socket.on(JOIN_ROOM, async (roomID) => {
225+
if (!this.snapshots.has(roomID)) {
226+
this.snapshots.set(roomID, { elements: [] });
227+
}
213228
// this logic could be provided by an entitlement (license) service
214229
await authorizeWithRoomAndJoinHandler(
215230
roomID,
@@ -228,6 +243,44 @@ export class Server {
228243
this.utilService.contentModified(socket.data.userInfo.id, roomId),
229244
);
230245
this.resetCollaboratorInactivityTrackerForSocket(socket);
246+
let eventData: SocketEventData<ServerBroadcastPayload> | undefined;
247+
try {
248+
eventData = tryDecodeIncoming<ServerBroadcastPayload>(data);
249+
} catch (e) {
250+
this.logger.error({
251+
message: e?.message ?? JSON.stringify(e),
252+
});
253+
}
254+
255+
if (!eventData) {
256+
return;
257+
}
258+
259+
const snapshot = this.snapshots.get(roomID);
260+
261+
if (!snapshot) {
262+
return;
263+
}
264+
265+
if (eventData.type === 'sync-check') {
266+
console.log(
267+
'sync-check',
268+
JSON.stringify(
269+
detectChanges(snapshot.elements, eventData.payload.elements),
270+
null,
271+
2,
272+
),
273+
);
274+
}
275+
276+
const a = reconcileElements(
277+
snapshot.elements,
278+
eventData.payload.elements,
279+
);
280+
// console.log(
281+
// JSON.stringify(detectChanges(snapshot.elements, a), null, 2),
282+
// );
283+
snapshot.elements = a;
231284
});
232285
socket.on(SCENE_INIT, (roomID: string, data: ArrayBuffer) => {
233286
socket.broadcast.to(roomID).emit(CLIENT_BROADCAST, data);
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export enum ClientBroadcastPayloadType {
1+
export enum BroadcastPayloadType {
22
SCENE_INIT = 'SCENE_INIT',
33
SCENE_UPDATE = 'SCENE_UPDATE',
44
}
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './idle.state';
2+
export * from './server.broadcast.payload';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { ExcalidrawElement } from '../excalidraw.element';
2+
import { ExcalidrawFile } from '../excalidraw.file';
3+
4+
export type ServerBroadcastPayload = {
5+
elements: readonly ExcalidrawElement[];
6+
files: readonly ExcalidrawFile[];
7+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export type ExcalidrawElement = {
2+
id: string;
3+
index: number;
4+
version: number;
5+
versionNonce: number;
6+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type ExcalidrawFile = Record<string, unknown>;

src/excalidraw-backend/types/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export * from './client.broadcast.payload.type';
1+
export * from './broadcast.payload.type';
22
export * from './collaboration.mode.reasons';
33

44
export * from './defaults';
@@ -14,3 +14,5 @@ export * from './socket.io.socket';
1414

1515
export * from './user.info.for.room';
1616
export * from './user.idle.state';
17+
18+
export * from './excalidraw.element';

src/excalidraw-backend/types/socket.event.data.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
type BasePayload = Record<string, unknown>;
1+
export type BasePayload = Record<string, unknown>;
22

33
export type SocketEventData<TPayload extends BasePayload = BasePayload> = {
44
type: string;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Transforms array of objects containing `id` attribute,
3+
* or array of ids (strings), into a Map, keyd by `id`.
4+
*/
5+
export const arrayToMap = <T extends { id: string } | string>(
6+
items: readonly T[] | Map<string, T>,
7+
) => {
8+
if (items instanceof Map) {
9+
return items;
10+
}
11+
return items.reduce((acc: Map<string, T>, element) => {
12+
acc.set(typeof element === 'string' ? element : element.id, element);
13+
return acc;
14+
}, new Map());
15+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { BasePayload, SocketEventData } from '../types';
2+
3+
/**
4+
* Tries to decode incoming binary data.
5+
* Throws an exception otherwise.
6+
* @param {ArrayBuffer}data The incoming binary data
7+
* @returns {SocketEventData} Returns the decoded data in form of event data
8+
* @throws {TypeError} Throws an error if the data cannot be decoded
9+
* @throws {SyntaxError} Thrown if the data to parse is not valid JSON.
10+
*/
11+
export const tryDecodeIncoming = <TPayload extends BasePayload>(
12+
data: ArrayBuffer,
13+
): SocketEventData<TPayload> | never => {
14+
const strEventData = tryDecodeBinary(data);
15+
16+
return JSON.parse(strEventData) as SocketEventData<TPayload>;
17+
};
18+
/**
19+
*
20+
* @throws {TypeError} Throws an error if the data cannot be decoded
21+
*/
22+
export const tryDecodeBinary = (data: ArrayBuffer): string => {
23+
const decoder = new TextDecoder('utf-8');
24+
return decoder.decode(data);
25+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
interface Item {
2+
id: string;
3+
[key: string]: any;
4+
}
5+
6+
function arraysEqual(arr1: any[], arr2: any[]): boolean {
7+
if (arr1.length !== arr2.length) return false;
8+
for (let i = 0; i < arr1.length; i++) {
9+
if (arr1[i] !== arr2[i]) return false;
10+
}
11+
return true;
12+
}
13+
14+
export function detectChanges(
15+
oldArray: readonly Item[],
16+
newArray: readonly Item[],
17+
) {
18+
const changes = {
19+
added: [] as Item[],
20+
removed: [] as Item[],
21+
updated: [] as {
22+
id: string;
23+
changes: { before: Partial<Item>; after: Partial<Item> };
24+
}[],
25+
};
26+
27+
const oldMap = new Map<string, Item>();
28+
const newMap = new Map<string, Item>();
29+
30+
oldArray.forEach((item) => oldMap.set(item.id, item));
31+
newArray.forEach((item) => newMap.set(item.id, item));
32+
33+
// Detect added and updated items
34+
newMap.forEach((newItem, id) => {
35+
const oldItem = oldMap.get(id);
36+
if (!oldItem) {
37+
changes.added.push(newItem);
38+
} else {
39+
const before: Partial<Item> = {};
40+
const after: Partial<Item> = {};
41+
for (const key in newItem) {
42+
if (Array.isArray(newItem[key]) && Array.isArray(oldItem[key])) {
43+
if (!arraysEqual(newItem[key], oldItem[key])) {
44+
before[key] = oldItem[key];
45+
after[key] = newItem[key];
46+
}
47+
} else if (newItem[key] !== oldItem[key]) {
48+
before[key] = oldItem[key];
49+
after[key] = newItem[key];
50+
}
51+
}
52+
if (Object.keys(before).length > 0) {
53+
changes.updated.push({ id, changes: { before, after } });
54+
}
55+
}
56+
});
57+
58+
// Detect removed items
59+
oldMap.forEach((oldItem, id) => {
60+
if (!newMap.has(id)) {
61+
changes.removed.push(oldItem);
62+
}
63+
});
64+
65+
return changes;
66+
}

src/excalidraw-backend/utils/handlers.ts

+4-7
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import {
1515
} from '../types';
1616
import { minCollaboratorsInRoom } from '../types';
1717
import { SocketEventData } from '../types';
18-
import { IdleStatePayload } from '../types/events';
18+
import { IdleStatePayload, ServerBroadcastPayload } from '../types/events';
1919
import { closeConnection } from './util';
20+
import { tryDecodeBinary, tryDecodeIncoming } from './decode.incoming';
2021

2122
const fetchSocketsSafe = async (
2223
wsServer: SocketIoServer,
@@ -157,17 +158,13 @@ export const idleStateEventHandler = (
157158
) => {
158159
socket.broadcast.to(roomID).emit(IDLE_STATE, data);
159160

160-
const decoder = new TextDecoder('utf-8');
161-
const strEventData = decoder.decode(data);
162161
try {
163-
const eventData = JSON.parse(
164-
strEventData,
165-
) as SocketEventData<IdleStatePayload>;
162+
const eventData = tryDecodeIncoming<IdleStatePayload>(data);
166163
socket.data.state = eventData.payload.userState;
167164
} catch (e) {
168165
logger.error({
169166
message: e?.message ?? JSON.stringify(e),
170-
data: strEventData,
167+
data: e instanceof SyntaxError ? tryDecodeBinary(data) : undefined,
171168
});
172169
}
173170
};
+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { ExcalidrawElement } from '../types';
2+
import { arrayToMap } from './array.to.map';
3+
4+
const shouldDiscardRemoteElement = (
5+
local: ExcalidrawElement | undefined,
6+
remote: ExcalidrawElement,
7+
): boolean => {
8+
return !!(
9+
local &&
10+
// local element is newer
11+
(local.version > remote.version ||
12+
// resolve conflicting edits deterministically by taking the one with
13+
// the lowest versionNonce
14+
(local.version === remote.version &&
15+
local.versionNonce < remote.versionNonce))
16+
);
17+
};
18+
19+
/*const validateIndicesThrottled = throttle(
20+
(
21+
orderedElements: readonly OrderedExcalidrawElement[],
22+
localElements: readonly OrderedExcalidrawElement[],
23+
remoteElements: readonly RemoteExcalidrawElement[],
24+
) => {
25+
if (
26+
import.meta.env.DEV ||
27+
import.meta.env.MODE === ENV.TEST ||
28+
window?.DEBUG_FRACTIONAL_INDICES
29+
) {
30+
// create new instances due to the mutation
31+
const elements = syncInvalidIndices(
32+
orderedElements.map((x) => ({ ...x })),
33+
);
34+
35+
validateFractionalIndices(elements, {
36+
// throw in dev & test only, to remain functional on `DEBUG_FRACTIONAL_INDICES`
37+
shouldThrow: import.meta.env.DEV || import.meta.env.MODE === ENV.TEST,
38+
includeBoundTextValidation: true,
39+
reconciliationContext: {
40+
localElements,
41+
remoteElements,
42+
},
43+
});
44+
}
45+
},
46+
1000 * 60,
47+
{ leading: true, trailing: false },
48+
);*/
49+
50+
export const reconcileElements = (
51+
localElements: readonly ExcalidrawElement[],
52+
remoteElements: readonly ExcalidrawElement[],
53+
): ExcalidrawElement[] => {
54+
const localElementsMap = arrayToMap(localElements);
55+
const reconciledElements: ExcalidrawElement[] = [];
56+
const added = new Set<string>();
57+
58+
// process remote elements
59+
for (const remoteElement of remoteElements) {
60+
if (!added.has(remoteElement.id)) {
61+
const localElement = localElementsMap.get(remoteElement.id);
62+
const discardRemoteElement = shouldDiscardRemoteElement(
63+
localElement,
64+
remoteElement,
65+
);
66+
67+
if (localElement && discardRemoteElement) {
68+
reconciledElements.push(localElement);
69+
added.add(localElement.id);
70+
} else {
71+
reconciledElements.push(remoteElement);
72+
added.add(remoteElement.id);
73+
}
74+
}
75+
}
76+
77+
// process remaining local elements
78+
for (const localElement of localElements) {
79+
if (!added.has(localElement.id)) {
80+
reconciledElements.push(localElement);
81+
added.add(localElement.id);
82+
}
83+
}
84+
85+
// const orderedElements = orderByFractionalIndex(reconciledElements);
86+
87+
// validateIndicesThrottled(orderedElements, localElements, remoteElements);
88+
89+
// de-duplicate indices
90+
// syncInvalidIndices(orderedElements);
91+
92+
// return orderedElements as ReconciledExcalidrawElement[];
93+
return reconciledElements;
94+
};

0 commit comments

Comments
 (0)