feat(realtime): use CBOR encoding in production mode

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2023-03-30 01:40:16 +02:00
parent 8a66031ff3
commit 7f8add6cd4
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
10 changed files with 225 additions and 19 deletions

View file

@ -3,7 +3,11 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * 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 { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets';
import { IncomingMessage } from 'http'; import { IncomingMessage } from 'http';
import WebSocket from 'ws'; import WebSocket from 'ws';
@ -76,7 +80,12 @@ export class WebsocketGateway implements OnGatewayConnection {
const realtimeNote = const realtimeNote =
await this.realtimeNoteService.getOrCreateRealtimeNote(note); 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( const connection = new RealtimeConnection(
websocketTransporter, websocketTransporter,
user, user,

View file

@ -38,6 +38,7 @@
"url": "https://github.com/hedgedoc/hedgedoc.git" "url": "https://github.com/hedgedoc/hedgedoc.git"
}, },
"dependencies": { "dependencies": {
"cbor-x": "1.5.1",
"eventemitter2": "6.4.9", "eventemitter2": "6.4.9",
"isomorphic-ws": "5.0.0", "isomorphic-ws": "5.0.0",
"reveal.js": "4.4.0", "reveal.js": "4.4.0",

View file

@ -10,6 +10,10 @@ export * from './message-transporters/message-transporter.js'
export * from './message-transporters/realtime-user.js' export * from './message-transporters/realtime-user.js'
export * from './message-transporters/websocket-transporter.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 { parseUrl } from './utils/parse-url.js'
export { export {
MissingTrailingSlashError, MissingTrailingSlashError,

View file

@ -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<MessageType>): Uint8Array {
const type = messageTypes.indexOf(message.type)
return this.encoder.encode({
...message,
type
})
}
decode(message: ArrayBuffer): Message<MessageType> {
const uint8Array = new Uint8Array(message)
const decoded = this.decoder.decode(uint8Array) as ReceivedMessage
const type = messageTypes[decoded.type]
return {
...decoded,
type
} as Message<MessageType>
}
}

View file

@ -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<MessageType>): string {
return JSON.stringify(message)
}
public decode(message: string): Message<MessageType> {
return JSON.parse(message) as Message<MessageType>
}
}

View file

@ -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<MessageType>): WebSocket.Data
public abstract decode(message: WebSocket.Data): Message<MessageType>
}

View file

