diff --git a/backend/src/realtime/websocket/websocket.gateway.ts b/backend/src/realtime/websocket/websocket.gateway.ts index 48ad651ac..e104829ed 100644 --- a/backend/src/realtime/websocket/websocket.gateway.ts +++ b/backend/src/realtime/websocket/websocket.gateway.ts @@ -3,7 +3,11 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { WebsocketTransporter } from '@hedgedoc/commons'; +import { + CborMessageEncoder, + JsonMessageEncoder, + WebsocketTransporter, +} from '@hedgedoc/commons'; import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets'; import { IncomingMessage } from 'http'; import WebSocket from 'ws'; @@ -76,7 +80,12 @@ export class WebsocketGateway implements OnGatewayConnection { const realtimeNote = await this.realtimeNoteService.getOrCreateRealtimeNote(note); - const websocketTransporter = new WebsocketTransporter(); + const messageEncoder = + process.env.NODE_ENV === 'development' + ? new JsonMessageEncoder() + : new CborMessageEncoder(); + + const websocketTransporter = new WebsocketTransporter(messageEncoder); const connection = new RealtimeConnection( websocketTransporter, user, diff --git a/commons/package.json b/commons/package.json index 68523a0f0..4e49a9700 100644 --- a/commons/package.json +++ b/commons/package.json @@ -38,6 +38,7 @@ "url": "https://github.com/hedgedoc/hedgedoc.git" }, "dependencies": { + "cbor-x": "1.5.1", "eventemitter2": "6.4.9", "isomorphic-ws": "5.0.0", "reveal.js": "4.4.0", diff --git a/commons/src/index.ts b/commons/src/index.ts index d416c8a20..ce235fe0e 100644 --- a/commons/src/index.ts +++ b/commons/src/index.ts @@ -10,6 +10,10 @@ export * from './message-transporters/message-transporter.js' export * from './message-transporters/realtime-user.js' export * from './message-transporters/websocket-transporter.js' +export * from './message-encoders/message-encoder.js' +export * from './message-encoders/cbor-message-encoder.js' +export * from './message-encoders/json-message-encoder.js' + export { parseUrl } from './utils/parse-url.js' export { MissingTrailingSlashError, diff --git a/commons/src/message-encoders/cbor-message-encoder.ts b/commons/src/message-encoders/cbor-message-encoder.ts new file mode 100644 index 000000000..4136abf61 --- /dev/null +++ b/commons/src/message-encoders/cbor-message-encoder.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Message, MessageType } from '../message-transporters/message.js' +import { MessageEncoder } from './message-encoder.js' +import { Encoder, Decoder } from 'cbor-x' + +interface ReceivedMessage { + type: number + payload: unknown +} + +const keyMap = { + type: 0, + payload: 1, + users: 2, + ownUser: 3, + displayName: 4, + styleIndex: 5, + active: 6, + username: 7, + cursor: 8, + from: 9, + to: 10 +} + +const messageTypes = Object.values(MessageType) + +export class CborMessageEncoder implements MessageEncoder { + private readonly encoder: Encoder = new Encoder({ + keyMap + }) + private readonly decoder: Decoder = new Decoder({ + keyMap + }) + + encode(message: Message): Uint8Array { + const type = messageTypes.indexOf(message.type) + return this.encoder.encode({ + ...message, + type + }) + } + + decode(message: ArrayBuffer): Message { + const uint8Array = new Uint8Array(message) + const decoded = this.decoder.decode(uint8Array) as ReceivedMessage + const type = messageTypes[decoded.type] + return { + ...decoded, + type + } as Message + } +} diff --git a/commons/src/message-encoders/json-message-encoder.ts b/commons/src/message-encoders/json-message-encoder.ts new file mode 100644 index 000000000..d3252c109 --- /dev/null +++ b/commons/src/message-encoders/json-message-encoder.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Message, MessageType } from '../message-transporters/message.js' +import { MessageEncoder } from './message-encoder.js' + +export class JsonMessageEncoder implements MessageEncoder { + public encode(message: Message): string { + return JSON.stringify(message) + } + + public decode(message: string): Message { + return JSON.parse(message) as Message + } +} diff --git a/commons/src/message-encoders/message-encoder.ts b/commons/src/message-encoders/message-encoder.ts new file mode 100644 index 000000000..60e557ace --- /dev/null +++ b/commons/src/message-encoders/message-encoder.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { Message, MessageType } from '../message-transporters/message.js' +import WebSocket from 'isomorphic-ws' + +export abstract class MessageEncoder { + public abstract encode(message: Message): WebSocket.Data + + public abstract decode(message: WebSocket.Data): Message +} diff --git a/commons/src/message-transporters/message.ts b/commons/src/message-transporters/message.ts index a93c394d4..c9b59fd9b 100644 --- a/commons/src/message-transporters/message.ts +++ b/commons/src/message-transporters/message.ts @@ -6,19 +6,22 @@ import { RealtimeUser, RemoteCursor } from './realtime-user.js' export enum MessageType { - NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST', + // This enum is sorted by frequency of usage for efficient binary encoding + REALTIME_USER_STATE_SET = 'REALTIME_USER_STATE_SET', + REALTIME_USER_SET_ACTIVITY = 'REALTIME_USER_SET_ACTIVITY', + REALTIME_USER_SINGLE_UPDATE = 'REALTIME_USER_SINGLE_UPDATE', NOTE_CONTENT_UPDATE = 'NOTE_CONTENT_UPDATE', + + NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST', + REALTIME_USER_STATE_REQUEST = 'REALTIME_USER_STATE_REQUEST', + PING = 'PING', PONG = 'PONG', + READY = 'READY', + METADATA_UPDATED = 'METADATA_UPDATED', DOCUMENT_DELETED = 'DOCUMENT_DELETED', - SERVER_VERSION_UPDATED = 'SERVER_VERSION_UPDATED', - REALTIME_USER_STATE_SET = 'REALTIME_USER_STATE_SET', - REALTIME_USER_SINGLE_UPDATE = 'REALTIME_USER_SINGLE_UPDATE', - REALTIME_USER_STATE_REQUEST = 'REALTIME_USER_STATE_REQUEST', - REALTIME_USER_SET_ACTIVITY = 'REALTIME_USER_SET_ACTIVITY', - - READY = 'READY' + SERVER_VERSION_UPDATED = 'SERVER_VERSION_UPDATED' } export interface MessagePayloads { diff --git a/commons/src/message-transporters/websocket-transporter.ts b/commons/src/message-transporters/websocket-transporter.ts index bf1e629d2..d51125fbc 100644 --- a/commons/src/message-transporters/websocket-transporter.ts +++ b/commons/src/message-transporters/websocket-transporter.ts @@ -3,6 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ +import { MessageEncoder } from '../message-encoders/message-encoder.js' import { ConnectionState, MessageTransporter } from './message-transporter.js' import { Message, MessageType } from './message.js' import WebSocket, { MessageEvent } from 'isomorphic-ws' @@ -13,7 +14,7 @@ export class WebsocketTransporter extends MessageTransporter { private messageCallback: undefined | ((event: MessageEvent) => void) private closeCallback: undefined | (() => void) - constructor() { + constructor(private readonly encoder: MessageEncoder) { super() } @@ -57,10 +58,7 @@ export class WebsocketTransporter extends MessageTransporter { } private processMessageEvent(event: MessageEvent): void { - if (typeof event.data !== 'string') { - return - } - const message = JSON.parse(event.data) as Message + const message = this.encoder.decode(event.data) this.receiveMessage(message) } @@ -92,7 +90,8 @@ export class WebsocketTransporter extends MessageTransporter { } try { - this.websocket.send(JSON.stringify(content)) + const encoded = this.encoder.encode(content) + this.websocket.send(encoded) } catch (error: unknown) { this.disconnect() throw error diff --git a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts index dacdd29c6..dc2a0ebc5 100644 --- a/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts +++ b/frontend/src/components/editor-page/editor-pane/hooks/yjs/use-realtime-connection.ts @@ -7,10 +7,15 @@ import { useApplicationState } from '../../../../../hooks/common/use-application import { getGlobalState } from '../../../../../redux' import { setRealtimeConnectionState } from '../../../../../redux/realtime/methods' import { Logger } from '../../../../../utils/logger' -import { isMockMode } from '../../../../../utils/test-modes' +import { isMockMode, isDevMode } from '../../../../../utils/test-modes' import { useWebsocketUrl } from './use-websocket-url' import type { MessageTransporter } from '@hedgedoc/commons' -import { MockedBackendMessageTransporter, WebsocketTransporter } from '@hedgedoc/commons' +import { + CborMessageEncoder, + JsonMessageEncoder, + MockedBackendMessageTransporter, + WebsocketTransporter +} from '@hedgedoc/commons' import type { Listener } from 'eventemitter2' import WebSocket from 'isomorphic-ws' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -31,7 +36,8 @@ export const useRealtimeConnection = (): MessageTransporter => { return new MockedBackendMessageTransporter(getGlobalState().noteDetails.markdownContent.plain) } else { logger.debug('Creating Websocket connection...') - return new WebsocketTransporter() + const encoder = isDevMode ? new JsonMessageEncoder() : new CborMessageEncoder() + return new WebsocketTransporter(encoder) } }, []) @@ -39,6 +45,7 @@ export const useRealtimeConnection = (): MessageTransporter => { if (messageTransporter instanceof WebsocketTransporter && websocketUrl) { logger.debug(`Connecting to ${websocketUrl.toString()}`) const socket = new WebSocket(websocketUrl) + socket.binaryType = 'arraybuffer' socket.addEventListener('error', () => { setTimeout(() => { establishWebsocketConnection() diff --git a/yarn.lock b/yarn.lock index c79fd2b18..8c2e5e79c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1702,6 +1702,48 @@ __metadata: languageName: node linkType: hard +"@cbor-extract/cbor-extract-darwin-arm64@npm:2.1.1": + version: 2.1.1 + resolution: "@cbor-extract/cbor-extract-darwin-arm64@npm:2.1.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@cbor-extract/cbor-extract-darwin-x64@npm:2.1.1": + version: 2.1.1 + resolution: "@cbor-extract/cbor-extract-darwin-x64@npm:2.1.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@cbor-extract/cbor-extract-linux-arm64@npm:2.1.1": + version: 2.1.1 + resolution: "@cbor-extract/cbor-extract-linux-arm64@npm:2.1.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@cbor-extract/cbor-extract-linux-arm@npm:2.1.1": + version: 2.1.1 + resolution: "@cbor-extract/cbor-extract-linux-arm@npm:2.1.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@cbor-extract/cbor-extract-linux-x64@npm:2.1.1": + version: 2.1.1 + resolution: "@cbor-extract/cbor-extract-linux-x64@npm:2.1.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@cbor-extract/cbor-extract-win32-x64@npm:2.1.1": + version: 2.1.1 + resolution: "@cbor-extract/cbor-extract-win32-x64@npm:2.1.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@codemirror/autocomplete@npm:6.4.2": version: 6.4.2 resolution: "@codemirror/autocomplete@npm:6.4.2" @@ -2320,6 +2362,7 @@ __metadata: "@types/ws": 8.5.4 "@typescript-eslint/eslint-plugin": 5.57.0 "@typescript-eslint/parser": 5.57.0 + cbor-x: 1.5.1 eslint: 8.37.0 eslint-config-prettier: 8.8.0 eslint-plugin-jest: 27.2.1 @@ -6753,6 +6796,49 @@ __metadata: languageName: node linkType: hard +"cbor-extract@npm:^2.1.1": + version: 2.1.1 + resolution: "cbor-extract@npm:2.1.1" + dependencies: + "@cbor-extract/cbor-extract-darwin-arm64": 2.1.1 + "@cbor-extract/cbor-extract-darwin-x64": 2.1.1 + "@cbor-extract/cbor-extract-linux-arm": 2.1.1 + "@cbor-extract/cbor-extract-linux-arm64": 2.1.1 + "@cbor-extract/cbor-extract-linux-x64": 2.1.1 + "@cbor-extract/cbor-extract-win32-x64": 2.1.1 + node-gyp: latest + node-gyp-build-optional-packages: 5.0.3 + dependenciesMeta: + "@cbor-extract/cbor-extract-darwin-arm64": + optional: true + "@cbor-extract/cbor-extract-darwin-x64": + optional: true + "@cbor-extract/cbor-extract-linux-arm": + optional: true + "@cbor-extract/cbor-extract-linux-arm64": + optional: true + "@cbor-extract/cbor-extract-linux-x64": + optional: true + "@cbor-extract/cbor-extract-win32-x64": + optional: true + bin: + download-cbor-prebuilds: bin/download-prebuilds.js + checksum: 283d9cdb3c716b171b5ad8666673f4ac373f975b51d9a38233d280c6f9381d66c6af4c011a561d993c4be6e427e34681bc3c5af194b9da0c9ab3401d424b7988 + languageName: node + linkType: hard + +"cbor-x@npm:1.5.1": + version: 1.5.1 + resolution: "cbor-x@npm:1.5.1" + dependencies: + cbor-extract: ^2.1.1 + dependenciesMeta: + cbor-extract: + optional: true + checksum: e4ff6012194e93739a36027a2d8abbe4e98f62c003c77896bed1c22bf29782fe371480c7db37a0fc288a1a56cb0b711e48072ed6f42d51bdf96395a1ed01bcea + languageName: node + linkType: hard + "chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -13816,6 +13902,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build-optional-packages@npm:5.0.3": + version: 5.0.3 + resolution: "node-gyp-build-optional-packages@npm:5.0.3" + bin: + node-gyp-build-optional-packages: bin.js + node-gyp-build-optional-packages-optional: optional.js + node-gyp-build-optional-packages-test: build-test.js + checksum: be3f0235925c8361e5bc1a03848f5e24815b0df8aa90bd13f1eac91cd86264bbb8b7689ca6cd083b02c8099c7b54f9fb83066c7bb77c2389dc4eceab921f084f + languageName: node + linkType: hard + "node-gyp@npm:8.x": version: 8.4.1 resolution: "node-gyp@npm:8.4.1"