|
|
|
@ -1,11 +1,9 @@
|
|
|
|
|
/* eslint-disable no-console */
|
|
|
|
|
import throttle from "lodash.throttle";
|
|
|
|
|
import msgpack from "msgpack-lite";
|
|
|
|
|
import ReconnectingWebSocket, {
|
|
|
|
|
type Event,
|
|
|
|
|
type CloseEvent,
|
|
|
|
|
} from "reconnecting-websocket";
|
|
|
|
|
import { Utils } from "./utils";
|
|
|
|
|
import { Network, Utils } from "./utils";
|
|
|
|
|
import {
|
|
|
|
|
LocalDeltasQueue,
|
|
|
|
|
type MetadataRepository,
|
|
|
|
@ -16,16 +14,17 @@ import type { StoreChange } from "../store";
|
|
|
|
|
import type { ExcalidrawImperativeAPI } from "../types";
|
|
|
|
|
import type { ExcalidrawElement, SceneElementsMap } from "../element/types";
|
|
|
|
|
import type {
|
|
|
|
|
CLIENT_MESSAGE_RAW,
|
|
|
|
|
SERVER_DELTA,
|
|
|
|
|
CHANGE,
|
|
|
|
|
CLIENT_CHANGE,
|
|
|
|
|
SERVER_MESSAGE,
|
|
|
|
|
CLIENT_MESSAGE_BINARY,
|
|
|
|
|
} from "./protocol";
|
|
|
|
|
import { debounce } from "../utils";
|
|
|
|
|
import { randomId } from "../random";
|
|
|
|
|
import { orderByFractionalIndex } from "../fractionalIndex";
|
|
|
|
|
import { ENV } from "../constants";
|
|
|
|
|
|
|
|
|
|
class SocketMessage implements CLIENT_MESSAGE_RAW {
|
|
|
|
|
class SocketMessage implements CLIENT_MESSAGE_BINARY {
|
|
|
|
|
constructor(
|
|
|
|
|
public readonly type: "relay" | "pull" | "push",
|
|
|
|
|
public readonly payload: Uint8Array,
|
|
|
|
@ -77,6 +76,7 @@ class SocketClient {
|
|
|
|
|
window.addEventListener("online", this.onOnline);
|
|
|
|
|
window.addEventListener("offline", this.onOffline);
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.debug(`Connecting to the room "${this.roomId}"...`);
|
|
|
|
|
this.socket = new ReconnectingWebSocket(
|
|
|
|
|
`${this.host}/connect?roomId=${this.roomId}`,
|
|
|
|
@ -103,7 +103,6 @@ class SocketClient {
|
|
|
|
|
{ leading: true, trailing: false },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// CFDO: the connections seem to keep hanging for some reason
|
|
|
|
|
public disconnect() {
|
|
|
|
|
if (this.isDisconnected) {
|
|
|
|
|
return;
|
|
|
|
@ -119,6 +118,7 @@ class SocketClient {
|
|
|
|
|
this.socket?.removeEventListener("error", this.onError);
|
|
|
|
|
this.socket?.close();
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.debug(`Disconnected from the room "${this.roomId}".`);
|
|
|
|
|
} finally {
|
|
|
|
|
this.socket = null;
|
|
|
|
@ -135,7 +135,6 @@ class SocketClient {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CFDO: could be closed / closing / connecting
|
|
|
|
|
if (this.isDisconnected) {
|
|
|
|
|
this.connect();
|
|
|
|
|
return;
|
|
|
|
@ -143,10 +142,14 @@ class SocketClient {
|
|
|
|
|
|
|
|
|
|
const { type, payload } = message;
|
|
|
|
|
|
|
|
|
|
// CFDO II: could be slowish for large payloads, thing about a better solution (i.e. msgpack 10x faster, 2x smaller)
|
|
|
|
|
const payloadBuffer = msgpack.encode(payload) as Uint8Array;
|
|
|
|
|
const payloadBuffer = Network.toBinary(payload);
|
|
|
|
|
const payloadSize = payloadBuffer.byteLength;
|
|
|
|
|
|
|
|
|
|
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.debug("send", message, payloadSize);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payloadSize < SocketClient.MAX_MESSAGE_SIZE) {
|
|
|
|
|
const message = new SocketMessage(type, payloadBuffer);
|
|
|
|
|
return this.sendMessage(message);
|
|
|
|
@ -176,86 +179,55 @@ class SocketClient {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) {
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.debug("onMessage", message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.handlers.onMessage(message);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private onOpen = (event: Event) => {
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.debug(`Connection to the room "${this.roomId}" opened.`);
|
|
|
|
|
this.isOffline = false;
|
|
|
|
|
this.handlers.onOpen(event);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private onClose = (event: CloseEvent) => {
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.debug(`Connection to the room "${this.roomId}" closed.`, event);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private onError = (event: Event) => {
|
|
|
|
|
console.debug(
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.error(
|
|
|
|
|
`Connection to the room "${this.roomId}" returned an error.`,
|
|
|
|
|
event,
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private sendMessage = ({ payload, ...metadata }: CLIENT_MESSAGE_RAW) => {
|
|
|
|
|
const metadataBuffer = msgpack.encode(metadata) as Uint8Array;
|
|
|
|
|
|
|
|
|
|
// contains the length of the rest of the message, so that we could decode it server side
|
|
|
|
|
const headerBuffer = new ArrayBuffer(4);
|
|
|
|
|
new DataView(headerBuffer).setUint32(0, metadataBuffer.byteLength);
|
|
|
|
|
|
|
|
|
|
// concatenate into [header(4 bytes)][metadata][payload]
|
|
|
|
|
const message = Uint8Array.from([
|
|
|
|
|
...new Uint8Array(headerBuffer),
|
|
|
|
|
...metadataBuffer,
|
|
|
|
|
...payload,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// CFDO: add dev-level logging
|
|
|
|
|
{
|
|
|
|
|
const headerLength = 4;
|
|
|
|
|
const header = new Uint8Array(message.buffer, 0, headerLength);
|
|
|
|
|
const metadataLength = new DataView(
|
|
|
|
|
header.buffer,
|
|
|
|
|
header.byteOffset,
|
|
|
|
|
).getUint32(0);
|
|
|
|
|
|
|
|
|
|
const metadata = new Uint8Array(
|
|
|
|
|
message.buffer,
|
|
|
|
|
headerLength,
|
|
|
|
|
headerLength + metadataLength,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const payload = new Uint8Array(
|
|
|
|
|
message.buffer,
|
|
|
|
|
headerLength + metadataLength,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
console.log({
|
|
|
|
|
...msgpack.decode(metadata),
|
|
|
|
|
payload,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.socket?.send(message);
|
|
|
|
|
private sendMessage = (message: CLIENT_MESSAGE_BINARY) => {
|
|
|
|
|
this.socket?.send(Network.encodeClientMessage(message));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// CFDO: should be (runtime) type-safe
|
|
|
|
|
private async receiveMessage(
|
|
|
|
|
message: Blob,
|
|
|
|
|
): Promise<SERVER_MESSAGE | undefined> {
|
|
|
|
|
const arrayBuffer = await message.arrayBuffer();
|
|
|
|
|
const uint8Array = new Uint8Array(arrayBuffer);
|
|
|
|
|
|
|
|
|
|
const [decodedMessage, decodeError] = Utils.try<SERVER_MESSAGE>(() =>
|
|
|
|
|
msgpack.decode(uint8Array),
|
|
|
|
|
const [decodedMessage, decodingError] = Utils.try<SERVER_MESSAGE>(() =>
|
|
|
|
|
Network.fromBinary(uint8Array),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (decodeError) {
|
|
|
|
|
if (decodingError) {
|
|
|
|
|
console.error("Failed to decode message:", message);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CFDO: should be type-safe
|
|
|
|
|
return decodedMessage;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@ -285,7 +257,7 @@ export class SyncClient {
|
|
|
|
|
>();
|
|
|
|
|
|
|
|
|
|
// #region ACKNOWLEDGED DELTAS & METADATA
|
|
|
|
|
// CFDO: shouldn't be stateful, only request / response
|
|
|
|
|
// CFDO II: shouldn't be stateful, only request / response
|
|
|
|
|
private readonly acknowledgedDeltasMap: Map<string, AcknowledgedDelta> =
|
|
|
|
|
new Map();
|
|
|
|
|
|
|
|
|
@ -336,7 +308,7 @@ export class SyncClient {
|
|
|
|
|
return new SyncClient(api, repository, queue, {
|
|
|
|
|
host: SyncClient.HOST_URL,
|
|
|
|
|
roomId: roomId ?? SyncClient.ROOM_ID,
|
|
|
|
|
// CFDO: temporary, so that all deltas are loaded and applied on init
|
|
|
|
|
// CFDO II: temporary, so that all deltas are loaded and applied on init
|
|
|
|
|
lastAcknowledgedVersion: 0,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
@ -377,7 +349,7 @@ export class SyncClient {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CFDO: should be throttled! 60 fps for live scenes, 10s or so for single player
|
|
|
|
|
// CFDO: should be throttled! 16ms (60 fps) for live scenes, not needed at all for single player
|
|
|
|
|
public relay(change: StoreChange): void {
|
|
|
|
|
if (this.client.isDisconnected) {
|
|
|
|
|
// don't reconnect if we're explicitly disconnected
|
|
|
|
@ -414,7 +386,7 @@ export class SyncClient {
|
|
|
|
|
|
|
|
|
|
// #region PRIVATE SOCKET MESSAGE HANDLERS
|
|
|
|
|
private onOpen = (event: Event) => {
|
|
|
|
|
// CFDO: hack to pull everything for on init
|
|
|
|
|
// CFDO II: hack to pull everything for on init
|
|
|
|
|
this.pull(0);
|
|
|
|
|
this.push();
|
|
|
|
|
};
|
|
|
|
@ -425,9 +397,8 @@ export class SyncClient {
|
|
|
|
|
this.push();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private onMessage = ({ type, payload }: SERVER_MESSAGE) => {
|
|
|
|
|
// CFDO: add dev-level logging
|
|
|
|
|
console.log({ type, payload });
|
|
|
|
|
private onMessage = (serverMessage: SERVER_MESSAGE) => {
|
|
|
|
|
const { type, payload } = serverMessage;
|
|
|
|
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "relayed":
|
|
|
|
@ -441,8 +412,8 @@ export class SyncClient {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private handleRelayed = (payload: CHANGE) => {
|
|
|
|
|
// CFDO: retrieve the map already
|
|
|
|
|
private handleRelayed = (payload: CLIENT_CHANGE) => {
|
|
|
|
|
// CFDO I: retrieve the map already
|
|
|
|
|
const nextElements = new Map(
|
|
|
|
|
this.api.getSceneElementsIncludingDeleted().map((el) => [el.id, el]),
|
|
|
|
|
) as SceneElementsMap;
|
|
|
|
@ -457,7 +428,6 @@ export class SyncClient {
|
|
|
|
|
!existingElement || // new element
|
|
|
|
|
existingElement.version < relayedElement.version // updated element
|
|
|
|
|
) {
|
|
|
|
|
// CFDO: in theory could make the yet unsynced element (due to a bug) to move to the top
|
|
|
|
|
nextElements.set(id, relayedElement);
|
|
|
|
|
this.relayedElementsVersionsCache.set(id, relayedElement.version);
|
|
|
|
|
}
|
|
|
|
@ -492,7 +462,7 @@ export class SyncClient {
|
|
|
|
|
) as SceneElementsMap;
|
|
|
|
|
|
|
|
|
|
for (const { id, version, payload } of remoteDeltas) {
|
|
|
|
|
// CFDO: temporary to load all deltas on init
|
|
|
|
|
// CFDO II: temporary to load all deltas on init
|
|
|
|
|
this.acknowledgedDeltasMap.set(id, {
|
|
|
|
|
delta: StoreDelta.load(payload),
|
|
|
|
|
version,
|
|
|
|
|