@ -6,19 +6,22 @@
import { RealtimeUser, RemoteCursor } from './realtime-user.js' import { RealtimeUser, RemoteCursor } from './realtime-user.js'
export enum MessageType { 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_UPDATE = 'NOTE_CONTENT_UPDATE',
NOTE_CONTENT_STATE_REQUEST = 'NOTE_CONTENT_STATE_REQUEST',
REALTIME_USER_STATE_REQUEST = 'REALTIME_USER_STATE_REQUEST',
PING = 'PING', PING = 'PING',
PONG = 'PONG', PONG = 'PONG',
READY = 'READY',
METADATA_UPDATED = 'METADATA_UPDATED', METADATA_UPDATED = 'METADATA_UPDATED',
DOCUMENT_DELETED = 'DOCUMENT_DELETED', DOCUMENT_DELETED = 'DOCUMENT_DELETED',
SERVER_VERSION_UPDATED = 'SERVER_VERSION_UPDATED', 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'
} }
export interface MessagePayloads { export interface MessagePayloads {

View file

@ -3,6 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { MessageEncoder } from '../message-encoders/message-encoder.js'
import { ConnectionState, MessageTransporter } from './message-transporter.js' import { ConnectionState, MessageTransporter } from './message-transporter.js'
import { Message, MessageType } from './message.js' import { Message, MessageType } from './message.js'
import WebSocket, { MessageEvent } from 'isomorphic-ws' import WebSocket, { MessageEvent } from 'isomorphic-ws'
@ -13,7 +14,7 @@ export class WebsocketTransporter extends MessageTransporter {
private messageCallback: undefined | ((event: MessageEvent) => void) private messageCallback: undefined | ((event: MessageEvent) => void)
private closeCallback: undefined | (() => void) private closeCallback: undefined | (() => void)
constructor() { constructor(private readonly encoder: MessageEncoder) {
super() super()
} }
@ -57,10 +58,7 @@ export class WebsocketTransporter extends MessageTransporter {
} }
private processMessageEvent(event: MessageEvent): void { private processMessageEvent(event: MessageEvent): void {
if (typeof event.data !== 'string') { const message = this.encoder.decode(event.data)
return
}
const message = JSON.parse(event.data) as Message<MessageType>
this.receiveMessage(message) this.receiveMessage(message)
} }
@ -92,7 +90,8 @@ export class WebsocketTransporter extends MessageTransporter {
} }
try { try {
this.websocket.send(JSON.stringify(content)) const encoded = this.encoder.encode(content)
this.websocket.send(encoded)
} catch (error: unknown) { } catch (error: unknown) {
this.disconnect() this.disconnect()
throw error throw error

View file

@ -7,10 +7,15 @@ import { useApplicationState } from '../../../../../hooks/common/use-application
import { getGlobalState } from '../../../../../redux' import { getGlobalState } from '../../../../../redux'
import { setRealtimeConnectionState } from '../../../../../redux/realtime/methods' import { setRealtimeConnectionState } from '../../../../../redux/realtime/methods'
import { Logger } from '../../../../../utils/logger' 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 { useWebsocketUrl } from './use-websocket-url'
import type { MessageTransporter } from '@hedgedoc/commons' 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 type { Listener } from 'eventemitter2'
import WebSocket from 'isomorphic-ws' import WebSocket from 'isomorphic-ws'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@ -31,7 +36,8 @@ export const useRealtimeConnection = (): MessageTransporter => {
return new MockedBackendMessageTransporter(getGlobalState().noteDetails.markdownContent.plain) return new MockedBackendMessageTransporter(getGlobalState().noteDetails.markdownContent.plain)
} else { } else {
logger.debug('Creating Websocket connection...') 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) { if (messageTransporter instanceof WebsocketTransporter && websocketUrl) {
logger.debug(`Connecting to ${websocketUrl.toString()}`) logger.debug(`Connecting to ${websocketUrl.toString()}`)
const socket = new WebSocket(websocketUrl) const socket = new WebSocket(websocketUrl)
socket.binaryType = 'arraybuffer'
socket.addEventListener('error', () => { socket.addEventListener('error', () => {
setTimeout(() => { setTimeout(() => {
establishWebsocketConnection() establishWebsocketConnection()

View file

@ -1702,6 +1702,48 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@codemirror/autocomplete@npm:6.4.2":
version: 6.4.2 version: 6.4.2
resolution: "@codemirror/autocomplete@npm:6.4.2" resolution: "@codemirror/autocomplete@npm:6.4.2"
@ -2320,6 +2362,7 @@ __metadata:
"@types/ws": 8.5.4 "@types/ws": 8.5.4
"@typescript-eslint/eslint-plugin": 5.57.0 "@typescript-eslint/eslint-plugin": 5.57.0
"@typescript-eslint/parser": 5.57.0 "@typescript-eslint/parser": 5.57.0
cbor-x: 1.5.1
eslint: 8.37.0 eslint: 8.37.0
eslint-config-prettier: 8.8.0 eslint-config-prettier: 8.8.0
eslint-plugin-jest: 27.2.1 eslint-plugin-jest: 27.2.1
@ -6753,6 +6796,49 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "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 version: 4.1.2
resolution: "chalk@npm:4.1.2" resolution: "chalk@npm:4.1.2"
@ -13816,6 +13902,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "node-gyp@npm:8.x":
version: 8.4.1 version: 8.4.1
resolution: "node-gyp@npm:8.4.1" resolution: "node-gyp@npm:8.4.1"