Skip to content

Commit 3051276

Browse files
Server 4565 (#26)
* Config & safer connection (#4) * more logging * workflow changed * timeout operator changed * reverted back build-deploy-k8s-sandbox-azure.yml * reverted back build-deploy-k8s-sandbox-azure.yml * reverted back build-deploy-k8s-sandbox-azure.yml * auto-save time converted to milliseconds * throttled save config changed * wip z-index * rearranged types * handle changes in the file store * z-index * z-index, second try * clean code --------- Co-authored-by: Valentin Yanakiev <[email protected]>
1 parent 76cceb5 commit 3051276

22 files changed

+169
-57
lines changed

config.yml

+3-2
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ settings:
4141
# the window in which contributions are accepted to be counted towards a single contribution event;
4242
# time is in SECONDS
4343
contribution_window: ${CONTRIBUTION_WINDOW}:600
44-
# SECONDS between auto saves
45-
save_interval: ${AUTOSAVE_INTERVAL}:10
44+
# MILLISECONDS after the first change was made to the whiteboard before it's autosaved.
45+
# This is preventing saving on every change - instead, all the changes done in the last interval are saved only once.
46+
save_interval: ${AUTOSAVE_INTERVAL}:2000
4647
# SECONDS of inactivity before a collaborator is made view-only
4748
collaborator_inactivity: ${COLLABORATOR_INACTIVITY}:1800
4849
# how often the inactivity timer is reset;

src/excalidraw-backend/server.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ import {
1717
defaultSaveInterval,
1818
DISCONNECT,
1919
DISCONNECTING,
20-
ExcalidrawElement,
21-
ExcalidrawFileStore,
2220
IDLE_STATE,
2321
INIT_ROOM,
2422
InMemorySnapshot,
@@ -47,6 +45,8 @@ import {
4745
disconnectEventHandler,
4846
disconnectingEventHandler,
4947
idleStateEventHandler,
48+
isRoomId,
49+
prepareContentForSave,
5050
serverBroadcastEventHandler,
5151
serverVolatileBroadcastEventHandler,
5252
} from './utils';
@@ -56,6 +56,7 @@ import { isAbortError, jsonToArrayBuffer } from '../util';
5656
import { ConfigType } from '../config';
5757
import { tryDecodeIncoming } from './utils/decode.incoming';
5858
import { SceneInitPayload, ServerBroadcastPayload } from './types/events';
59+
import { ExcalidrawElement, ExcalidrawFileStore } from '../excalidraw/types';
5960
import { isSaveErrorData } from '../services/whiteboard-integration/outputs';
6061

6162
type RoomTrackers = Map<string, AbortController>;
@@ -106,7 +107,7 @@ export class Server {
106107
this.collaboratorInactivityMs =
107108
(collaborator_inactivity ?? defaultCollaboratorInactivity) * 1000;
108109

109-
this.saveIntervalMs = (save_interval ?? defaultSaveInterval) * 1000;
110+
this.saveIntervalMs = save_interval ?? defaultSaveInterval;
110111
}
111112

112113
private async fetchSocketsSafe(roomID: string) {
@@ -493,7 +494,7 @@ export class Server {
493494
}
494495
/**
495496
* Creates a new throttled save function for a room and stores it in __throttledSaveFnMap__.</br>
496-
* Called once after __wait__ milliseconds of the last received save request; Guaranteed save every __maxWait__ milliseconds;</br>
497+
* Called once immediately on the first invocation, then once after __wait__ milliseconds; Guaranteed save every __maxWait__ milliseconds;</br>
497498
* To be used when the room is created, and used only for that room.</br>
498499
* Use __cancelThrottledSave__ to cancel this function.</br>
499500
* Use __flushThrottledSave__ to invoke this function immediately.
@@ -508,7 +509,7 @@ export class Server {
508509
this.notifyRoomSaved(roomId);
509510
},
510511
wait,
511-
{ leading: false, trailing: true },
512+
{ leading: true, trailing: true },
512513
);
513514
this.throttledSaveFnMap.set(roomId, throttledSave);
514515

@@ -580,13 +581,12 @@ export class Server {
580581
return;
581582
}
582583

583-
const { data } = await this.utilService.save(roomId, snapshot.content);
584+
const cleanContent = prepareContentForSave(snapshot);
585+
const { data } = await this.utilService.save(roomId, cleanContent);
584586
if (isSaveErrorData(data)) {
585587
this.logger.error(`Failed to save room '${roomId}': ${data.error}`);
586588
} else {
587589
this.logger.verbose?.(`Room '${roomId}' saved successfully`);
588590
}
589591
}
590592
}
591-
// not that reliable, but best we can do
592-
const isRoomId = (id: string) => id.length === 36;

src/excalidraw-backend/types/defaults.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const defaultContributionInterval = 600;
2-
export const defaultSaveInterval = 10;
2+
export const defaultSaveInterval = 2000;
33
export const defaultCollaboratorInactivity = 60 * 30; // 30 minutes
44

55
export const resetCollaboratorModeDebounceWait = 1000;

src/excalidraw-backend/types/events/scene.init.payload.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { SCENE_INIT } from '../event.names';
22
import { DeepReadonly } from '../../utils';
3-
import { ExcalidrawElement } from '../excalidraw.element';
4-
import { ExcalidrawFileStore } from '../excalidraw.file';
3+
import { ExcalidrawElement } from '../../../excalidraw/types/excalidraw.element';
4+
import { ExcalidrawFileStore } from '../../../excalidraw/types/excalidraw.file';
55

66
export type SceneInitPayload = {
77
type: typeof SCENE_INIT;

src/excalidraw-backend/types/events/server.broadcast.payload.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ExcalidrawElement } from '../excalidraw.element';
2-
import { ExcalidrawFileStore } from '../excalidraw.file';
1+
import { ExcalidrawElement } from '../../../excalidraw/types/excalidraw.element';
2+
import { ExcalidrawFileStore } from '../../../excalidraw/types/excalidraw.file';
33
import { DeepReadonly } from '../../utils';
44

55
export type ServerBroadcastPayload = {

src/excalidraw-backend/types/excalidraw.element.ts

-14
This file was deleted.

src/excalidraw-backend/types/in.memory.snapshot.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { ExcalidrawContent } from './excalidraw.content';
1+
import { ExcalidrawContent } from '../../excalidraw/types/excalidraw.content';
22
import { DeepReadonly } from '../utils';
3-
import { ExcalidrawElement } from './excalidraw.element';
4-
import { ExcalidrawFileStore } from './excalidraw.file';
3+
import { ExcalidrawElement } from '../../excalidraw/types/excalidraw.element';
4+
import { ExcalidrawFileStore } from '../../excalidraw/types/excalidraw.file';
55
import { reconcileElements } from '../utils/reconcile';
66
import { reconcileFiles } from '../utils/reconcile.files';
77
/**

src/excalidraw-backend/types/index.ts

-4
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,4 @@ export * from './socket.io.socket';
1313
export * from './user.info.for.room';
1414
export * from './user.idle.state';
1515

16-
export * from './excalidraw.content';
17-
export * from './excalidraw.element';
18-
export * from './excalidraw.file';
19-
2016
export * from './in.memory.snapshot';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const arrayToMapBy = <T extends Record<string, any>>(
2+
items: readonly T[],
3+
key: keyof T,
4+
): Map<string, T> => {
5+
return items.reduce((acc: Map<string, T>, element) => {
6+
acc.set(element[key], element);
7+
return acc;
8+
}, new Map<string, T>());
9+
};

src/excalidraw-backend/utils/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
export * from './util';
22
export * from './handlers';
33
export * from './deep.readonly';
4+
export * from './is.room.id';
5+
export * from './prepare.content.for.save';
6+
export * from './array.to.map';
7+
export * from './array.to.map.by';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// not that reliable, but best we can do
2+
export const isRoomId = (id: string) => id.length === 36;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { InMemorySnapshot } from '../types';
2+
import { DeepReadonly } from './deep.readonly';
3+
import { ExcalidrawContent, ExcalidrawFileStore } from '../../excalidraw/types';
4+
import { isExcalidrawImageElement } from '../../util';
5+
6+
// todo: remove isDeleted, and maybe others
7+
export const prepareContentForSave = (
8+
snapshot: InMemorySnapshot,
9+
): DeepReadonly<ExcalidrawContent> => {
10+
const { files: fileStore, ...restOfContent } = snapshot.content;
11+
// clear files from the file store which are not referenced by elements
12+
const cleanStore: ExcalidrawFileStore = {};
13+
14+
for (const fileId of Object.keys(fileStore)) {
15+
// is there an element referencing the file
16+
const hostElement = restOfContent.elements.find(
17+
(el) => isExcalidrawImageElement(el) && el.fileId === fileId,
18+
);
19+
20+
if (hostElement) {
21+
// there is an element referencing the file - save the file
22+
cleanStore[fileId] = fileStore[fileId];
23+
}
24+
}
25+
26+
return {
27+
...restOfContent,
28+
files: cleanStore,
29+
};
30+
};

src/excalidraw-backend/utils/reconcile.files.ts

+18-13
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
import {
2-
ExcalidrawElement,
3-
ExcalidrawFileStore,
4-
ExcalidrawImageElement,
5-
} from '../types';
61
import { DeepReadonly } from './deep.readonly';
2+
import { ExcalidrawElement, ExcalidrawFileStore } from '../../excalidraw/types';
73

84
export const reconcileFiles = (
9-
localElements: readonly ExcalidrawElement[],
5+
_localElements: readonly ExcalidrawElement[],
106
localFileStore: DeepReadonly<ExcalidrawFileStore>,
117
remoteFileStore: DeepReadonly<ExcalidrawFileStore>,
128
): ExcalidrawFileStore => {
@@ -16,24 +12,33 @@ export const reconcileFiles = (
1612
// find a local file that matches the remote file
1713
// if it's already in - discard the remote
1814
if (reconciledFileStore[remoteFileId]) {
15+
// if the file already exists in the local store
16+
// update just the urls, because they might have been changed.
17+
// sometimes files get converted from dataURL to URL, and we would like to have the URL
18+
reconciledFileStore[remoteFileId] = {
19+
...reconciledFileStore[remoteFileId],
20+
url: remoteFileStore[remoteFileId].url,
21+
dataURL: remoteFileStore[remoteFileId].dataURL,
22+
};
1923
continue;
2024
}
25+
/** uncomment this when Excalidraw starts sending the element and the file at the same time
26+
* otherwise it's causing a bug where the file is sent but the element that should fit the image is not
27+
* then another event is sent with the host element but not the file
28+
* so to accommodate this we store the file for future use
29+
*/
2130
// if it's not found locally - check if it's used by any element
22-
const elementUsingTheFile = localElements.find(
31+
/*const elementUsingTheFile = localElements.find(
2332
(element) =>
2433
isExcalidrawImageElement(element) && element.fileId === remoteFileId,
2534
);
26-
// if it's not found locally and not used by any local element - discard the remote
35+
if it's not found locally and not used by any local element - discard the remote
2736
if (!elementUsingTheFile) {
2837
continue;
29-
}
38+
}*/
3039
// if it's not found locally but used by a local element - add it to the list of reconciled files
3140
reconciledFileStore[remoteFileId] = remoteFileStore[remoteFileId];
3241
}
3342

3443
return reconciledFileStore;
3544
};
36-
37-
const isExcalidrawImageElement = (
38-
element: ExcalidrawElement,
39-
): element is ExcalidrawImageElement => element.type === 'image';

src/excalidraw-backend/utils/reconcile.ts

+53-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { ExcalidrawElement } from '../types';
21
import { arrayToMap } from './array.to.map';
2+
import { ExcalidrawElement } from '../../excalidraw/types';
3+
import { arrayToMapBy } from './array.to.map.by';
4+
import { Logger } from '@nestjs/common';
35
// import { orderByFractionalIndex, syncInvalidIndices } from './fractionalIndex';
46

57
const shouldDiscardRemoteElement = (
@@ -48,6 +50,8 @@ const shouldDiscardRemoteElement = (
4850
{ leading: true, trailing: false },
4951
);*/
5052

53+
const logger = new Logger('reconcileElements');
54+
5155
export const reconcileElements = (
5256
localElements: readonly ExcalidrawElement[],
5357
remoteElements: readonly ExcalidrawElement[],
@@ -83,8 +87,6 @@ export const reconcileElements = (
8387
}
8488
}
8589

86-
// const orderedElements = orderByFractionalIndex(reconciledElements);
87-
8890
/**
8991
* todo: not sure how important is this and how does it affect the end result
9092
* since the debounce is set to 60 seconds, which might mean that the room has already closed
@@ -94,7 +96,53 @@ export const reconcileElements = (
9496

9597
// de-duplicate indices
9698
// const syncedElemented = syncInvalidIndices(orderedElements);
99+
try {
100+
return orderByPrecedingElement(reconciledElements);
101+
} catch (error) {
102+
logger.error(error.message);
103+
return reconciledElements;
104+
}
105+
};
106+
107+
const orderByPrecedingElement = (
108+
unOrderedElements: ExcalidrawElement[],
109+
): ExcalidrawElement[] | never => {
110+
// for zero or one element return the same array, as it's already sorted
111+
if (unOrderedElements.length < 2) {
112+
return unOrderedElements;
113+
}
114+
// validated there is just one starting element
115+
const startElements = unOrderedElements.filter(
116+
(el) => el.__precedingElement__ === '^',
117+
);
118+
119+
if (startElements.length !== 1) {
120+
throw new Error(
121+
`There must be exactly one element with __precedingElement__ = '^'`,
122+
);
123+
}
124+
// create a map of elements by <__precedingElement__, element that has this __precedingElement__ value>
125+
// for easy access
126+
const elementMapByPrecedingKey = arrayToMapBy(
127+
unOrderedElements,
128+
'__precedingElement__',
129+
);
130+
const orderedElements: ExcalidrawElement[] = [];
131+
// the array is starting with element that has no preceding element
132+
let parentElement = startElements[0];
133+
orderedElements.push(parentElement);
134+
// Follow the chain of __precedingElement__
135+
while (true) {
136+
const childElement = elementMapByPrecedingKey.get(parentElement.id);
137+
138+
if (!childElement) {
139+
// we have reached the end of the chain
140+
break;
141+
}
142+
143+
orderedElements.push(childElement);
144+
parentElement = childElement;
145+
}
97146

98-
// return orderedElements as ReconciledExcalidrawElement[];
99-
return reconciledElements;
147+
return orderedElements;
100148
};
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
type ExcalidrawBaseElement = {
2+
id: string;
3+
// index: number; // still not available in 0.17.0
4+
type: string; // many types
5+
version: number;
6+
versionNonce: number;
7+
/**
8+
* used for sorting the array;
9+
* Excalidraw uses a sorted array to simulate z-index behavior by
10+
* drawing the elements in order, starting from the first element
11+
* TODO: to be removed once we update the version of Excalidraw, where they are removing this field and introduce a new field called "index"
12+
*/
13+
__precedingElement__: string;
14+
};
15+
16+
export type ExcalidrawImageElement = ExcalidrawBaseElement & {
17+
type: 'image';
18+
fileId: string;
19+
};
20+
21+
export type ExcalidrawElement = ExcalidrawBaseElement | ExcalidrawImageElement;

src/excalidraw-backend/types/excalidraw.file.ts src/excalidraw/types/excalidraw.file.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ export type ExcalidrawFile = {
33
id: string;
44
created: number;
55
lastRetrieved: number;
6-
url: string;
6+
url?: string;
7+
dataURL: string;
78
};
89

910
export type ExcalidrawFileStore = {

src/excalidraw/types/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './excalidraw.element';
2+
export * from './excalidraw.content';
3+
export * from './excalidraw.file';

src/services/util/util.service.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
SaveInputData,
1010
WhoInputData,
1111
} from '../whiteboard-integration/inputs';
12-
import { ExcalidrawContent } from '../../excalidraw-backend/types';
12+
import { ExcalidrawContent } from '../../excalidraw/types';
1313
import { isFetchErrorData } from '../whiteboard-integration/outputs';
1414
import { excalidrawInitContent } from '../../util';
1515
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';

src/util/excalidraw.init.data.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ExcalidrawContent } from '../excalidraw-backend/types';
1+
import { ExcalidrawContent } from '../excalidraw/types';
22

33
export const excalidrawInitContent: ExcalidrawContent = {
44
appState: {},

src/util/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './is.abort.error';
22
export * from './excalidraw.init.data';
3+
export * from './is.excalidraw.image.element';
34

45
export * from './json-to-arraybuffer/json.to.arraybuffer';
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ExcalidrawElement, ExcalidrawImageElement } from '../excalidraw/types';
2+
3+
export const isExcalidrawImageElement = (
4+
element: ExcalidrawElement,
5+
): element is ExcalidrawImageElement => element.type === 'image';

0 commit comments

Comments
 (0)